765 Commits

Author SHA1 Message Date
mika kuns
50c10b6e75 feat(logging): tag UI task actions with TaskId + debug trace lines 2026-06-04 19:22:08 +02:00
mika kuns
075b6d13af feat(logging): tag Worker task execution with TaskId for traceability 2026-06-04 19:16:56 +02:00
mika kuns
324f1d9c7c feat(logging): wire App/Ui logging to shared LoggingSetup 2026-06-04 19:15:36 +02:00
mika kuns
c592ca32fb feat(logging): route Worker logging through shared LoggingSetup 2026-06-04 19:13:51 +02:00
mika kuns
7ce418d474 feat(logging): shared LoggingSetup with build-config sink branching 2026-06-04 19:11:50 +02:00
mika kuns
ab260ad0a6 feat(logging): runtime Debug-build detection via DebuggableAttribute 2026-06-04 19:09:49 +02:00
mika kuns
b3b87df320 feat(logging): default TaskId enricher with passing tests 2026-06-04 19:07:38 +02:00
mika kuns
da73324e3a build(logging): scaffold ClaudeDo.Logging project 2026-06-04 18:32:28 +02:00
mika kuns
c5a4e350e9 docs(logging): implementation plan for build-config logging + traceability
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 18:27:49 +02:00
mika kuns
e547921fdd docs(logging): runtime build-config detection, Warning in Release, retain 2
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 18:20:37 +02:00
mika kuns
f1316dfd0e docs(logging): design for build-config debug logging + task traceability
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 18:12:46 +02:00
mika kuns
cc7355eaa4 fix(ui): stop app crash when approving review after Merge all
The Details island review commands (Approve/Reject/Park/Cancel) invoked the
hub without catching exceptions. After "Merge all" folds the parent out of
WaitingForReview, pressing Approve made the hub throw a HubException, which
escaped the generated AsyncRelayCommand as an unobserved async-void exception
and crashed the app. Wrap the calls in try/catch like the Tasks island does;
the TaskUpdated broadcast reconciles the UI.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Add these MCP tools:
1. g

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

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

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

1.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:15:25 +02:00
mika kuns
549b87bb74 docs: reflect Startup-shortcut worker autostart
All checks were successful
Release / release (push) Successful in 34s
Replace Windows-service/scheduled-task deployment docs with the Startup-folder
shortcut mechanism and the App's connection-failure prompt.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:39:44 +02:00
845359b885 feat: planning sessions foundation (Plan A) (#4)
Merges Plan A: schema + repos + auto-parent-completion hook.
2026-04-23 16:31:37 +00:00
mika kuns
d4a46420c9 feat(worker): hook TryCompleteParentAsync after MarkDone/MarkFailed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:18:50 +02:00
mika kuns
f704244b84 test(data): parent delete with children is restricted 2026-04-23 18:15:12 +02:00
mika kuns
782110604b fix(data): enable foreign_keys pragma in MigrateAndConfigure 2026-04-23 18:15:06 +02:00
mika kuns
19bf032a2e test(data): queue skips Planning/Planned/Draft 2026-04-23 18:09:29 +02:00
mika kuns
b7464c9a11 feat(data): TaskRepository.TryCompleteParentAsync 2026-04-23 18:08:14 +02:00
mika kuns
524aaf85af feat(data): TaskRepository.DiscardPlanningAsync 2026-04-23 18:04:40 +02:00
mika kuns
a9e7479326 feat(data): TaskRepository.FinalizePlanningAsync 2026-04-23 18:03:10 +02:00
mika kuns
2e80cc606e feat(data): TaskRepository.FindByPlanningTokenAsync 2026-04-23 17:59:42 +02:00
mika kuns
d099138487 feat(data): TaskRepository.UpdatePlanningSessionIdAsync 2026-04-23 17:58:28 +02:00
mika kuns
2278d97b7e feat(data): TaskRepository.SetPlanningStartedAsync 2026-04-23 17:56:19 +02:00
mika kuns
74255ddc82 feat(data): TaskRepository.CreateChildAsync 2026-04-23 17:54:43 +02:00
mika kuns
b466246c1b feat(data): TaskRepository.GetChildrenAsync 2026-04-23 17:52:51 +02:00
mika kuns
b3eb39a28b feat(data): migration AddPlanningSupport 2026-04-23 17:48:10 +02:00
mika kuns
253e6f05e0 feat(data): configure planning columns and self-ref FK with Restrict 2026-04-23 17:45:31 +02:00
mika kuns
042a1b47c2 feat(data): add planning columns and self-ref navigations to TaskEntity 2026-04-23 17:44:55 +02:00
mika kuns
7a20534e7c feat(data): add Planning, Planned, Draft task statuses 2026-04-23 17:44:29 +02:00
mika kuns
ee2cbc92ef feat(ui): move list-settings access from lists pane to tasks header
The gear button on list rows became noisy and overlapped with row
selection. Moves it into the tasks island header where it targets the
currently selected list. Lists pane regains a cleaner row layout.
Also: swallow GetListConfig errors on fresh lists that have no row yet.
2026-04-23 17:40:27 +02:00
mika kuns
373f04a034 build: manage version via MinVer with AssemblyInformationalVersion
Uses MinVer to derive the version from git tags (prefix "v"), stored in
AssemblyInformationalVersion. Program.cs reads that attribute (stripping
the "+sha" build metadata) so the update-check compares against a clean
semver. CI overrides the tag via /p:MinVerVersionOverride=$VERSION.
2026-04-23 17:40:01 +02:00
mika kuns
43d517dcfc docs(plans): add planning sessions implementation plans A, B, C
- Plan A (Foundation): schema, enum, repos, auto-status hook
- Plan B (Worker MCP + Launcher): MCP server, SignalR endpoints, wt.exe launcher
- Plan C (UI): context menu, hierarchy rendering, dialog, client methods

Plans B and C depend on Plan A merging first (marker: migration file
AddPlanningSupport). B and C can run in parallel after A.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:36:02 +02:00
mika kuns
8891d48af2 docs(spec): add planning sessions design
Interactive "Open planning Session" context menu: launches a scoped
MCP-backed Claude CLI session in Windows Terminal, letting the user
brainstorm and Claude break a rough task into concrete child-tasks
under a flat parent-child hierarchy. Includes schema, MCP tool surface,
terminal launch, UI changes, lifecycle, testing, and a three-plan
phasing (A foundation, then B worker + C UI in parallel).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:21:24 +02:00
mika kuns
0b72c0fb53 Merge branch 'feat/self-update'
Self-update for app and installer. Integrates cleanly with the
worker-log-footer feature that landed on main in parallel — the
shell VM now carries both worker-log state and update-check state,
and MainWindow hosts both the update banner and the footer log line.

Conflict resolved in IslandsShellViewModel.cs: kept nullable property
types from main's test-only parameterless constructor work, and added
the UpdateCheck property exposing the injected service.
2026-04-23 15:24:07 +02:00
mika kuns
a41e5b5b2d docs(open): add self-update manual verification checklist 2026-04-23 15:11:39 +02:00
mika kuns
00c62178e1 feat(ui): add update banner and Help menu to MainWindow 2026-04-23 15:10:43 +02:00
mika kuns
bbe7d73de2 feat(ui): wire update-check state and commands into shell VM 2026-04-23 15:05:56 +02:00
mika kuns
0934b294c2 feat(app): register UpdateCheckService and InstallerLocator in DI 2026-04-23 15:03:28 +02:00
mika kuns
b28d8f2f4a feat(ui): show worker log line in footer 2026-04-23 14:59:28 +02:00
mika kuns
ec4ec44603 feat(ui): add worker log state and 30s timer to shell VM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:56:58 +02:00
mika kuns
ee09706811 feat(ui): add InstallerLocator 2026-04-23 14:56:57 +02:00
mika kuns
c06d1d6afb feat(ui): add UpdateCheckService 2026-04-23 14:53:20 +02:00
mika kuns
f906e7086c feat(ui): add WorkerLogLevelToBrushConverter with tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:53:03 +02:00
mika kuns
caf900b02d feat(installer): self-update pre-flight before wizard 2026-04-23 14:49:48 +02:00
mika kuns
e80e3fccc0 feat(ui): subscribe to WorkerLog SignalR event 2026-04-23 14:49:43 +02:00
mika kuns
e8056553fd feat(worker): emit WorkerLog for merge, discard, reset 2026-04-23 14:47:45 +02:00
mika kuns
ea4d2d7c0c feat(worker): emit WorkerLog events from TaskRunner 2026-04-23 14:46:10 +02:00
mika kuns
98c188a5da feat(releases): add SelfUpdater.DownloadAndVerifyAsync 2026-04-23 14:45:13 +02:00
mika kuns
0c3dcb0052 feat(releases): add SelfUpdater.HandleReplaceSelfAsync 2026-04-23 14:42:41 +02:00
mika kuns
e017d66023 feat(releases): add SelfUpdater.DecideUpdateAsync 2026-04-23 14:40:45 +02:00
mika kuns
ba0b38b4f1 feat(releases): add SelfUpdater installer-asset matching 2026-04-23 14:38:20 +02:00
mika kuns
5b4cdd366e refactor(installer): use shared VersionComparer in InstallModeDetector 2026-04-23 14:36:45 +02:00
mika kuns
7c0f8d8408 feat(releases): add VersionComparer 2026-04-23 14:21:25 +02:00
mika kuns
0a7fcae137 feat(worker): add WorkerLog SignalR event 2026-04-23 14:19:04 +02:00
mika kuns
5346737e2b test(releases): port ReleaseClient + ChecksumVerifier tests to new project
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:18:17 +02:00
mika kuns
80f6669585 feat(data): add WorkerLogLevel enum 2026-04-23 14:17:31 +02:00
mika kuns
27054e6715 chore: ignore .worktrees/ for local dev worktrees 2026-04-23 14:15:53 +02:00
mika kuns
ea7694566d docs(superpowers): add worker-log footer implementation plan 2026-04-23 14:11:39 +02:00
mika kuns
46e01aefed refactor(releases): move release-API + checksum types to ClaudeDo.Releases 2026-04-23 14:09:40 +02:00
mika kuns
41e0bea162 docs(superpowers): add worker-log footer implementation plan 2026-04-23 14:03:09 +02:00
mika kuns
86012e02b9 feat(releases): add empty ClaudeDo.Releases library 2026-04-23 13:59:45 +02:00
mika kuns
da19eb807b docs(superpowers): add worker-log footer design spec 2026-04-23 13:56:09 +02:00
mika kuns
0d37473575 docs(self-update): add implementation plan 2026-04-23 13:52:36 +02:00
mika kuns
6a4bf676ff docs(self-update): add design spec for app + installer self-update 2026-04-23 13:44:52 +02:00
mika kuns
a135485339 docs(superpowers): add default-agents plan and design spec 2026-04-23 13:08:23 +02:00
mika kuns
3c420acd54 style(ui): polish list sidebar, kbd chips, and session terminal
- Introduce .list-count style for sidebar badges (brighter on active row).
- Bind list header More button to the correct OpenSettingsCommand.
- Give the per-list gear the standard icon-btn look.
- Center-align kbd chip content and title-bar/icon button content.
- Drop the kind-marker column in SessionTerminal and always show the
  scrollbar so the terminal feels like one.
2026-04-23 13:08:17 +02:00
mika kuns
5ced1b97a6 refactor(ui): redesign list settings and merge modals with custom chrome
Both modals now use SystemDecorations=None with a draggable title bar,
sectioned layout matching the rest of the island shell, Escape-to-cancel,
and themed brushes instead of hard-coded colours. ListSettings adds a
Browse... button that reads agent frontmatter from arbitrary .md files.
2026-04-23 13:08:09 +02:00
mika kuns
1344beba56 fix(ui): select task on left-click even when reorder is disabled
The tunnel pointer handler returned early when CanReorder was false,
so clicking a row in smart/virtual lists never updated the details
pane. Select first, then bail out of the drag path; also skip drag
initialisation on nested buttons so the done-toggle click still fires.
2026-04-23 13:08:02 +02:00
mika kuns
c8c8bb4a47 feat(ui): replay persisted task log when selecting a task
Read the task's LogPath on selection and feed each line through the
live-stream parser so Claude output stays visible across app restarts.
Tail-caps at 2000 lines to avoid flooding the UI.
2026-04-23 13:07:54 +02:00
mika kuns
6f725d12f5 feat(ui): add queueing and scheduling from task row context menu
- Right-click on a task row exposes Send to queue / Remove from queue
  and Schedule for... / Clear schedule actions.
- New virtual:queued list in the sidebar with live count.
- Sidebar counts are now computed (open per list, running, queued,
  review) and refreshed on task- and worker-side events.
- Sending a task to the queue wakes the worker so it starts immediately.
2026-04-23 13:07:48 +02:00
mika kuns
9952ff98f2 feat(ui): use ClaudeTask icon for window and taskbar
Expose the .ico as an Avalonia resource, set it on MainWindow, and
swap the custom SystemDecorations=None for BorderOnly so the icon
appears on the taskbar and window can be resized normally.
2026-04-23 13:07:38 +02:00
mika kuns
4a6d96b90e feat(installer): show version info and offer worker restart in settings
- Surface Latest version and flag unparseable pre-release tags in
  VersionLabel so users know why auto-update was skipped.
- Prompt to stop/start the worker service after Save, since the
  worker only reads its config at process start.
2026-04-23 13:07:31 +02:00
mika kuns
2690332d13 feat(installer): record data directory in install manifest
Write the resolved DbPath parent into the manifest so UninstallRunner
can honour customised data locations instead of always assuming
~/.todo-app. Older manifests fall back to the default path.
2026-04-23 13:07:23 +02:00
mika kuns
31218fc205 feat(installer): harden database init and service setup steps
- InitDatabaseStep: create DbPath parent directory so custom paths work
- RegisterServiceStep: pass obj= argument so ServiceAccount is honoured
- StartServiceStep: poll for RUNNING state so downstream steps don't race
2026-04-23 13:07:16 +02:00
mika kuns
cc01871407 chore(settings): allow context7 MCP tools 2026-04-23 13:07:07 +02:00
mika kuns
e70ae7f6ce feat(ui): add Restore default agents button to Settings modal 2026-04-23 12:21:02 +02:00
mika kuns
1830273a9d feat(ui): add RestoreDefaultAgentsAsync to WorkerClient 2026-04-23 12:19:44 +02:00
mika kuns
1a10e6fa09 feat(worker): expose RestoreDefaultAgents hub method 2026-04-23 12:18:49 +02:00
mika kuns
df57c2bc05 feat(worker): seed default agents on startup 2026-04-23 12:15:28 +02:00
mika kuns
990be09bd7 feat(worker): add DefaultAgentSeeder for first-launch agent seeding
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:12:55 +02:00
mika kuns
e275f67a5e build(worker): ship DefaultAgents folder in build output 2026-04-23 12:09:13 +02:00
mika kuns
ff3de1d100 feat(worker): add bundled default agent definitions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:08:16 +02:00
Mika Kuns
a4e313dbad improve Frontend 2026-04-22 17:09:00 +02:00
Mika Kuns
7de5510735 fix(ui): session terminal scrolls to end after layout so last line is fully visible 2026-04-22 15:42:02 +02:00
Mika Kuns
5e54275842 fix(ui): pin AgentStrip above metadata footer, terminal sits above it 2026-04-22 15:38:34 +02:00
Mika Kuns
6ac88235a7 fix(ui): session terminal auto-sizes to output, caps at 420px before scrolling 2026-04-22 15:34:47 +02:00
Mika Kuns
c599fdcb8c refactor(ui): single scrollable DetailsIsland body with agent-settings gear flyout, remove Notes 2026-04-22 15:16:40 +02:00
Mika Kuns
b0b15e474e feat(ui): always-visible Steps section at top of DetailsIsland with add-step input 2026-04-22 15:08:07 +02:00
Mika Kuns
839f862b7d fix(ui): move agent-settings expander out of capped scroller so it expands properly 2026-04-22 13:50:35 +02:00
Mika Kuns
2901a769d8 fix(ui): use PlaceholderText instead of obsolete Watermark in ListSettingsModalView 2026-04-22 13:33:49 +02:00
Mika Kuns
e74e7eecf4 docs: refresh CLAUDE.md files for agent settings UI 2026-04-22 13:31:28 +02:00
Mika Kuns
bba577888b feat(ui): per-task agent settings in DetailsIsland
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 13:29:57 +02:00
Mika Kuns
5784dbee94 feat(ui): open ListSettingsModal via context menu and gear button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 13:27:05 +02:00
Mika Kuns
5348220e60 feat(ui): add ListSettingsModalView 2026-04-22 13:22:39 +02:00
Mika Kuns
cd0b95ef9a feat(ui): add ListSettingsModalViewModel 2026-04-22 13:20:42 +02:00
Mika Kuns
fc1cfe59ec feat(ui): WorkerClient supports list/task agent settings + ListUpdated event 2026-04-22 13:18:16 +02:00
Mika Kuns
7c312161bb feat(worker): add hub methods for list and task agent settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 13:16:46 +02:00
Mika Kuns
480eb0817a feat(data): add TaskRepository.UpdateAgentSettingsAsync 2026-04-22 13:10:21 +02:00
Mika Kuns
1b94fa5c44 feat(data): add ListRepository.DeleteConfigAsync 2026-04-22 13:09:03 +02:00
Mika Kuns
02464b7f89 docs(plans): agent settings UI implementation plan 2026-04-22 12:06:31 +02:00
Mika Kuns
68f461d0e1 docs(specs): agent settings per list and per task UI reimplementation 2026-04-22 12:01:20 +02:00
Mika Kuns
cfb410dd4d Merge task: Improve the Readme 2026-04-22 11:34:13 +02:00
Mika Kuns
b378fbf550 chore(settings): allow context-mode MCP tools locally 2026-04-22 11:03:43 +02:00
Mika Kuns
cb43bcdd10 docs(plans): add 2026-04-21 open-items consolidation 2026-04-22 11:03:40 +02:00
Mika Kuns
31420574db feat(ui): show status messages and real diff-stats in DiffModal
- Count additions/deletions per file as lines are parsed.
- Surface load failures and empty-diff states via StatusMessage.
- Pass the worktree base commit so diffs render against the branch
  base, not just the working-tree HEAD.
2026-04-22 11:03:37 +02:00
Mika Kuns
07dee31847 fix(data): use UTF-8 encoding for git process stdio
Ensures non-ASCII git output (branch names, paths, commit messages) is
read and written without locale-dependent corruption.
2026-04-22 11:03:24 +02:00
Mika Kuns
4debd5ce09 fix(ui): disable Merge button after worktree is no longer Active
Add WorktreeStateLabel observable property populated from
entity.Worktree?.State.ToString() in both BindAsync and
RefreshWorktreeAsync. CanMerge now requires WorktreeStateLabel == "Active"
so the button disables after a successful merge with removeWorktree:false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 10:36:09 +02:00
Mika Kuns
1495c63e3d fix(worker): return Blocked when MergeAbortAsync fails to avoid stuck repo
If git merge --abort throws, the repo is left mid-merge. Previously the
code logged a warning and returned a conflict result, giving the UI a
stale file list. Now it returns Blocked with an explicit message so the
caller knows manual resolution is required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 10:30:44 +02:00
Mika Kuns
953d93179d fix(worker): honour targetBranch in MergeAsync by checking out before merge
Add GitService.CheckoutBranchAsync; compare targetBranch to current HEAD
before MergeNoFfAsync and switch when they differ. Returns Blocked if the
branch does not exist. Add three new tests (two service, one GitService).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 10:25:35 +02:00
Mika Kuns
1bc7fcc609 feat(ui): add Merge button to DiffModal 2026-04-22 09:53:11 +02:00
Mika Kuns
c911717a3b feat(ui): add Merge command to DiffModal 2026-04-22 09:53:07 +02:00
Mika Kuns
949911f6c8 feat(ui): attach MergeModal to DetailsIsland 2026-04-22 09:53:03 +02:00
Mika Kuns
f3a58a6515 feat(ui): wire DetailsIsland ApproveMerge through MergeModal 2026-04-22 09:52:59 +02:00
Mika Kuns
ee4cd706ef chore(app): register MergeModalViewModel 2026-04-22 09:47:19 +02:00
Mika Kuns
e11b01951e feat(ui): add MergeModalView 2026-04-22 09:46:33 +02:00
Mika Kuns
3d0cc4ffed feat(ui): add MergeModalViewModel 2026-04-22 09:46:20 +02:00
Mika Kuns
4585b20f80 feat(ui): add MergeTaskAsync and GetMergeTargetsAsync to WorkerClient 2026-04-22 09:44:48 +02:00
Mika Kuns
c53b5878cf feat(worker): expose MergeTask and GetMergeTargets on WorkerHub 2026-04-22 09:44:22 +02:00
Mika Kuns
c13ae437f7 chore(worker): register TaskMergeService 2026-04-22 09:43:34 +02:00
Mika Kuns
5780879629 test(worker): cover GetTargetsAsync and dirty-tree block 2026-04-22 09:41:31 +02:00
Mika Kuns
2bcd5ef9bd test(worker): cover merge conflict auto-abort 2026-04-22 09:41:31 +02:00
Mika Kuns
63eb860e40 test(worker): cover diverged non-conflicting merge 2026-04-22 09:41:30 +02:00
Mika Kuns
e80ac7de49 test(worker): cover TaskMergeService removeWorktree path 2026-04-22 09:41:30 +02:00
Mika Kuns
3331c24898 feat(worker): implement TaskMergeService happy path 2026-04-22 09:37:35 +02:00
Mika Kuns
1c20d8f846 feat(worker): scaffold TaskMergeService with pre-flight checks 2026-04-22 09:36:16 +02:00
Mika Kuns
77a1460e3a feat(git): add ListConflictedFilesAsync 2026-04-22 09:31:36 +02:00
Mika Kuns
21a1870fd7 feat(git): add MergeAbortAsync 2026-04-22 09:29:24 +02:00
Mika Kuns
3ebbdb3f6e feat(git): add MergeNoFfAsync returning (exitCode, stderr) 2026-04-22 09:27:47 +02:00
Mika Kuns
535d0c5558 feat(git): add IsMidMergeAsync 2026-04-22 09:25:10 +02:00
Mika Kuns
2d807aa606 feat(git): add ListLocalBranchesAsync 2026-04-22 09:23:35 +02:00
Mika Kuns
93ee7b72d5 feat(git): add GetCurrentBranchAsync 2026-04-22 09:22:41 +02:00
Mika Kuns
32ef1b389a docs: clarify merged-with-cleanup-warning result shape 2026-04-22 09:17:43 +02:00
Mika Kuns
0885518a68 docs: add worktree merge implementation plan
23 TDD-sized tasks covering GitService additions, TaskMergeService,
SignalR surface, MergeModal view/vm, and wiring into DetailsIsland
plus DiffModal. Each task: failing test -> implement -> green -> commit.
2026-04-22 09:16:38 +02:00
Mika Kuns
944d3bd3e8 docs: add worktree merge design spec
Captures decisions for merging a task worktree into a target branch:
merge-commit strategy, dual UI entry (Details island + DiffModal),
per-merge cleanup checkbox, pre-flight + abort-on-conflict.
2026-04-22 09:08:44 +02:00
Mika Kuns
fb89e02b02 docs: note ResetTask hub method and TaskResetService 2026-04-21 17:46:00 +02:00
Mika Kuns
58c8210afa fix(ui): correct Reset button tooltip wording 2026-04-21 17:44:50 +02:00
Mika Kuns
2ce6b7bd3a feat(ui): add Continue and Reset buttons to agent strip 2026-04-21 17:44:00 +02:00
Mika Kuns
f90d3d8375 fix(ui): early-return in ResetAsync when ConfirmAsync is unwired 2026-04-21 17:42:36 +02:00
Mika Kuns
b03e858a8f feat(ui): add Continue and Reset commands to DetailsIslandViewModel 2026-04-21 17:40:32 +02:00
Mika Kuns
2278b516ea feat(ui): add ContinueTaskAsync and ResetTaskAsync to WorkerClient 2026-04-21 17:37:41 +02:00
Mika Kuns
219a231f32 feat(worker): expose ResetTask hub method
Wire TaskResetService into DI and add WorkerHub.ResetTask with the
same InvalidOperationException/KeyNotFoundException error-translation
pattern as ContinueTask.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:35:37 +02:00
Mika Kuns
74eb36d3c0 feat(worker): add TaskResetService for discard + reset flow
Orchestrates worktree discard, task reset to Manual, and SignalR broadcast.
Includes integration tests (happy path + running-task rejection).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:31:52 +02:00
Mika Kuns
202236a45b feat(data): add TaskRepository.ResetToManualAsync 2026-04-21 17:26:01 +02:00
Mika Kuns
88be19a231 test(worker): strengthen DiscardAsync test (cleanup + branch assertion) 2026-04-21 17:23:58 +02:00
Mika Kuns
44203f3c67 feat(worker): add WorktreeManager.DiscardAsync for task reset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:21:08 +02:00
Mika Kuns
133774cb86 docs: add implementation plan for continue and reset buttons 2026-04-21 16:47:51 +02:00
Mika Kuns
a3bb557d76 docs: add spec for continue and reset buttons on failed tasks 2026-04-21 16:43:54 +02:00
Mika Kuns
23f8fddc4d docs: add UI-rewrite notes, plans, and stream-formatter spec 2026-04-21 15:56:19 +02:00
Mika Kuns
a180e8446c chore(worker): tweak launchSettings 2026-04-21 15:56:14 +02:00
Mika Kuns
0406d35b61 style(ui): polish islands and remove terminal traffic-light dots 2026-04-21 15:56:07 +02:00
Mika Kuns
e6b37624a1 feat(ui): add settings modal and wire to worker hub 2026-04-21 15:55:53 +02:00
Mika Kuns
fca5d57fef feat(worker): extend ClaudeArgsBuilder with MaxTurns and PermissionMode 2026-04-21 15:55:40 +02:00
Mika Kuns
cfb9ca1ca4 feat(worker): add WorktreeMaintenanceService for idle-worktree cleanup 2026-04-21 15:55:35 +02:00
Mika Kuns
62a1121571 feat(data): add AppSettings entity, migration, and repository 2026-04-21 15:55:29 +02:00
Mika Kuns
4283c67d81 fix(worker): prefix broadcast lines with [stdout] so UI parser routes them 2026-04-21 15:35:40 +02:00
Mika Kuns
883c98dc0a chore(claude-do): Improve the Readme
ClaudeDo-Task: 2d1915154c1448118a7e0158c13de113
2026-04-21 15:31:26 +02:00
Mika Kuns
374e811e78 feat(ui): render user tool_result blocks as one-line summaries 2026-04-21 15:13:00 +02:00
Mika Kuns
ec679e45ed fix(ui): truncate WebFetch URL in tool_use arg 2026-04-21 15:11:17 +02:00
Mika Kuns
3a67fe81b4 feat(ui): render assistant tool_use blocks with per-tool args 2026-04-21 15:08:35 +02:00
Mika Kuns
dc6e3fe442 feat(ui): render assistant text blocks, skip thinking 2026-04-21 15:05:40 +02:00
Mika Kuns
b525498770 feat(ui): format system init message in StreamLineFormatter 2026-04-21 15:03:29 +02:00
Mika Kuns
668087cda4 refactor(ui): skeleton dispatch for StreamLineFormatter rewrite 2026-04-21 15:00:00 +02:00
Mika Kuns
b4741137d0 docs(settings-modal): add design spec
Approved design for a general-settings modal behind the user-footer ⋯ button: Claude defaults, worktree defaults + maintenance actions, About section.
2026-04-21 13:32:58 +02:00
mika kuns
e19a9d373e fix(ui): filled window icons, boxed task rows, proper explorer button
- Window controls (min/close) were stroke-only paths — PathIcon fills
  geometries, so only max rendered. Swap all three (and BrandCheck) to
  closed filled shapes.
- Task rows now have a 1px LineBrush border + rounded 8px box with
  subtle AccentSoft hover, matching the mock's visible row separation.
- "Open in explorer" button switched from icon-btn to btn class so it
  matches "Open diff" / "Worktree" framing, with a proper Lucide-style
  filled arrow geometry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:02:08 +02:00
mika kuns
42fb7cee0d fix(ui): NAVIGATOR eyebrow — drop broken converter binding
The Binding had no Path, so it bound to ListsIslandViewModel itself
and the UpperCase converter produced the fully-qualified type name,
which rendered as "CLAUDEDO.UI.VIEWMODELS.ISLAI..." in the header.
Replace with literal Text="NAVIGATOR".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:54:36 +02:00
mika kuns
5acc896d5c fix(ui): wire delete confirm, close-details, uppercase eyebrow, explorer button
- A1: list-name eyebrow runs through UpperCase converter.
- D2: + Open-in-explorer icon button in AgentStrip (Process.Start on worktree path).
- D4: DeleteTaskCommand prompts inline confirm Window before deleting; shell
  wires Details.CloseDetail to clear Tasks.SelectedTask and Details.DeleteFromList
  to reload the current list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:47:10 +02:00
mika kuns
9b1178ca2f style(ui): subtasks, notes, details metadata footer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:39:40 +02:00
mika kuns
01af8cb7d7 style(ui): session terminal header, line columns, LIVE chip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:36:39 +02:00
mika kuns
c3f077e3b6 style(ui): agent strip with worktree panel and diff meter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:35:33 +02:00
mika kuns
b64ff3d908 style(ui): details header with logbook eyebrow and task-id badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:34:28 +02:00
mika kuns
82f2d526a0 style(ui): task section dividers overdue/tasks/completed
Expose OverdueItems / OpenItems / CompletedItems as separate observable
collections recomputed in LoadForList (and after add / toggle-done).

- OverdueItems: ScheduledFor.Date < Today && !Done
- OpenItems:    remaining !Done
- CompletedItems: Done

View renders three sections with eyebrow-style headers:
- OVERDUE (blood accent, only when non-empty)
- TASKS (shown only when overdue is also visible, matching the mock)
- COMPLETED · N (hidden when IsShowingCompleted is false)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:31:45 +02:00
mika kuns
0ef7113958 style(ui): task row chip set, selected/done states, live tail
- Expose IsOverdue, Tags, StepsCount/StepsCompleted, DiffAdditions/Deletions
  on TaskRowViewModel; parse DiffStat into numeric add/del.
- Rebuild TaskRowView with explicit chip set: status, list-with-dot, branch
  (GitBranch icon + mono), diff (+N moss / −M blood), per-tag chips.
- Selected row shows 2px accent left bar + AccentSoft background.
- Done rows dim to 0.55 opacity with faint title plus strikethrough.
- Live-tail row: mono 11px ellipsized text + slim 3px moss progress bar,
  visible only when Status=Running and HasLiveTail.
- task-row Border now transitions Background and Margin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:30:47 +02:00
mika kuns
940b72f8dd style(ui): tasks header toolbar and add-task row
- Reformat subtitle to "{Weekday}, {Month} {Day} · {open} open".
- Add right-aligned running/review status pill (kbd style).
- Add header icon toolbar: Sort, Eye (toggle completed), MoreHorizontal.
- Wire Eye to IsShowingCompleted [ObservableProperty] on the VM.
- Style add-task row as rounded Surface2 border with dashed Plus circle,
  borderless TextBox, and ENTER kbd chip visible on focus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:30:32 +02:00
mika kuns
287e098c3a style(ui): lists search icon, kbd hint, footer actions
Rewrites ListsIslandView: NAVIGATOR eyebrow, search bar with PathIcon+borderless TextBox+kbd chip,
two ItemsControls for SmartLists/UserLists with icon/dot + active left-bar,
+ New list button, and footer profile row (avatar initials, username, machine/local, MoreHorizontal button).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:22:41 +02:00
mika kuns
4531b95c42 style(ui): lists icons, section headers, active state
- Add central icon library (Icon.Sun/Activity/Star/Calendar/Eye/Inbox/Folder/Search/Plus/MoreHorizontal/GitBranch/Copy/Trash/Sort/X/Check) to IslandStyles.axaml
- Add list-section-label, search-wrap, kbd, new-list-btn, avatar-circle styles
- Add UpperCaseConverter, IconKeyConverter, DotBrushConverter; register in App.axaml
- Expose SmartLists / UserLists filtered collections from ListsIslandViewModel
- Add DotColorKey (Moss/Peat/Sage rotation) and UserInitials/UserName/MachineName props

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:22:35 +02:00
mika kuns
0bf2d78fba style(ui): background gradient and stronger island shadow
Add a RadialGradientBrush border (DeepBrush center → VoidBrush edges)
filling the island row. Island BoxShadow was already set to IslandShadow
token — no change needed there.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:17:17 +02:00
mika kuns
480d05975d style(ui): custom title bar with brand and window controls
Replace character-glyph window buttons with PathIcon controls using
Lucide-style StreamGeometry. Add left brand block (check glyph +
CLAUDEDO + separator dot + selected list name) and draggable middle
strip. Close button hover turns BloodBrush.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:16:39 +02:00
mika kuns
27c6a4b859 fix(ui): drop icon-btn sizing from AgentStrip text buttons
Switched the four text-label buttons (Open diff, Worktree, Stop,
Approve & merge) from Classes="icon-btn" (24x24 fixed size) to
Classes="btn" so content is not clipped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:01:14 +02:00
mika kuns
2d1a4881aa fix(ui): use Tag-attribute selectors for terminal log colors
Rewrote all Border.terminal TextBlock log-kind selectors from CSS
class form (.log-sys) to attribute form ([Tag=log-sys]), matching
the pattern used by the diff-line selectors. Classes are not set on
dynamically created TextBlocks; Tag binding works.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:01:07 +02:00
mika kuns
62aac7eedb fix(ui): guard Bind/LoadForList against interleaved DbContext awaits
Added CancellationTokenSource per-load in both DetailsIslandViewModel
and TasksIslandViewModel. Public entry points cancel any in-flight load
before starting a new one. DB calls and collection mutations after awaits
are guarded with ThrowIfCancellationRequested. DetailsIslandViewModel
now sets _subscribedTaskId only after the DB confirms the entity exists,
preventing the SignalR handler from routing messages to a stale load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:00:58 +02:00
mika kuns
279f2c7598 fix(ui): wire modal delegates from DetailsIslandView owner
Moved ShowDiffModal/ShowWorktreeModal delegate wiring from
AgentStripView (child, possibly detached) to DetailsIslandView
(the VM's DataContext owner). TopLevel is resolved at invocation
time, not at wiring time, so attachment order no longer matters.
AgentStripView reduced to InitializeComponent() only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:00:45 +02:00
mika kuns
95146518b2 fix(ui): remove stale brush overrides in App.axaml
Deleted inline SolidColorBrush resources whose keys collide with
Tokens.axaml (AccentBrush, TextDimBrush, etc.) so the moss-green
palette from the merged token file takes effect. Updated the three
ListBoxItem styles to reference AccentGlowBrush/AccentSoftBrush
instead of hard-coded hex.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:00:28 +02:00
mika kuns
eee98b7828 fix(app): restore ViewModels using for IslandsShellViewModel 2026-04-20 10:44:31 +02:00
mika kuns
5a17a727b9 chore(ui): remove obsolete pre-rewrite views and viewmodels
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:41:14 +02:00
mika kuns
6dade011b0 feat(ui): keyboard shortcuts (/ Ctrl+N Space Esc)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:37:52 +02:00
mika kuns
47e8e1ff94 feat(ui): pulse, hover, modal, and row-add animations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:33:39 +02:00
mika kuns
abd7733c90 feat(ui): worktree modal with tree view and M/A badges
Adds WorktreeModalView/ViewModel showing git status --porcelain as a
recursive file tree with M/A/D/? status badges. Wires the Worktree
button in AgentStripView to OpenWorktreeCommand on DetailsIslandViewModel.
Adds GetStatusPorcelainAsync to GitService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:31:12 +02:00
mika kuns
4d68543cf2 feat(ui): diff modal with file sidebar and tinted hunks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:30:03 +02:00
mika kuns
f94bb35db7 feat(ui): tasks island with rows, chips, add-task, selection
TaskRowView with status chip (EqStatus converter + parameter),
StrikeIfTrue, NotNullToBool converters. TasksIslandView with header,
add-task TextBox (Enter=AddCommand), ItemsControl + flat Button for
selection. Converters registered in App.axaml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:24:36 +02:00
mika kuns
4f41b084fa feat(ui): details island with agent strip, terminal, subtasks, notes
Adds AgentStripView (status/model/turns/tokens row, worktree path,
branch line, action buttons), SessionTerminalView (scrollable log with
auto-scroll on CollectionChanged, prompt TextBox with Enter binding),
and replaces DetailsIslandView placeholder with full ScrollViewer layout
containing editable title, agent strip, terminal, subtasks, notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:23:04 +02:00
mika kuns
fcf53ab4f5 feat(ui): DetailsIslandViewModel with agent state and log
Implements LogLineViewModel (LogKind enum + ClassName), full
DetailsIslandViewModel (editable title, notes, prompt, agent strip
fields, Log/Subtasks collections, Bind method, SendPromptCommand,
ApproveMergeCommand, StopCommand). Wires TaskMessageEvent for live log.
Updates Program.cs DI for new IDbContextFactory + WorkerClient deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:22:57 +02:00
mika kuns
0034accb4f feat(ui): TasksIslandViewModel with smart/virtual/user filtering
Uses IDbContextFactory directly (singleton-safe). Adds ToggleDone,
ToggleStar, Select commands. Fixes pre-existing SessionTerminalView
compiled-binding error on Classes property via x:CompileBindings="False".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:22:11 +02:00
mika kuns
f167120c90 feat(ui): Lists island view with search and nav items
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:21:12 +02:00
mika kuns
dc1b648b4c feat(ui): TaskRowViewModel with status chip mapping
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:20:57 +02:00
mika kuns
06cc141176 feat(ui): ListsIslandViewModel with smart/virtual/user lists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:19:58 +02:00
mika kuns
05404f46f2 feat(ui): chromeless three-island shell 2026-04-20 10:17:20 +02:00
mika kuns
8909119d1b feat(ui): scaffold islands shell and child VMs 2026-04-20 10:15:05 +02:00
mika kuns
55917c921a feat(ui): merge Tokens and IslandStyles into App 2026-04-20 10:12:49 +02:00
mika kuns
1893576b6a feat(ui): embed Inter Tight and JetBrains Mono fonts
Adds variable-weight TTF files (from google/fonts) as Avalonia resources
under Assets/Fonts/. OFL license files included for both families.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:11:06 +02:00
mika kuns
9a05907170 refactor(data): centralize list seeding in MigrateAndConfigure, add default-value test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:07:12 +02:00
mika kuns
92a6e0642e feat(ui): add design Tokens resource dictionary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:06:10 +02:00
mika kuns
579b527dcd feat(ui): add island control styles 2026-04-20 10:05:28 +02:00
mika kuns
bd8a4d0565 feat(data): seed default Lists (My Day, Important, Planned) 2026-04-20 10:02:07 +02:00
mika kuns
928dde1358 feat(data): migration for IsStarred/IsMyDay/Notes columns 2026-04-20 09:59:55 +02:00
mika kuns
a1190a35bd feat(data): add IsStarred, IsMyDay, Notes to TaskEntity 2026-04-20 09:59:12 +02:00
mika kuns
eff1045e63 General Changes 2026-04-20 09:54:13 +02:00
mika kuns
2a8cd97d02 fix(installer): expand ~ in UiDbPath
All checks were successful
Release / release (push) Successful in 29s
2026-04-17 14:33:30 +02:00
mika kuns
09e8b1f10b fix(ui): init editor TCS before dialog can complete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:32:46 +02:00
mika kuns
92d8d902df fix(ui): reset stale worktree state on TaskDetail reload 2026-04-17 14:31:24 +02:00
mika kuns
aa1008dcff fix(ui): capture CurrentListId before await in AddTask 2026-04-17 14:30:35 +02:00
mika kuns
5f3d41e1f6 fix(installer): make user-data deletion on uninstall opt-in
Add bool removeAppData parameter (default false) to UninstallRunner.RunAsync,
gate ~/.todo-app deletion on it, surface a checkbox in SettingsWindow, and
update the confirmation message to reflect whether data will be removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:29:35 +02:00
mika kuns
7d48f34b15 fix(installer): rollback-safe extract with .bak stash 2026-04-17 14:27:45 +02:00
mika kuns
51a1bbe6b8 fix(installer): move service start out of RegisterServiceStep 2026-04-17 14:26:34 +02:00
mika kuns
ad7c9facaf fix(worker): escape newline/tab in CLI args 2026-04-17 14:25:15 +02:00
mika kuns
11a4376da5 fix(worker): guard against same task in queue and override slot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:23:08 +02:00
mika kuns
f10ad69863 fix(installer): reject CurrentUser service account without password 2026-04-17 14:20:09 +02:00
mika kuns
dc4571a338 fix(ui): swallow DB errors in TaskListViewModel.OnTaskUpdated 2026-04-17 14:19:11 +02:00
mika kuns
4fb6ba6be8 fix(worker): emit RunCreated after run row exists
Remove premature RunCreated broadcast from WorkerHub.RunNow and the
duplicate calls in RunAsync retry block and ContinueAsync. RunOnceAsync
now owns the broadcast for every run, fired immediately after the row
insert so the UI never receives an event for a non-existent row.
2026-04-17 14:17:00 +02:00
mika kuns
3423919655 fix: resolve critical bugs and improve reliability across worker, data, UI
- Fix worker using wrong DB by defaulting to CurrentUser service account
  and expanding ~ to absolute paths at install time
- Fix DbContext disposed before fire-and-forget by passing taskId instead
  of TaskEntity into RunInSlotAsync, which creates its own context
- Fix ActiveTaskDto property casing mismatch between hub and client
- Move WAL mode PRAGMA before migrations to prevent concurrent lock issues
- Replace FirstAsync with FirstOrDefaultAsync + null guards in tag operations
- Add delete confirmation flow for lists
- Log fire-and-forget exceptions instead of swallowing them
- Broadcast RunCreated event from WorkerHub.RunNow
- Add IDisposable to MainWindowViewModel for event handler cleanup
- Preserve subtask CreatedAt on updates instead of overwriting
- Replace bare catch blocks with Debug.WriteLine logging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:12:59 +02:00
mika kuns
fca2bdb596 Merge remote-tracking branch 'origin/main' 2026-04-16 12:26:50 +02:00
mika kuns
721f0cd903 Merge branch 'feat/subtask-tree-view' 2026-04-16 12:17:46 +02:00
mika kuns
32bb52875f feat(ui): add subtask tree view with expand/collapse in task list
Tasks with subtasks show a chevron for inline expand/collapse.
Subtask checkboxes toggle completion state directly. Also sets
Windows AppUserModelID for proper taskbar identity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:16:22 +02:00
mika kuns
4f25c3dd40 docs: add subtask tree view design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 10:18:04 +02:00
33fedc7e26 Merge pull request 'feat/efcore-migration' (#3) from feat/efcore-migration into main
All checks were successful
Release / release (push) Successful in 30s
Reviewed-on: #3
2026-04-16 07:38:59 +00:00
mika kuns
4ca48044db chore: add design-time.db to gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:10:53 +02:00
mika kuns
611454df1e fix(data): address code review findings
- Fix sort order regression in GetByListIdAsync (was descending, should be ascending)
- Restore WAL mode pragma (was silently dropped in EF migration)
- Add existing-DB compatibility shim in MigrateAndConfigure (baselines InitialCreate
  migration for databases created by the old schema.sql)
- Remove dead AddDbContext/AddScoped registrations from Worker (only IDbContextFactory
  is used by singleton consumers)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:10:35 +02:00
mika kuns
8d61b05179 docs: update CLAUDE.md files for EF Core migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:03:15 +02:00
mika kuns
7d0ca45a60 refactor(installer): switch InitDatabaseStep to EF Core migrations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:01:50 +02:00
mika kuns
36484ed45a feat(worker,ui): wire EF Core into DI and update all consumers to IDbContextFactory
Worker and App Program.cs: replace SqliteConnectionFactory+SchemaInitializer
with AddDbContextFactory<ClaudeDoDbContext> + Database.Migrate(). Repos
changed from AddSingleton to AddScoped.

All singleton services (QueueService, StaleTaskRecovery, WorktreeManager,
TaskRunner) and singleton ViewModels (MainWindowViewModel, TaskDetailViewModel,
TaskListViewModel, TaskEditorViewModel) now take IDbContextFactory<ClaudeDoDbContext>
and create short-lived contexts per operation.

Test infrastructure: DbFixture now uses EF migrations instead of SchemaInitializer;
all test classes create contexts via DbFixture.CreateContext().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:59:24 +02:00
mika kuns
b7be52a623 chore(data): remove raw ADO.NET infrastructure, add EF migration and design-time factory
Delete SqliteConnectionFactory, SchemaInitializer, and schema.sql.
Fix ValueConverter lambdas in entity configurations (no throw-expressions
in expression trees). Add IDesignTimeDbContextFactory for dotnet-ef tooling.
Generate InitialCreate migration with seed data for agent/manual tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:59:06 +02:00
mika kuns
34ca1b018f feat(data): rewrite all repositories to use EF Core ClaudeDoDbContext
Replace raw ADO.NET implementations with EF Core LINQ queries and
ExecuteUpdate/ExecuteDelete for bulk operations. TaskRepository preserves
FlipAllRunningToFailedAsync(reason) signature and keeps raw SQL for the
atomic queue claim (UPDATE...RETURNING). GetByListAsync alias kept for
backwards compat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:58:57 +02:00
mika kuns
51a5dcbb73 feat(data): add ClaudeDoDbContext with Fluent API configurations 2026-04-16 08:37:37 +02:00
mika kuns
f8f13865d2 feat(data): add navigation properties to all entity models 2026-04-16 08:36:22 +02:00
mika kuns
a064865417 chore(data): swap Microsoft.Data.Sqlite for EF Core packages 2026-04-16 08:35:40 +02:00
mika kuns
9236ca6d45 docs(data): add EF Core migration implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:32:18 +02:00
mika kuns
9e1f1370bb docs(data): add EF Core migration design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:20:33 +02:00
Mika Kuns
3b1f148122 feat(branding): add app and installer icons
All checks were successful
Release / release (push) Successful in 27s
- app: ClaudeTask.ico wired as ApplicationIcon and MainWindow.Icon
  via avares URI
- installer: ClaudeTaskSetup.ico wired as ApplicationIcon and embedded
  as a WPF Resource so WizardWindow and SettingsWindow can reference it
  via pack URI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:28:15 +02:00
Mika Kuns
2b3fe02d8c fix(ui): prevent async void races and leak-on-exit
- task detail vm: LoadAsync now uses a per-call CancellationTokenSource
  so rapid TaskUpdated events can't race on _taskId / Subtasks / Tags;
  old subtask PropertyChanged handlers are torn down before Clear
- task detail vm: async void event handlers (OnTaskUpdated,
  OnWorktreeUpdated, OnSubtaskPropertyChanged) wrap work in try/catch
  so thrown exceptions can't crash the Avalonia sync context
- task detail vm: Clear cancels/disposes the load CTS so a late-arriving
  LoadAsync can't resurrect detail state after deselect
- app: DisposeAsync the ServiceProvider in a finally after the classic
  desktop lifetime ends, so WorkerClient.DisposeAsync runs and the
  SignalR connection closes cleanly instead of being abandoned

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:27:45 +02:00
Mika Kuns
d3b85f2234 fix(worker): address concurrency, cancellation, and resource issues
- claude process: run stdout/stderr reads without ct; rely on
  kill-on-cancel closing the pipes to unblock them — previously
  ReadLineAsync(ct) could hang, stalling task slots and shutdown
- task runner: terminal db writes (task_runs, MarkDone, MarkFailed,
  SetLogPath) now use CancellationToken.None; RunOnceAsync catches
  OCE and finalizes the run row so ContinueAsync can resume
- task repository: GetNextQueuedAgentTaskAsync is now a single
  UPDATE ... RETURNING statement — closes TOCTOU window where two
  loop iterations could dispatch the same queued task
- queue service: dispose CancellationTokenSource in slot-completion
  ContinueWith to stop leaking wait handles
- git service: register ct.Kill(processTree), drain reads without ct,
  always reap via WaitForExitAsync(None) — no more git zombies on
  cancelled worktree ops
- worktree manager: branch name uses full task id (dashes stripped)
  instead of 8-char prefix, eliminating collision risk

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:27:18 +02:00
Mika Kuns
fc9029de97 fix(installer): wait for prior service registration to clear before create
After `sc delete`, the service stays in "marked for deletion" state until
every open handle (services.msc, Task Manager Services tab, Event Viewer,
prior sc query process) is closed. The installer used to immediately call
`sc create` and hit a silent hang / confusing "specified service has been
marked for deletion" error.

Poll `sc query` for up to 30s after delete; if the service is still
registered past that, fail with actionable guidance (close the offending
console or reboot). Also translate exit 1072 from `sc create` into the
same human-readable hint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:54:36 +02:00
Mika Kuns
1c764dae3f fix(installer): publish framework-dependent single-file
All checks were successful
Release / release (push) Successful in 27s
Self-contained single-file bundle was crashing at startup with 0xc0000005
in the apphost bootstrap (COR_E_EXECUTIONENGINE), because the Linux Gitea
runner doesn't carry the Microsoft.WindowsDesktop.App runtime pack — the
resulting bundle was missing WPF runtime bits. Disabling compression alone
didn't resolve it.

Switch to framework-dependent single-file: target machines need the .NET 8
Desktop Runtime (x64) installed but the bundle is ~2 MB instead of ~140 MB
and starts reliably. Keep IncludeAllContentForSelfExtract=true so native
deps (e_sqlite3) extract to a temp dir on first run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:11:27 +02:00
Mika Kuns
cfec3297a4 fix(installer): disable single-file compression to prevent WPF startup AV
All checks were successful
Release / release (push) Successful in 30s
Published installer was crashing at launch with 0xc0000005 (access violation)
and exit code 0x80131506 (COR_E_EXECUTIONENGINE), faulting inside the exe's
own bundle-extractor bootstrap before any managed code ran. This is a known
interaction between WPF, single-file publish, and EnableCompressionInSingleFile.

Disable compression (exe grows ~30% but startup becomes reliable).
IncludeAllContentForSelfExtract stays true — WPF still needs on-disk
extraction for XAML resources.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:48:06 +02:00
Mika Kuns
6e1d64b489 Change sdk Version
All checks were successful
Release / release (push) Successful in 39s
2026-04-15 14:23:26 +02:00
Mika Kuns
f599f8d0af fix(installer,worker): service hosting, dark theme, uninstall polish
Some checks failed
Release / release (push) Failing after 0s
Worker:
- Wire UseWindowsService + Microsoft.Extensions.Hosting.WindowsServices so
  SCM's Service Control Protocol handshake succeeds. Previously the binary
  exited immediately under sc start, leaving the service registered but
  never running.

Installer:
- Pin SDK to .NET 9 (global.json) — SDK 10 dropped win-arm from its RID
  graph, breaking restore of the WPF project; .NET 9 keeps win-arm AND
  understands the .slnx solution format.
- Force SelfContained=true and default RID=win-x64 when PublishSingleFile
  is set, so Rider Publish and CLI produce the same bundle.
- Dark theme: set Background/Foreground explicitly on WizardWindow and
  SettingsWindow roots (WPF implicit styles don't cascade to derived
  Window types). Custom ComboBox template + ComboBoxItem style so
  dropdowns honour the dark palette instead of system defaults.
- Throttle download progress to one report per MB and overwrite the same
  UI line (\r prefix marker) instead of appending per chunk.
- Register ClaudeDo in HKLM\...\Uninstall so it appears in Apps & Features.
  Copy installer into InstallDir\uninstaller\ for the UninstallString, and
  schedule a cmd.exe trampoline to handle the self-delete case when
  Apps & Features launches the copy from inside the install dir.
- Treat sc.exe stop exit 1062 (ERROR_SERVICE_NOT_ACTIVE) as success.
- Delete the uninstall registry key during UninstallRunner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:19:09 +02:00
Mika Kuns
9b928c6217 fix(installer): set EnableWindowsTargeting so Linux Gitea runners can publish
All checks were successful
Release / release (push) Successful in 40s
The release workflow runs on a Linux container; building net8.0-windows +
UseWPF=true requires this opt-in property since .NET 8. No-op on Windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:39:23 +02:00
c9e38aef88 Merge pull request 'feat/ui-improvements' (#1) from feat/ui-improvements into main
Some checks failed
Release / release (push) Failing after 19s
Reviewed-on: #1
2026-04-15 09:28:58 +00:00
66843d242b Merge branch 'main' into feat/ui-improvements 2026-04-15 09:28:18 +00:00
6afe5959ca Merge pull request 'feat/release-workflow' (#2) from feat/release-workflow into main
Reviewed-on: #2
2026-04-15 09:27:53 +00:00
b623651a5d Merge branch 'main' into feat/release-workflow 2026-04-15 09:27:21 +00:00
Mika Kuns
6b1b920149 feat(installer): download-mode rewrite + Gitea Releases pipeline
Rewrites ClaudeDo.Installer to fetch prebuilt binaries from
git.kuns.dev/releases/ClaudeDo instead of building from source.

- Async InstallModeDetector: FreshInstall / Update / Config
- DownloadAndExtractStep with SHA256 verify + scratch-dir extract
- UninstallRunner: stop-service / delete / full ~/.todo-app removal
  with path guard + partial-failure reporting
- Config view: Save / Repair / Uninstall buttons
- Self-contained single-file publish for the installer itself
- 29 xUnit tests in new ClaudeDo.Installer.Tests project

Spec: docs/superpowers/specs/2026-04-15-installer-download-mode-design.md
Plan: docs/superpowers/plans/2026-04-15-installer-download-mode.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:22:01 +02:00
Mika Kuns
9a407bde83 feat(ui): agent config inline in detail panel, file picker, subtask UI
TaskDetailView now edits Model / SystemPrompt / Agent inline (LostFocus
save), matching the modal editor. Both TaskEditorView and TaskDetailView
gain a Browse button that opens a .md file picker — external agent
paths are preserved on reload via a synthetic AgentInfo entry. Both
views also render the per-task subtask checklist (CheckBox + TextBox +
remove), with diff-on-save in the editor and inline-save in the detail
panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:20:17 +02:00
Mika Kuns
8c051d8f62 feat(data): add subtasks table, repository and prompt integration
Per-task checklist backend: subtasks table with CASCADE delete,
SubtaskEntity + SubtaskRepository (connection-per-op, async), DI
registration in App and Worker, TaskRunner composes a '## Sub-Tasks'
markdown block into the Claude prompt when subtasks exist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:19:54 +02:00
Mika Kuns
8577c55685 feat(ui): remove MaxWidth on main columns to use full window width
Lists (320px) and Detail (500px) borders no longer cap the 3-column
grid — star-sizing (1*:2*:1.5*) now fills the window, reducing the
dead whitespace between columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:19:41 +02:00
Mika Kuns
b7a8d78d4a chore(installer): remove orphaned InstallerService DI registration 2026-04-15 11:10:24 +02:00
Mika Kuns
b5455a1965 feat(installer): mode-aware wizard page list + Update-mode step pipeline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 11:07:03 +02:00
Mika Kuns
5d42438a72 fix(installer): UninstallRunner abort-on-stop-fail + path guard + partial-failure reporting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 10:56:39 +02:00
Mika Kuns
2898bec314 feat(installer): Config view — Save/Repair/Uninstall commands + footer buttons 2026-04-15 10:31:24 +02:00
Mika Kuns
ac38ea8c34 feat(installer): add UninstallRunner (service + shortcuts + dirs) 2026-04-15 10:29:25 +02:00
Mika Kuns
8d2f7e9907 fix(installer): null-defensive WelcomePage heading + guard unreachable modes 2026-04-15 10:27:30 +02:00
Mika Kuns
da1fe2109a feat(installer): rewrite WelcomePage for download-mode + update heading
Removes SourceDirectory field (no longer in InstallContext), adds
dynamic Heading/Subheading/InstallDirEditable for FreshInstall vs Update
mode, and updates XAML to match sibling page style.
2026-04-15 10:14:38 +02:00
Mika Kuns
5e432a4a27 fix(installer): fall back to Config on detection timeout when install.json exists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 10:10:02 +02:00
Mika Kuns
01c29bb6f6 feat(installer): async mode detection + mode-aware DI wiring 2026-04-15 10:01:20 +02:00
Mika Kuns
12e532718c fix(installer): wrap WriteInstallManifestStep I/O in try/catch like sibling steps 2026-04-15 09:58:16 +02:00
Mika Kuns
fe913ae5ef build(installer): add single-file self-contained publish properties 2026-04-15 09:55:53 +02:00
Mika Kuns
4fab0481c4 refactor(installer): replace SourceDirectory with Mode/Version fields in InstallContext 2026-04-15 09:54:57 +02:00
Mika Kuns
0989176127 refactor(installer): remove source-build steps (replaced by DownloadAndExtractStep) 2026-04-15 09:54:22 +02:00
Mika Kuns
548251841f feat(installer): add WriteInstallManifestStep
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:48:29 +02:00
Mika Kuns
ea32a74baa fix(installer): harden DownloadAndExtractStep per review 2026-04-15 09:43:27 +02:00
Mika Kuns
c1e330164e feat(installer): add DownloadAndExtractStep with SHA256 verify
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:37:04 +02:00
Mika Kuns
5b4af29420 fix(installer): check exit code (not stdout) for ERROR_SERVICE_ALREADY_RUNNING 2026-04-15 09:32:26 +02:00
Mika Kuns
d87de152e0 feat(installer): add Stop/StartServiceStep sc.exe wrappers 2026-04-15 09:27:54 +02:00
Mika Kuns
b4dc9509cb test(installer): pin 'unparseable version = Config' behavior + document IsNewer limits 2026-04-15 09:26:18 +02:00
Mika Kuns
97fb215ce6 feat(installer): replace sync ModeDetector with async InstallModeDetector
Placeholder edit to App.xaml.cs to keep the project building until Task 11
wires the new async detector.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:19:16 +02:00
Mika Kuns
83d7058b32 fix(installer): propagate cancellation + defensive asset parsing in ReleaseClient
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:15:56 +02:00
Mika Kuns
5603fd458d feat(installer): add IReleaseClient + Gitea ReleaseClient 2026-04-15 09:10:02 +02:00
Mika Kuns
d0c0e2ce1f feat(installer): add ChecksumVerifier (SHA256 + checksums.txt parser) 2026-04-15 09:03:08 +02:00
Mika Kuns
2fc6924dcb test(installer): add InstallManifest wrong-shape json test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 08:59:51 +02:00
Mika Kuns
921e626208 feat(installer): add InstallManifest + json-backed store 2026-04-15 08:53:52 +02:00
Mika Kuns
c23ed94817 test(installer): address review — drop UseWPF, thread-safe FakeHttpMessageHandler 2026-04-15 08:51:12 +02:00
Mika Kuns
2d34afb2e5 test(installer): scaffold ClaudeDo.Installer.Tests project 2026-04-15 08:46:17 +02:00
Mika Kuns
c0bd46542a docs(installer): add download-mode implementation plan
17-task TDD plan for rewriting the installer to fetch binaries from
releases/ClaudeDo on git.kuns.dev. Covers InstallManifest, ReleaseClient,
InstallModeDetector, DownloadAndExtractStep, Config/Repair/Uninstall,
and the publish-time single-file self-contained settings.

Workflow file is out of scope (handled by VPS Claude).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:37:07 +02:00
Mika Kuns
0498fbae47 docs(installer): finalize decisions — self-contained, auto-check, full uninstall
- App + Worker now self-contained (zero .NET runtime dep on target)
- Collapse Manage mode into "update check -> Config view" on every
  subsequent launch; Repair + Uninstall become buttons in Config
- Uninstall removes {InstallDir} and ~/.todo-app in full (no prompt
  to keep data) — matches user's stated intent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:21:12 +02:00
Mika Kuns
43a10cff95 docs(installer): pin release target to releases/ClaudeDo
VPS confirmed the releases/ org is world-readable without auth; the
ClaudeDo source already lives at git.kuns.dev/releases/ClaudeDo, so the
workflow uses the built-in gitea.token (no cross-org PAT needed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:14:28 +02:00
Mika Kuns
bd7d5940a2 docs(installer): add download-mode + Gitea Releases design spec
Design for rewriting the installer to fetch prebuilt binaries from Gitea
Releases on git.kuns.dev instead of building from source. Covers the
Actions workflow, release artifact layout, install.json marker file,
Install/Update/Manage mode detection, and the new DownloadAndExtractStep.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:03:40 +02:00
CubeGameLP
78831b2263 feat(installer): add WPF installer/configurator project
Standalone WPF app (ClaudeDo.Installer) that handles full installation
and ongoing configuration of ClaudeDo. Two modes: wizard for first run,
tabbed settings panel for subsequent launches. Page-based extensibility
via IInstallerPage interface — adding new config sections requires only
one new class.

Install pipeline: dotnet publish, deploy binaries, write configs, init
DB (via SchemaInitializer from ClaudeDo.Data), register Windows Service,
create shortcuts. Dark theme matching the Avalonia app (forest teal accent).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:01:03 +02:00
637 changed files with 108369 additions and 3819 deletions

View File

@@ -2,7 +2,14 @@
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)"
"Bash(git commit:*)",
"mcp__plugin_context-mode_context-mode__batch_execute",
"mcp__plugin_context-mode_context-mode__execute",
"mcp__plugin_context7_context7__query-docs",
"mcp__plugin_context-mode_context-mode__search",
"Bash(git fetch *)",
"PowerShell(cmdkey *)",
"mcp__plugin_context7_context7__resolve-library-id"
]
}
}

16
.gitattributes vendored Normal file
View File

@@ -0,0 +1,16 @@
* text=auto eol=lf
*.sln text eol=crlf
*.slnx text eol=crlf
*.cmd text eol=crlf
*.bat text eol=crlf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
*.exe binary
*.dll binary

View File

@@ -53,7 +53,7 @@ jobs:
cd "$WORK/src"
dotnet publish src/ClaudeDo.App/ClaudeDo.App.csproj \
-c Release -r win-x64 --self-contained true \
/p:Version=$VERSION -o out/app
/p:MinVerVersionOverride=$VERSION -o out/app
- name: Publish ClaudeDo.Worker (win-x64, self-contained)
env:
@@ -65,9 +65,9 @@ jobs:
cd "$WORK/src"
dotnet publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj \
-c Release -r win-x64 --self-contained true \
/p:Version=$VERSION -o out/worker
/p:MinVerVersionOverride=$VERSION -o out/worker
- name: Publish ClaudeDo.Installer (win-x64, single-file)
- name: Publish ClaudeDo.Installer (win-x64, single-file, framework-dependent)
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
@@ -75,9 +75,12 @@ jobs:
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
cd "$WORK/src"
# Framework-dependent — WPF runtime pack isn't distributed on Linux SDK;
# the previous self-contained bundle crashed at startup (apphost AV).
# Target machines need .NET 8 Desktop Runtime (x64).
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
-c Release -r win-x64 --self-contained true \
/p:Version=$VERSION /p:PublishSingleFile=true \
-c Release -r win-x64 --self-contained false \
/p:MinVerVersionOverride=$VERSION /p:PublishSingleFile=true \
-o out/installer
- name: Package assets

6
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# Local dev worktrees (created by using-git-worktrees skill)
.worktrees/
# .NET build output
bin/
obj/
@@ -42,6 +45,8 @@ artifacts/
# Avalonia / XAML designer
*.designer.cs
# ...but EF Core migration Designer files are real source and must be tracked
!**/Migrations/*.Designer.cs
# Project-specific
*.db
@@ -61,3 +66,4 @@ Desktop.ini
*.log
*.tmp
*.bak
design-time.db

View File

@@ -15,7 +15,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
## Tech Stack
- .NET 8.0, Avalonia 12.0.0 (Fluent theme)
- SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM
- SQLite (WAL mode) via Entity Framework Core (Microsoft.EntityFrameworkCore.Sqlite)
- SignalR for real-time IPC
- CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`)
- Git worktrees for task isolation
@@ -27,26 +27,52 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- Worker config: `~/.todo-app/worker.config.json`
- Logs: `~/.todo-app/logs/`
- Worktrees: configured per worker (sibling or central strategy)
- Schema: `schema/schema.sql` (embedded resource in ClaudeDo.Data)
## Conventions
- Repository pattern — each entity has its own async repository
- All data operations are async with CancellationToken support
- Task status flow: Manual | Queued -> Running -> Done | Failed
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A standalone task's successful run lands in WaitingForReview (planning children go straight to Done); from review you can approve (Done), reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel (Cancelled).
- Worktree state flow: Active -> Merged | Discarded | Kept
- Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup
- The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed
- Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)
- Small single-consumer helper types live in their consumer's file, not standalone files
- Commit messages use conventional format: `{commitType}(slug): title`
- Views use compiled bindings (`x:DataType`)
- ViewModels use `[ObservableProperty]` and `[RelayCommand]` source generators
## Working style (autonomous)
For any non-trivial feature, bug, or change, run this loop without hand-holding:
1. **Brainstorm first** (superpowers:brainstorming) — ask clarifying questions one at a time, propose 23 options with a recommendation, present a short design, get approval before building.
2. **Write it down** — a spec in `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md` and a step-by-step plan in `docs/superpowers/plans/` (superpowers:writing-plans). Commit the docs.
3. **Implement on main** with superpowers:subagent-driven-development — one subagent per task, TDD, build + test, commit per task with Conventional Commits. Once the plan is approved, do NOT pause for re-approval between tasks; only stop for genuine decisions or blockers.
4. **Trust but verify** — read each subagent's diff and run the build/tests yourself before marking a task done.
5. **Bugs** → superpowers:systematic-debugging (find the root cause before any fix).
6. **Never claim UI works without running it** — explicitly flag visual-verification gaps for the user to check.
Commit freely (per task + the spec/plan docs). Never push without asking.
## Building & Testing
`dotnet build ClaudeDo.slnx` requires .NET 9; on .NET 8 build individual projects with `-c Release` (a running Worker locks the `Debug` output).
```bash
dotnet build ClaudeDo.slnx
dotnet test tests/ClaudeDo.Worker.Tests
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release # pulls in Ui + Data
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release # also: Data.Tests, Ui.Tests, Localization.Tests, Installer.Tests, Releases.Tests
```
### Gotchas
- **Subagents:** use the `sonnet` model; stage files explicitly by path — never `git add -A` (parallel sessions often leave unrelated WIP in the tree).
- **Icons:** `PathIcon` *fills* its geometry. Line-art/stroke icons must be authored as filled geometry, or rendered with a stroked `Path` — otherwise they render invisible.
- **Localization:** `locales/en.json` and `locales/de.json` keys must stay in parity (Localization.Tests enforces it).
- **Test fakes:** changing `IWorkerClient` / `WorkerHub` / ViewModel constructors breaks hand-rolled fakes in both test projects — update them.
## Docs
- `docs/plan.md` — full architecture and design spec

View File

@@ -4,9 +4,17 @@
<Project Path="src/ClaudeDo.Data/ClaudeDo.Data.csproj" />
<Project Path="src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
<Project Path="src/ClaudeDo.Localization/ClaudeDo.Localization.csproj" />
<Project Path="src/ClaudeDo.Logging/ClaudeDo.Logging.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj" />
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
<Project Path="tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj" />
<Project Path="tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj" />
</Folder>
</Solution>

8
Directory.Build.props Normal file
View File

@@ -0,0 +1,8 @@
<Project>
<PropertyGroup>
<MinVerTagPrefix>v</MinVerTagPrefix>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MinVer" Version="5.0.0" PrivateAssets="all" />
</ItemGroup>
</Project>

View File

@@ -16,29 +16,29 @@ Two-process system communicating over SignalR:
| **ClaudeDo.Worker** | ASP.NET Core hosted service, task queue, Claude CLI runner |
```
┌──────────────┐ SignalR ──────────────┐
│ ClaudeDo.App│◄──────────►│ClaudeDo.Worker│
│ (Avalonia) │ 127.0.0.1 │ (ASP.NET)
│ │ :47821
│ ┌──────────┐│ │ ┌──────────┐ │
│ │ Ui ││ │ │ TaskQueue│ │
│ │(ViewModels)│ │ │ Claude CLI│ │
│ └──────────┘│ │ └──────────┘ │
└─────────────┘ ──────┬───────┘
───────────┬───────────────┘
──────┴──────┐
│ ClaudeDo.Data│
(SQLite) │
─────────────┘
┌────────────────┐ SignalR ┌────────────────┐
│ ClaudeDo.App │◄──────────►│ ClaudeDo.Worker
│ (Avalonia) │ 127.0.0.1 │ (ASP.NET Core)
│ :47821
│ ┌────────────┐│ │ ┌────────────┐ │
│ │ Ui ││ │ │ TaskQueue │ │
│ │(ViewModels)│ │ │ Claude CLI │ │
│ └────────────┘│ │ └────────────┘ │
└───────┬────────┘ └───────┬───────
└──────────────┬───────────────┘
┌───────┴──────
│ ClaudeDo.Data
(SQLite) │
└───────────────┘
```
## Tech Stack
- .NET 8.0
- Avalonia 12.0.0 (Fluent theme)
- SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM
- 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
@@ -53,7 +53,8 @@ Two-process system communicating over SignalR:
```bash
# Build
dotnet build ClaudeDo.slnx
dotnet build src/ClaudeDo.App
dotnet build src/ClaudeDo.Worker
# Run tests
dotnet test tests/ClaudeDo.Worker.Tests

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>ClaudeDo — Rider Island</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css?v=2" />
<template id="__bundler_thumbnail">
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#0d1311"/>
<rect x="30" y="60" width="36" height="80" rx="6" fill="#161d1a" stroke="#2a3330"/>
<rect x="76" y="60" width="60" height="80" rx="6" fill="#161d1a" stroke="#2a3330"/>
<rect x="146" y="60" width="24" height="80" rx="6" fill="#161d1a" stroke="#2a3330"/>
<circle cx="90" cy="85" r="4" fill="#4a6b4a"/>
<circle cx="90" cy="105" r="4" stroke="#3a4542" fill="none"/>
<circle cx="90" cy="125" r="4" stroke="#3a4542" fill="none"/>
</svg>
</template>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="data.jsx"></script>
<script type="text/babel" src="icons.jsx"></script>
<script type="text/babel" src="islands.jsx"></script>
<script type="text/babel" src="modals.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,240 @@
<!--
ClaudeDo component styles for Avalonia.
Depends on Tokens.axaml being merged first.
How to use each style:
<Border Classes="island"> — floating island container
<Border Classes="chip running"> — status chip
<Button Classes="icon-btn"> — 24×24 icon button
<Button Classes="btn primary"> — rounded-rect button
<TextBlock Classes="eyebrow"> — uppercase mono label
<Border Classes="agent-strip running"> — agent status strip
<Border Classes="terminal"> — terminal/log window
-->
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ============================================================ -->
<!-- ISLAND -->
<!-- ============================================================ -->
<Style Selector="Border.island">
<Setter Property="Background" Value="{StaticResource IslandBackgroundBrush}" />
<Setter Property="BorderBrush" Value="#0DFFFFFF" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{StaticResource IslandCornerRadius}" />
<Setter Property="BoxShadow" Value="{StaticResource IslandShadow}" />
<Setter Property="ClipToBounds" Value="True" />
</Style>
<!-- Island header separator (apply on the header Border inside an island) -->
<Style Selector="Border.island-header">
<Setter Property="Padding" Value="{StaticResource IslandHeaderPadding}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
</Style>
<!-- ============================================================ -->
<!-- CHIPS / BADGES -->
<!-- ============================================================ -->
<Style Selector="Border.chip">
<Setter Property="CornerRadius" Value="{StaticResource ChipCornerRadius}" />
<Setter Property="Padding" Value="8,3" />
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Border.chip > TextBlock">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
</Style>
<!-- Status variants — tint background 12% alpha of the status hue -->
<Style Selector="Border.chip.running">
<Setter Property="Background" Value="#1F7C9166" />
<Setter Property="BorderBrush" Value="#4C7C9166" />
</Style>
<Style Selector="Border.chip.running > TextBlock">
<Setter Property="Foreground" Value="{StaticResource StatusRunningBrush}" />
</Style>
<Style Selector="Border.chip.review">
<Setter Property="Background" Value="#1FD4A574" />
<Setter Property="BorderBrush" Value="#4CD4A574" />
</Style>
<Style Selector="Border.chip.review > TextBlock">
<Setter Property="Foreground" Value="{StaticResource StatusReviewBrush}" />
</Style>
<Style Selector="Border.chip.error">
<Setter Property="Background" Value="#1FC87060" />
<Setter Property="BorderBrush" Value="#4CC87060" />
</Style>
<Style Selector="Border.chip.error > TextBlock">
<Setter Property="Foreground" Value="{StaticResource StatusErrorBrush}" />
</Style>
<!-- ============================================================ -->
<!-- BUTTONS -->
<!-- ============================================================ -->
<Style Selector="Button.btn">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{StaticResource ButtonCornerRadius}" />
<Setter Property="Padding" Value="10,6" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.12" />
<BrushTransition Property="BorderBrush" Duration="0:0:0.12" />
</Transitions>
</Setter>
</Style>
<Style Selector="Button.btn:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
</Style>
<Style Selector="Button.btn.primary">
<Setter Property="Background" Value="{StaticResource AccentDimBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<!-- Icon button: 24×24 square with hover surface -->
<Style Selector="Button.icon-btn">
<Setter Property="Width" Value="24" />
<Setter Property="Height" Value="24" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
</Style>
<Style Selector="Button.icon-btn:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
</Style>
<!-- ============================================================ -->
<!-- INPUTS -->
<!-- ============================================================ -->
<Style Selector="TextBox.search">
<Setter Property="Background" Value="{StaticResource DeepBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{StaticResource InputCornerRadius}" />
<Setter Property="Padding" Value="10,8" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="CaretBrush" Value="{StaticResource AccentBrush}" />
</Style>
<Style Selector="TextBox.search:focus /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="BoxShadow" Value="0 0 0 3 #387C9166" />
</Style>
<!-- ============================================================ -->
<!-- TASK ROW -->
<!-- ============================================================ -->
<Style Selector="Border.task-row">
<Setter Property="Padding" Value="12,10" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="Border.task-row:pointerover">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
</Style>
<Style Selector="Border.task-row.selected">
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="BorderThickness" Value="0,0,0,0" />
<!-- Left-edge accent bar: use a nested Border child with Width=2 instead of inset shadow -->
</Style>
<!-- Checkbox indicator (the 18px circle that replaces the native CheckBox template) -->
<Style Selector="Ellipse.task-check">
<Setter Property="Width" Value="18" />
<Setter Property="Height" Value="18" />
<Setter Property="StrokeThickness" Value="1.5" />
<Setter Property="Stroke" Value="{StaticResource TextFaintBrush}" />
<Setter Property="Fill" Value="Transparent" />
</Style>
<Style Selector="Ellipse.task-check.done">
<Setter Property="Stroke" Value="{StaticResource AccentBrush}" />
<Setter Property="Fill" Value="{StaticResource AccentBrush}" />
</Style>
<!-- ============================================================ -->
<!-- AGENT STRIP -->
<!-- ============================================================ -->
<Style Selector="Border.agent-strip">
<Setter Property="Padding" Value="12,10" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
</Style>
<Style Selector="Border.agent-strip.running">
<Setter Property="Background" Value="#147C9166" />
<Setter Property="BorderBrush" Value="#4C7C9166" />
</Style>
<Style Selector="Border.agent-strip.review">
<Setter Property="Background" Value="#14D4A574" />
<Setter Property="BorderBrush" Value="#4CD4A574" />
</Style>
<Style Selector="Border.agent-strip.error">
<Setter Property="Background" Value="#14C87060" />
<Setter Property="BorderBrush" Value="#4CC87060" />
</Style>
<!-- ============================================================ -->
<!-- TERMINAL / LOG -->
<!-- ============================================================ -->
<Style Selector="Border.terminal">
<Setter Property="Background" Value="#FF080C0B" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="12" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Border.terminal TextBlock">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
</Style>
<Style Selector="Border.terminal TextBlock.log-sys">
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
</Style>
<Style Selector="Border.terminal TextBlock.log-tool">
<Setter Property="Foreground" Value="{StaticResource SageBrush}" />
</Style>
<Style Selector="Border.terminal TextBlock.log-claude">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="Border.terminal TextBlock.log-stderr">
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
</Style>
<Style Selector="Border.terminal TextBlock.log-done">
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}" />
</Style>
<!-- ============================================================ -->
<!-- LIST NAV ITEM -->
<!-- ============================================================ -->
<Style Selector="Border.list-item">
<Setter Property="Padding" Value="10,7" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="Border.list-item:pointerover">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
</Style>
<Style Selector="Border.list-item.active">
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
</Style>
</Styles>

View File

@@ -0,0 +1,253 @@
# ClaudeDo — Avalonia Handoff
## Overview
ClaudeDo is an agent dispatcher for Claude Code: a Windows desktop app that presents background coding agents as tasks. Each task has a title, a list, a git worktree/branch, an agent status (idle / queued / running / review / error), a live session log, and a diff. The UI is organised as **three floating islands** (Lists / Tasks / Details) over a dark "sea" background, Windows-11 style.
The bundled HTML file is a **design reference**, not production code. Your job is to recreate it as a native Avalonia app — match the look, feel, and interaction model, but use idiomatic AXAML, Avalonia controls, and whatever MVVM / ReactiveUI / CommunityToolkit patterns your codebase already uses.
## Fidelity
**High-fidelity.** All colors, typography, spacing, corner radii, shadows, and interaction states are final. Recreate pixel-perfectly using Avalonia primitives. The one exception: motion — CSS animations translate approximately to Avalonia `Transitions` / `Animation`; the durations and easings in `Tokens.axaml` are the intent.
## What's in this package
| File | Purpose |
|---|---|
| `Tokens.axaml` | `ResourceDictionary` — colors, brushes, spacing, corner radii, typography, shadows, motion durations. **Merge this first in `App.axaml`.** |
| `IslandStyles.axaml` | `Styles` — classed styles for Island, Chip, Button, TextBox, TaskRow, AgentStrip, Terminal, ListItem. Depends on `Tokens.axaml`. |
| `ClaudeDo.html` | The live design reference — open it in a browser to see behavior, hover states, animations, modals. |
| `ClaudeDo-standalone.html` | Fully offline single-file version (no network). Ship this with the handoff. |
| `app.jsx`, `islands.jsx`, `modals.jsx`, `icons.jsx`, `data.jsx`, `styles.css` | Source of the reference. Read `styles.css` for any measurement you need to verify; read the JSX for component structure and state transitions. |
| `ComponentSpec.md` | This file section below — maps every visual element to the AXAML control you should use. |
## How to wire the tokens
In `App.axaml`:
```xml
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://YourApp/Design/Tokens.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://YourApp/Design/IslandStyles.axaml" />
</Application.Styles>
```
Pack **Inter Tight** (sans) and **JetBrains Mono** (mono) as embedded resources and reference them via `avares://YourApp/Assets/Fonts/#Inter Tight` in `Tokens.axaml` if the system-font fallback isn't good enough.
---
## Window chrome
The reference shows a Windows-11-style app in a chromeless window with a custom title bar and taskbar. For a native Avalonia app, use `SystemDecorations="None"` + `ExtendClientAreaToDecorationsHint="True"` and draw your own title bar, OR use the platform chrome — the islands-over-sea metaphor works either way. The taskbar strip at the bottom of the reference is decorative; drop it.
## Layout
Root window:
```
┌─────────────────────────────────────────────────────────┐
│ TitleBar │
├─────────────┬──────────────────────────┬────────────────┤
│ Lists │ Tasks │ Details │
│ (260px) │ (1fr, min 340px) │ (320px) │
│ │ │ hides <1100px │
└─────────────┴──────────────────────────┴────────────────┘
```
Use a `Grid` with 3 columns: `260,*,320`. Collapse the Details column when `ActualWidth < 1100` via a bound `ColumnDefinition.Width`. Between columns and around the grid, add 14px gap — put each island in a `Border Classes="island"` with `Margin="7"` so you get the island-to-island gap naturally.
Background of the grid cell: apply `DesktopBackgroundBrush` from tokens, plus a subtle radial-gradient overlay via a `Border` with an opacity mask if desired.
---
## Components
### Island (base container)
- `Border Classes="island"`
- Contents: header section + scrollable body
- Header: inner `Border Classes="island-header"` with an eyebrow label (mono, uppercase, tracking 1.4) and a title
- Body: `ScrollViewer``StackPanel` or `ItemsControl`
- All three columns are islands.
### Lists island (left)
- Search box: `TextBox Classes="search"` with left-aligned search icon (PathIcon)
- Nav items: `ItemsControl` bound to a `Lists` collection
- Each item is `Border Classes="list-item"` (toggle `active` class when selected) containing
- `PathIcon` (16px)
- `TextBlock` (list name, 13px)
- `TextBlock` (count, mono 10px, right-aligned, TextFaintBrush)
- Lists shown: My Day, Important, Planned, Running, Review, Tasks (by project name)
### Tasks island (middle)
Header row:
- Eyebrow: weekday date ("MONDAY · APR 28")
- Title: "My Day" (or current list name) — 24px semibold
- Subtitle: "{N} open · {N} running · {N} in review" — mono 11px TextMute
- Right side: icon buttons (sort, filter, show-completed toggle)
Add-task row:
- `TextBox` with placeholder "Add a task…"
- On Enter: dispatch new task (see ViewModel spec)
Task list:
- `ItemsControl``Border Classes="task-row"` per task
- Row content: `Grid` with columns `Auto,*,Auto`
- Left: `Ellipse Classes="task-check"` (toggles `done` class on completion) — use a `Button` with a templated Ellipse for keyboard support
- Middle: `StackPanel` vertical
- Title: `TextBlock` 14px, strike-through when done
- Meta row: `StackPanel` horizontal with 8px gap, children:
- `Border Classes="chip {status}"` (status chip — Running / Review / Error / Queued / Idle)
- `Border Classes="chip"` with list name
- `Border Classes="chip"` with mono branch name (e.g. `agent/auth-pool`)
- `Border Classes="chip"` with diff stats (`+142 86`)
- Live tail of latest agent output when running — use `TextTrimming="CharacterEllipsis"` in a fixed-width container
- Right: `Button Classes="icon-btn"` (star)
- Selection: toggle `selected` class; add a `Rectangle` with `Width=2` as the left accent bar (child of the task-row Border)
### Details island (right)
Shown when a task is selected. Sections, top to bottom:
1. **Header**: task title (editable — `TextBox` with no visible border, `FontSize=18`), list chip, delete icon button
2. **Agent strip**`Border Classes="agent-strip {status}"`:
- Row 1: status indicator dot + status label ("Running" / "Review" / etc.) + model name ("claude-sonnet-4.5") + turns + tokens + elapsed
- Row 2: Worktree path (mono, truncating)
- Row 3: Branch → Base ("agent/auth-pool ← main") + commit count + diff stats
- Buttons: "Open diff" / "Worktree" / "Stop" (when running) / "Approve & merge" (when review)
3. **Session output**`Border Classes="terminal"` with a `ScrollViewer` auto-scrolled to bottom:
- Each line is a `TextBlock Classes="log-{kind}"` — kinds: sys, tool, claude, stdout, stderr, done, msg
- Below it, a prompt input: `[you]` prefix + `TextBox` to send messages to the agent
4. **Subtasks**`ItemsControl` of checkbox + text rows
5. **Notes** — multi-line `TextBox`, `AcceptsReturn="True"`
6. **Metadata** — created date, last activity, tags (readonly chips)
### Modals
Two modals in the reference: **Diff** and **Worktree**. Use `Window` with `WindowStartupLocation="CenterOwner"` and a scrim (`Border` over the main window with `Background="#BF030504"` + blur via `OpacityMask` or a child `Grid`). Or use `Dialog` if your shell has one.
**Diff modal**: left sidebar of files (each with `+N N` stats), right pane with syntax-colored hunks. Use two `ListBox`-style panels side-by-side. For lines: `del` = red tinted, `add` = green tinted, `ctx` = neutral. Left gutter columns: old line number, new line number, sign (`+` / `` / space).
**Worktree modal**: folder tree with `M` (modified) / `A` (added) badges. `TreeView` fits naturally.
### Status mapping
| Status | Chip color | Icon | When |
|---|---|---|---|
| idle | TextMute | circle | Task created, agent not dispatched |
| queued | Sage | dots | Agent queued behind others |
| running | Accent (moss) | pulse dot | Agent actively working |
| review | Peat | eye | Agent finished; awaiting approval |
| error | Blood | exclamation | Agent failed |
---
## State & interactions
### Task model (MVVM)
```csharp
public class TaskItem : ReactiveObject {
string Id, Title, List;
bool Done, Starred, MyDay;
DateTime? Due, Created;
string Notes;
List<string> Tags;
List<SubTask> Subtasks;
AgentState Agent; // null if not dispatched
}
public class AgentState : ReactiveObject {
AgentStatus Status; // Idle | Queued | Running | Review | Error
string Model, Worktree, Branch, BaseBranch;
int Commits, Turns, Tokens;
DiffStats Diff; // Files, Additions, Deletions
DateTime? StartedAt, FinishedAt;
ObservableCollection<LogLine> Log;
}
```
### Key interactions
- **Toggle done**: click checkbox → flip `Done`, animate strike-through (0.2s ease-out)
- **Select task**: click row → set `SelectedTask`; details island rebinds
- **Add task**: Enter in the add-task textbox → prepend new task; scroll list to top; 0.3s fade-in animation on the new row (use `Animation` with opacity + `TranslateTransform.Y`)
- **Dispatch agent**: "Start agent" button in Details → sets `Agent.Status = Running`, appends sys log "Agent dispatched."
- **Stop agent**: → `Status = Review` (or `Error` on failure), appends sys log
- **Send prompt**: Enter in prompt input → append `[you] {msg}` to log
- **Open diff / worktree**: opens modal; Esc closes
### Keyboard
- `/` focuses the search box in the Lists island
- `Cmd/Ctrl+N` focuses add-task
- `Space` toggles done on selected row
- `Esc` closes any open modal
### Animations
- Task-row hover: background transition 0.1s
- Task-row add: 0.3s opacity + slight Y-slide
- Task-row complete: 0.25s strike-through + fade to `done` styling
- Running status dot: infinite pulse (opacity 0.4 → 1.0, 1.2s)
- Modal open: 0.18s opacity + scale (0.98 → 1.0)
- Backdrop: 0.15s opacity fade
### Responsive
- `< 1100px`: hide Details island; details open as a transient panel or modal on task select
- `< 780px`: hide Lists island; use a hamburger drawer
---
## Design tokens (reference)
All final values live in `Tokens.axaml`. Reproduced here for reading:
**Surfaces**: `#0A0E0C` void · `#0D1311` deep · `#161D1A` surface · `#1C2422` surface-2 · `#222B28` surface-3 · `#2A3330` line
**Text**: `#E4EBE4` primary · `#9AA8A0` dim · `#6B7973` mute · `#4A5550` faint
**Accents**: `#7C9166` moss (primary) · `#8B9D7A` sage · `#D4A574` peat · `#C87060` blood
**Spacing**: 4, 8, 12, 14 (island gap), 18, 24
**Corner radii**: 14 (island) · 12 (modal) · 10 (chip) · 8 (task row, input) · 6 (button) · 999 (pill)
**Typography**: Inter Tight (sans), JetBrains Mono (mono). Scale: 10 (eyebrow) / 11 (mono, micro) / 13 (body) / 14 (task title) / 18 (h3) / 24 (h2) / 32 (h1)
**Shadows**:
- Island: `0 20 40 #59000000, 0 2 4 #4D000000`
- Modal: `0 40 80 #B2000000`
**Motion**: 120ms (fast) / 180ms (base) / 300ms (slow). Easing: cubic-bezier(0.4, 0, 0.2, 1) — use Avalonia's `CubicEaseOut` or a custom `SplineEasing`.
---
## Acceptance checklist
- [ ] Three-island layout with correct spacing and grid collapse at <1100px
- [ ] Lists sidebar with icons, counts, search, active state
- [ ] Task rows with checkbox, title, meta chips (status/list/branch/diff), star
- [ ] Task selection updates Details island
- [ ] Agent strip shows status, model, turns, tokens, elapsed, worktree, branch
- [ ] Session terminal renders all log kinds with distinct colors, auto-scrolls, accepts prompt input
- [ ] Diff modal with file sidebar and tinted add/del lines
- [ ] Worktree modal with M/A badges
- [ ] Status chip tints match the spec
- [ ] Fonts: Inter Tight + JetBrains Mono packed and applied
- [ ] Motion: task add/toggle, running pulse, modal open, hover transitions
- [ ] Keyboard shortcuts wired
## Questions / contact
The HTML reference is the source of truth for any visual ambiguity. Open `ClaudeDo-standalone.html` and inspect directly.

View File

@@ -0,0 +1,188 @@
<!--
ClaudeDo design tokens for Avalonia.
Merge into App.axaml via <Application.Resources><ResourceDictionary.MergedDictionaries>.
All colors are sRGB hex. Accent uses a single hue (88 = moss); swap to 40 for peat, 180 for sea.
-->
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ============================================================ -->
<!-- BASE PALETTE -->
<!-- ============================================================ -->
<!-- Void / deep / surfaces (windowpane layering, dark-first) -->
<Color x:Key="VoidColor">#FF0A0E0C</Color>
<Color x:Key="DeepColor">#FF0D1311</Color>
<Color x:Key="SurfaceColor">#FF161D1A</Color>
<Color x:Key="Surface2Color">#FF1C2422</Color>
<Color x:Key="Surface3Color">#FF222B28</Color>
<Color x:Key="LineColor">#FF2A3330</Color>
<Color x:Key="LineBrightColor">#FF3A4542</Color>
<!-- Text scale -->
<Color x:Key="TextColor">#FFE4EBE4</Color>
<Color x:Key="TextDimColor">#FF9AA8A0</Color>
<Color x:Key="TextMuteColor">#FF6B7973</Color>
<Color x:Key="TextFaintColor">#FF4A5550</Color>
<!-- Accent family (moss / sage / peat / blood) -->
<Color x:Key="MossColor">#FF4A6B4A</Color>
<Color x:Key="MossBrightColor">#FF6B8E6B</Color>
<Color x:Key="SageColor">#FF8B9D7A</Color>
<Color x:Key="PeatColor">#FFD4A574</Color>
<Color x:Key="PeatSoftColor">#FFB88D5E</Color>
<Color x:Key="BloodColor">#FFC87060</Color>
<!-- Primary accent — equivalent to oklch(58% 0.08 88) -->
<Color x:Key="AccentColor">#FF7C9166</Color>
<Color x:Key="AccentDimColor">#FF64785A</Color>
<Color x:Key="AccentSoftColor">#FF3E4B39</Color>
<Color x:Key="AccentGlowColor">#387C9166</Color> <!-- 22% alpha -->
<!-- Status colors -->
<Color x:Key="StatusRunningColor">#FF7C9166</Color>
<Color x:Key="StatusReviewColor">#FFD4A574</Color>
<Color x:Key="StatusErrorColor">#FFC87060</Color>
<Color x:Key="StatusQueuedColor">#FF8B9D7A</Color>
<Color x:Key="StatusIdleColor">#FF6B7973</Color>
<!-- ============================================================ -->
<!-- BRUSHES -->
<!-- ============================================================ -->
<SolidColorBrush x:Key="VoidBrush" Color="{StaticResource VoidColor}" />
<SolidColorBrush x:Key="DeepBrush" Color="{StaticResource DeepColor}" />
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource SurfaceColor}" />
<SolidColorBrush x:Key="Surface2Brush" Color="{StaticResource Surface2Color}" />
<SolidColorBrush x:Key="Surface3Brush" Color="{StaticResource Surface3Color}" />
<SolidColorBrush x:Key="LineBrush" Color="{StaticResource LineColor}" />
<SolidColorBrush x:Key="LineBrightBrush" Color="{StaticResource LineBrightColor}" />
<SolidColorBrush x:Key="TextBrush" Color="{StaticResource TextColor}" />
<SolidColorBrush x:Key="TextDimBrush" Color="{StaticResource TextDimColor}" />
<SolidColorBrush x:Key="TextMuteBrush" Color="{StaticResource TextMuteColor}" />
<SolidColorBrush x:Key="TextFaintBrush" Color="{StaticResource TextFaintColor}" />
<SolidColorBrush x:Key="MossBrush" Color="{StaticResource MossColor}" />
<SolidColorBrush x:Key="MossBrightBrush" Color="{StaticResource MossBrightColor}" />
<SolidColorBrush x:Key="SageBrush" Color="{StaticResource SageColor}" />
<SolidColorBrush x:Key="PeatBrush" Color="{StaticResource PeatColor}" />
<SolidColorBrush x:Key="PeatSoftBrush" Color="{StaticResource PeatSoftColor}" />
<SolidColorBrush x:Key="BloodBrush" Color="{StaticResource BloodColor}" />
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}" />
<SolidColorBrush x:Key="AccentDimBrush" Color="{StaticResource AccentDimColor}" />
<SolidColorBrush x:Key="AccentSoftBrush" Color="{StaticResource AccentSoftColor}" />
<SolidColorBrush x:Key="AccentGlowBrush" Color="{StaticResource AccentGlowColor}" />
<SolidColorBrush x:Key="StatusRunningBrush" Color="{StaticResource StatusRunningColor}" />
<SolidColorBrush x:Key="StatusReviewBrush" Color="{StaticResource StatusReviewColor}" />
<SolidColorBrush x:Key="StatusErrorBrush" Color="{StaticResource StatusErrorColor}" />
<SolidColorBrush x:Key="StatusQueuedBrush" Color="{StaticResource StatusQueuedColor}" />
<SolidColorBrush x:Key="StatusIdleBrush" Color="{StaticResource StatusIdleColor}" />
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#FF05070A" />
<GradientStop Offset="0.5" Color="#FF0A0D10" />
<GradientStop Offset="1" Color="#FF060A08" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="IslandBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="{StaticResource SurfaceColor}" />
<GradientStop Offset="1" Color="#FF131917" />
</LinearGradientBrush>
<!-- ============================================================ -->
<!-- SPACING -->
<!-- ============================================================ -->
<x:Double x:Key="SpaceXs">4</x:Double>
<x:Double x:Key="SpaceSm">8</x:Double>
<x:Double x:Key="SpaceMd">12</x:Double>
<x:Double x:Key="SpaceLg">14</x:Double> <!-- island gap -->
<x:Double x:Key="SpaceXl">18</x:Double> <!-- island interior padding -->
<x:Double x:Key="Space2Xl">24</x:Double>
<Thickness x:Key="IslandGapMargin">7</Thickness> <!-- half of 14 on each side -->
<Thickness x:Key="IslandHeaderPadding">18,16,18,12</Thickness>
<Thickness x:Key="IslandBodyPadding">14</Thickness>
<Thickness x:Key="WindowBodyPadding">14</Thickness>
<!-- ============================================================ -->
<!-- CORNERS & BORDERS -->
<!-- ============================================================ -->
<CornerRadius x:Key="IslandCornerRadius">14</CornerRadius>
<CornerRadius x:Key="ButtonCornerRadius">6</CornerRadius>
<CornerRadius x:Key="ChipCornerRadius">10</CornerRadius>
<CornerRadius x:Key="PillCornerRadius">999</CornerRadius>
<CornerRadius x:Key="InputCornerRadius">8</CornerRadius>
<CornerRadius x:Key="ModalCornerRadius">12</CornerRadius>
<Thickness x:Key="HairlineBorder">1</Thickness>
<!-- ============================================================ -->
<!-- TYPOGRAPHY -->
<!-- ============================================================ -->
<!--
Pack these fonts with the app (use Avalonia's FontFamily='avares://...#Family Name' syntax).
Sans: Inter Tight (display) + Inter (body fallback)
Mono: JetBrains Mono
-->
<FontFamily x:Key="SansFont">Inter Tight, Inter, Segoe UI, -apple-system, sans-serif</FontFamily>
<FontFamily x:Key="MonoFont">JetBrains Mono, IBM Plex Mono, Cascadia Mono, Consolas, monospace</FontFamily>
<!-- Type scale -->
<x:Double x:Key="FontSizeEyebrow">10</x:Double> <!-- uppercase label, 0.14em tracking -->
<x:Double x:Key="FontSizeMono">11</x:Double> <!-- chips, log lines, filepaths -->
<x:Double x:Key="FontSizeMicro">11</x:Double> <!-- meta rows -->
<x:Double x:Key="FontSizeBody">13</x:Double>
<x:Double x:Key="FontSizeTaskTitle">14</x:Double>
<x:Double x:Key="FontSizeH3">18</x:Double>
<x:Double x:Key="FontSizeH2">24</x:Double> <!-- island titles ("My Day") -->
<x:Double x:Key="FontSizeH1">32</x:Double>
<!-- Common text styles -->
<Style x:Key="EyebrowText" Selector="TextBlock.eyebrow">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeEyebrow}" />
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
<Setter Property="LetterSpacing" Value="1.4" />
<Setter Property="TextTransform" Value="Uppercase" />
</Style>
<Style x:Key="MonoText" Selector="TextBlock.mono">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
</Style>
<Style x:Key="IslandTitle" Selector="TextBlock.island-title">
<Setter Property="FontFamily" Value="{StaticResource SansFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeH2}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<!-- ============================================================ -->
<!-- SHADOWS (use on Island Border via BoxShadow) -->
<!-- ============================================================ -->
<BoxShadows x:Key="IslandShadow">0 20 40 0 #59000000, 0 2 4 0 #4D000000</BoxShadows>
<BoxShadows x:Key="ModalShadow">0 40 80 0 #B2000000</BoxShadows>
<BoxShadows x:Key="SubtleShadow">0 2 4 0 #33000000</BoxShadows>
<!-- ============================================================ -->
<!-- MOTION -->
<!-- ============================================================ -->
<Duration x:Key="MotionFast">0:0:0.12</Duration>
<Duration x:Key="MotionBase">0:0:0.18</Duration>
<Duration x:Key="MotionSlow">0:0:0.30</Duration>
<!-- Standard easing: cubic-bezier(0.4, 0, 0.2, 1) — equivalent to Avalonia's CubicEaseOut for most UI -->
</ResourceDictionary>

View File

@@ -0,0 +1,374 @@
// App shell + Tweaks panel + Windows chrome
const { useState, useEffect, useRef, useMemo } = window.React;
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accentHue": 88,
"islandGap": 14,
"islandRadius": 14,
"grainOpacity": 0.035,
"density": "comfy",
"sidebarWidth": 260
}/*EDITMODE-END*/;
const HUE_PRESETS = [
{ name: 'Moss', h: 88 },
{ name: 'Sea', h: 200 },
{ name: 'Peat', h: 60 },
{ name: 'Heather', h: 310 },
{ name: 'Rust', h: 30 },
];
const TweaksPanel = ({ open, onClose, tweaks, setTweaks }) => {
const update = (k, v) => {
const next = { ...tweaks, [k]: v };
setTweaks(next);
try {
window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [k]: v } }, '*');
} catch (e) {}
};
return (
<div className={`tweaks-panel ${open ? 'open' : ''}`}>
<div className="tweaks-head">
<div className="tweaks-title">Tweaks</div>
<button className="tweaks-close" onClick={onClose}><Icon name="close" size={12} /></button>
</div>
<div style={{ marginBottom: 10 }}>
<div className="tweak-row" style={{ paddingBottom: 4 }}>
<span className="label">Accent</span>
<span className="val">H {tweaks.accentHue}</span>
</div>
<div className="hue-swatches">
{HUE_PRESETS.map((p) => (
<div
key={p.h}
className={`hue-swatch ${tweaks.accentHue === p.h ? 'active' : ''}`}
style={{ background: `oklch(58% 0.08 ${p.h})` }}
title={p.name}
onClick={() => update('accentHue', p.h)}
/>
))}
<input
type="range" min="0" max="360" step="1"
value={tweaks.accentHue}
onChange={(e) => update('accentHue', +e.target.value)}
style={{ flex: 1, marginLeft: 6 }}
/>
</div>
</div>
<div className="tweak-row">
<span className="label">Gap</span>
<input type="range" min="4" max="28" step="1" value={tweaks.islandGap}
onChange={(e) => update('islandGap', +e.target.value)} />
<span className="val">{tweaks.islandGap}px</span>
</div>
<div className="tweak-row">
<span className="label">Radius</span>
<input type="range" min="0" max="24" step="1" value={tweaks.islandRadius}
onChange={(e) => update('islandRadius', +e.target.value)} />
<span className="val">{tweaks.islandRadius}px</span>
</div>
<div className="tweak-row">
<span className="label">Grain</span>
<input type="range" min="0" max="0.12" step="0.005" value={tweaks.grainOpacity}
onChange={(e) => update('grainOpacity', +e.target.value)} />
<span className="val">{tweaks.grainOpacity.toFixed(3)}</span>
</div>
<div className="tweak-row">
<span className="label">Sidebar</span>
<input type="range" min="200" max="340" step="4" value={tweaks.sidebarWidth}
onChange={(e) => update('sidebarWidth', +e.target.value)} />
<span className="val">{tweaks.sidebarWidth}</span>
</div>
<div className="tweak-row">
<span className="label">Density</span>
<div className="density-toggle">
<button className={tweaks.density === 'comfy' ? 'on' : ''} onClick={() => update('density', 'comfy')}>Comfy</button>
<button className={tweaks.density === 'compact' ? 'on' : ''} onClick={() => update('density', 'compact')}>Compact</button>
</div>
</div>
</div>
);
};
// Windows chrome
const TitleBar = ({ search }) => {
return (
<div className="titlebar">
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 16, height: 16, borderRadius: 4,
background: 'linear-gradient(135deg, var(--moss), var(--sage))',
display: 'grid', placeItems: 'center',
}}>
<svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="#0a0e0c" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M2 5l2 2 4-4"/></svg>
</div>
<span className="titlebar-title">
ClaudeDo <span className="bullet">·</span> Rider Island
</span>
</div>
<div className="titlebar-controls">
<button className="titlebar-btn"><Icon name="min" size={12} /></button>
<button className="titlebar-btn"><Icon name="max" size={12} /></button>
<button className="titlebar-btn close"><Icon name="close" size={12} /></button>
</div>
</div>
);
};
const Taskbar = () => {
const [clock, setClock] = useState(() => {
const n = new Date();
return {
time: n.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
date: n.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }),
};
});
useEffect(() => {
const id = setInterval(() => {
const n = new Date();
setClock({
time: n.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
date: n.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }),
});
}, 30000);
return () => clearInterval(id);
}, []);
const icons = ['windows', 'search', 'folder', 'inbox', 'note', 'calendar'];
return (
<div className="taskbar">
{icons.map((ic, i) => (
<div key={i} className={`taskbar-icon ${ic === 'note' ? 'active' : ''}`}>
<Icon name={ic} size={16} />
</div>
))}
<div className="taskbar-clock">
<div>{clock.time}</div>
<div>{clock.date}</div>
</div>
</div>
);
};
// ---------- App ----------
const App = () => {
const [tweaks, setTweaks] = useState(TWEAK_DEFAULTS);
const [tweaksOpen, setTweaksOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [tasks, setTasks] = useState(SEED_TASKS);
const [activeList, setActiveList] = useState('myday');
const [selectedId, setSelectedId] = useState('t1');
const [search, setSearch] = useState('');
const [showCompleted, setShowCompleted] = useState(true);
const [leavingIds, setLeavingIds] = useState([]);
const [enteringIds, setEnteringIds] = useState([]);
const [diffTaskId, setDiffTaskId] = useState(null);
const [worktreeTaskId, setWorktreeTaskId] = useState(null);
// Apply CSS tweaks
useEffect(() => {
const r = document.documentElement;
r.style.setProperty('--accent-h', tweaks.accentHue);
r.style.setProperty('--island-gap', tweaks.islandGap + 'px');
r.style.setProperty('--island-radius', tweaks.islandRadius + 'px');
r.style.setProperty('--grain-opacity', tweaks.grainOpacity);
r.style.setProperty('--sidebar-w', tweaks.sidebarWidth + 'px');
r.style.setProperty('--density', tweaks.density === 'comfy' ? 1 : 0.82);
}, [tweaks]);
// Tweaks host protocol
useEffect(() => {
const onMsg = (e) => {
const d = e.data;
if (!d || typeof d !== 'object') return;
if (d.type === '__activate_edit_mode') { setEditMode(true); setTweaksOpen(true); }
if (d.type === '__deactivate_edit_mode') { setEditMode(false); setTweaksOpen(false); }
};
window.addEventListener('message', onMsg);
try { window.parent.postMessage({ type: '__edit_mode_available' }, '*'); } catch (e) {}
return () => window.removeEventListener('message', onMsg);
}, []);
// Counts per list
const counts = useMemo(() => {
const c = {};
c.myday = tasks.filter((t) => t.myDay && !t.done).length;
c.important = tasks.filter((t) => t.starred && !t.done).length;
c.planned = tasks.filter((t) => t.due && !t.done).length;
c.assigned = 0;
c.flagged = 0;
c.all = tasks.filter((t) => !t.done).length;
SEED_USER_LISTS.forEach((l) => {
c[l.id] = tasks.filter((t) => t.list === l.id && !t.done).length;
});
return c;
}, [tasks]);
// Filter tasks
const visibleTasks = useMemo(() => {
let ts = tasks;
if (activeList === 'myday') ts = ts.filter((t) => t.myDay);
else if (activeList === 'important') ts = ts.filter((t) => t.starred);
else if (activeList === 'planned') ts = ts.filter((t) => t.due);
else if (activeList === 'all') ts = ts;
else if (activeList === 'assigned' || activeList === 'flagged') ts = [];
else ts = ts.filter((t) => t.list === activeList);
if (search) {
const q = search.toLowerCase();
ts = ts.filter((t) => t.title.toLowerCase().includes(q) || (t.notes || '').toLowerCase().includes(q));
}
return ts;
}, [tasks, activeList, search]);
const selected = tasks.find((t) => t.id === selectedId);
// Actions
const toggleTask = (id) => {
setTasks((prev) => prev.map((t) =>
t.id === id ? { ...t, done: !t.done, completedAt: !t.done ? new Date().toISOString() : null } : t
));
};
const starTask = (id) => {
setTasks((prev) => prev.map((t) => t.id === id ? { ...t, starred: !t.starred } : t));
};
const updateTask = (next) => {
setTasks((prev) => prev.map((t) => t.id === next.id ? next : t));
};
const deleteTask = (id) => {
setLeavingIds((l) => [...l, id]);
setTimeout(() => {
setTasks((prev) => prev.filter((t) => t.id !== id));
setLeavingIds((l) => l.filter((x) => x !== id));
if (selectedId === id) setSelectedId(null);
}, 280);
};
const addTask = (title) => {
const id = 't' + Date.now();
const newTask = {
id, title,
list: ['myday','important','planned','running','review','all'].includes(activeList) ? 'claudedo' : activeList,
myDay: true,
starred: false,
due: new Date().toISOString(),
notes: '',
tags: [],
subtasks: [],
created: new Date().toISOString(),
done: false,
agent: {
status: 'idle',
model: 'claude-sonnet-4.5',
worktree: `~/worktrees/${activeList}/new-task-${id.slice(1,6)}`,
branch: `agent/new-task-${id.slice(1,6)}`,
baseBranch: 'main',
commits: 0,
diff: { files: 0, additions: 0, deletions: 0 },
turns: 0,
tokens: 0,
log: [{ t: new Date().toISOString(), k: 'sys', m: 'Worktree ready. Agent not yet dispatched.' }],
},
};
setTasks((prev) => [newTask, ...prev]);
setEnteringIds((l) => [...l, id]);
setSelectedId(id);
setTimeout(() => setEnteringIds((l) => l.filter((x) => x !== id)), 300);
};
const agentAction = (id, action) => {
setTasks((prev) => prev.map((t) => {
if (t.id !== id || !t.agent) return t;
if (action === 'start') {
return { ...t, agent: { ...t.agent, status: 'running', startedAt: new Date().toISOString(),
log: [...(t.agent.log || []), { t: new Date().toISOString(), k: 'sys', m: 'Agent dispatched.' }] } };
}
if (action === 'stop') {
return { ...t, agent: { ...t.agent, status: 'review', finishedAt: new Date().toISOString(),
log: [...(t.agent.log || []), { t: new Date().toISOString(), k: 'sys', m: 'Stopped by operator.' }] } };
}
return t;
}));
};
const agentInput = (id, msg) => {
setTasks((prev) => prev.map((t) => {
if (t.id !== id || !t.agent) return t;
return { ...t, agent: { ...t.agent, log: [...(t.agent.log || []),
{ t: new Date().toISOString(), k: 'msg', m: '[you] ' + msg }] } };
}));
};
return (
<div className="desktop">
<div className="window">
<TitleBar />
<div className="window-body">
<ListsIsland
activeList={activeList}
setActiveList={(id) => { setActiveList(id); }}
counts={counts}
search={search}
setSearch={setSearch}
/>
<TasksIsland
tasks={visibleTasks}
selectedId={selectedId}
setSelected={setSelectedId}
onToggle={toggleTask}
onStar={starTask}
onAdd={addTask}
leavingIds={leavingIds}
enteringIds={enteringIds}
activeList={activeList}
showCompleted={showCompleted}
setShowCompleted={setShowCompleted}
/>
<div className="details-col">
<DetailsIsland
task={selected}
onUpdate={updateTask}
onDelete={deleteTask}
onToggle={toggleTask}
onStar={starTask}
onAgentAction={agentAction}
onAgentInput={agentInput}
onOpenDiff={(id) => setDiffTaskId(id)}
onOpenWorktree={(id) => setWorktreeTaskId(id)}
/>
</div>
</div>
</div>
<Taskbar />
{diffTaskId && (
<DiffModal
task={tasks.find((t) => t.id === diffTaskId)}
onClose={() => setDiffTaskId(null)}
/>
)}
{worktreeTaskId && (
<WorktreeModal
task={tasks.find((t) => t.id === worktreeTaskId)}
onClose={() => setWorktreeTaskId(null)}
/>
)}
{/* Tweaks: FAB (when edit mode is off) or panel (when toggled) */}
{editMode && (
<TweaksPanel
open={tweaksOpen}
onClose={() => setTweaksOpen(false)}
tweaks={tweaks}
setTweaks={setTweaks}
/>
)}
</div>
);
};
// The inner window shouldn't render TitleBar twice — fix:
// Actually we want ONE window with one titlebar. Remove the outer TitleBar.
const AppFixed = () => <App />;
ReactDOM.createRoot(document.getElementById('root')).render(<AppFixed />);

View File

@@ -0,0 +1,228 @@
// Seed data for ClaudeDo — Claude agent dispatcher
const SEED_LISTS = [
{ id: 'myday', kind: 'smart', icon: 'sun', name: 'My Day' },
{ id: 'running', kind: 'smart', icon: 'pulse', name: 'Running' },
{ id: 'important', kind: 'smart', icon: 'star', name: 'Important' },
{ id: 'planned', kind: 'smart', icon: 'calendar', name: 'Planned' },
{ id: 'review', kind: 'smart', icon: 'eye', name: 'Needs review' },
{ id: 'all', kind: 'smart', icon: 'inbox', name: 'All tasks' },
];
const SEED_USER_LISTS = [
{ id: 'claudedo', icon: 'folder', name: 'ClaudeDo', color: '#6b8e6b' },
{ id: 'tuning-web', icon: 'folder', name: 'tuning-web', color: '#d4a574' },
{ id: 'api-core', icon: 'folder', name: 'api-core', color: '#8b9d7a' },
{ id: 'ops', icon: 'folder', name: 'ops', color: '#7a95a8' },
];
const now = new Date();
const today = now;
const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1);
const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1);
const mkISO = (mins) => new Date(Date.now() - mins * 60000).toISOString();
// status: idle | queued | running | review | done | error
const SEED_TASKS = [
{
id: 't1',
title: 'Refactor the auth middleware to use new session store',
list: 'api-core',
myDay: true, starred: true,
due: today.toISOString(),
notes: 'Swap the old Redis client for the new pool-aware wrapper. Keep the public API stable.',
tags: ['refactor', 'backend'],
subtasks: [
{ id: 's1', title: 'Audit call sites', done: true },
{ id: 's2', title: 'Swap client in middleware.ts', done: true },
{ id: 's3', title: 'Update tests', done: false },
{ id: 's4', title: 'Run full test suite', done: false },
],
created: mkISO(120), done: false,
agent: {
status: 'running',
model: 'claude-sonnet-4.5',
worktree: '~/worktrees/api-core/auth-refactor',
branch: 'agent/auth-refactor',
baseBranch: 'main',
startedAt: mkISO(18),
commits: 3,
diff: { files: 7, additions: 142, deletions: 86 },
turns: 24,
tokens: 184200,
log: [
{ t: mkISO(18), k: 'sys', m: 'Session started · worktree: api-core/auth-refactor' },
{ t: mkISO(17), k: 'tool', m: 'read_file src/middleware/auth.ts' },
{ t: mkISO(17), k: 'tool', m: 'grep "createSessionStore" src/' },
{ t: mkISO(16), k: 'msg', m: 'Found 12 call sites across 4 modules. Starting with the middleware.' },
{ t: mkISO(14), k: 'tool', m: 'edit_file src/middleware/auth.ts (+48 22)' },
{ t: mkISO(12), k: 'tool', m: 'edit_file src/lib/session/index.ts (+31 14)' },
{ t: mkISO(11), k: 'tool', m: 'run_shell "pnpm test auth"' },
{ t: mkISO(10), k: 'stdout', m: ' ✓ auth/basic.test.ts (8)' },
{ t: mkISO(10), k: 'stdout', m: ' ✓ auth/session.test.ts (14)' },
{ t: mkISO(10), k: 'stdout', m: ' ✗ auth/expiry.test.ts (2 failed)' },
{ t: mkISO(9), k: 'msg', m: 'Two expiry tests failing — investigating the TTL calculation.' },
{ t: mkISO(6), k: 'tool', m: 'edit_file src/lib/session/ttl.ts (+12 4)' },
{ t: mkISO(5), k: 'tool', m: 'run_shell "pnpm test auth/expiry"' },
{ t: mkISO(4), k: 'stdout', m: ' ✓ auth/expiry.test.ts (6)' },
{ t: mkISO(3), k: 'msg', m: 'Expiry tests passing. Now running full suite…' },
{ t: mkISO(1), k: 'tool', m: 'run_shell "pnpm test"' },
{ t: mkISO(0.2), k: 'stdout', m: ' Running 284 tests across 41 files…' },
],
},
},
{
id: 't2',
title: 'Add dark mode toggle to settings page',
list: 'tuning-web',
myDay: true, starred: false,
due: today.toISOString(),
notes: 'Match the palette from the design system. Persist via localStorage.',
tags: ['ui'],
subtasks: [],
created: mkISO(90), done: false,
agent: {
status: 'review',
model: 'claude-sonnet-4.5',
worktree: '~/worktrees/tuning-web/dark-mode',
branch: 'agent/dark-mode',
baseBranch: 'main',
startedAt: mkISO(45),
finishedAt: mkISO(8),
commits: 2,
diff: { files: 4, additions: 68, deletions: 12 },
turns: 14,
tokens: 92400,
log: [
{ t: mkISO(45), k: 'sys', m: 'Session started · worktree: tuning-web/dark-mode' },
{ t: mkISO(44), k: 'tool', m: 'read_file src/pages/settings.tsx' },
{ t: mkISO(42), k: 'tool', m: 'read_file src/theme/tokens.css' },
{ t: mkISO(38), k: 'tool', m: 'edit_file src/pages/settings.tsx (+32 2)' },
{ t: mkISO(30), k: 'tool', m: 'edit_file src/hooks/useTheme.ts (+24 0)' },
{ t: mkISO(22), k: 'tool', m: 'edit_file src/theme/tokens.css (+10 8)' },
{ t: mkISO(14), k: 'tool', m: 'run_shell "pnpm build"' },
{ t: mkISO(12), k: 'stdout', m: ' ✓ Built in 4.2s' },
{ t: mkISO(10), k: 'tool', m: 'run_shell "pnpm test"' },
{ t: mkISO(9), k: 'stdout', m: ' ✓ 182 tests passed' },
{ t: mkISO(8), k: 'done', m: 'Ready for review — 2 commits on agent/dark-mode' },
],
},
},
{
id: 't3',
title: 'Investigate flaky checkout test',
list: 'tuning-web',
myDay: true, starred: false,
due: today.toISOString(),
notes: 'Fails ~1 in 8 runs on CI. Probably a race in the cart hydration.',
tags: ['bug', 'tests'],
subtasks: [],
created: mkISO(200), done: false,
agent: {
status: 'error',
model: 'claude-sonnet-4.5',
worktree: '~/worktrees/tuning-web/flaky-checkout',
branch: 'agent/flaky-checkout',
baseBranch: 'main',
startedAt: mkISO(55),
finishedAt: mkISO(40),
commits: 0,
diff: { files: 0, additions: 0, deletions: 0 },
turns: 6,
tokens: 28100,
log: [
{ t: mkISO(55), k: 'sys', m: 'Session started · worktree: tuning-web/flaky-checkout' },
{ t: mkISO(54), k: 'tool', m: 'run_shell "pnpm test checkout --repeat 20"' },
{ t: mkISO(50), k: 'stdout', m: ' runs: 20 · passes: 18 · failures: 2' },
{ t: mkISO(45), k: 'tool', m: 'read_file src/features/checkout/cart.tsx' },
{ t: mkISO(42), k: 'tool', m: 'run_shell "pnpm tsc --noEmit"' },
{ t: mkISO(41), k: 'stderr', m: ' src/features/checkout/cart.tsx(142,7): TS2339: ...' },
{ t: mkISO(40), k: 'error', m: 'Blocked: cannot reproduce the race locally. Paused for operator input.' },
],
},
},
{
id: 't4',
title: 'Write migration guide for v3 API',
list: 'api-core',
myDay: true, starred: false,
due: tomorrow.toISOString(),
notes: '',
tags: ['docs'],
subtasks: [],
created: mkISO(20), done: false,
agent: {
status: 'idle',
model: 'claude-sonnet-4.5',
worktree: '~/worktrees/api-core/v3-migration-guide',
branch: 'agent/v3-migration-guide',
baseBranch: 'main',
commits: 0,
diff: { files: 0, additions: 0, deletions: 0 },
turns: 0,
tokens: 0,
log: [
{ t: mkISO(20), k: 'sys', m: 'Worktree ready. Agent not yet dispatched.' },
],
},
},
{
id: 't5',
title: 'Upgrade Postgres client to v16',
list: 'ops',
myDay: true, starred: false,
due: yesterday.toISOString(),
notes: 'Coordinate with infra on the rolling restart window.',
tags: ['infra'],
subtasks: [],
created: mkISO(1440), done: false,
agent: {
status: 'queued',
model: 'claude-sonnet-4.5',
worktree: '~/worktrees/ops/pg-16',
branch: 'agent/pg-16',
baseBranch: 'main',
commits: 0,
diff: { files: 0, additions: 0, deletions: 0 },
turns: 0,
tokens: 0,
log: [
{ t: mkISO(30), k: 'sys', m: 'Queued · waiting for api-core/auth-refactor to complete.' },
],
},
},
{
id: 't6',
title: 'Fix favicon serving on preview domains',
list: 'tuning-web',
myDay: true, starred: false,
due: null, notes: '', tags: ['bug'],
subtasks: [],
created: mkISO(300),
done: true, completedAt: mkISO(60),
agent: {
status: 'done',
model: 'claude-sonnet-4.5',
worktree: '~/worktrees/tuning-web/favicon',
branch: 'agent/favicon',
baseBranch: 'main',
startedAt: mkISO(90),
finishedAt: mkISO(60),
commits: 1,
diff: { files: 2, additions: 14, deletions: 3 },
turns: 8,
tokens: 41800,
mergedInto: 'main',
log: [
{ t: mkISO(90), k: 'sys', m: 'Session started' },
{ t: mkISO(75), k: 'tool', m: 'edit_file nginx/preview.conf (+8 3)' },
{ t: mkISO(70), k: 'tool', m: 'edit_file public/favicon.ico (+6 0)' },
{ t: mkISO(65), k: 'done', m: 'Merged into main · closed PR #482' },
],
},
},
];
window.SEED_LISTS = SEED_LISTS;
window.SEED_USER_LISTS = SEED_USER_LISTS;
window.SEED_TASKS = SEED_TASKS;

View File

@@ -0,0 +1,123 @@
// Icons for ClaudeDo (line icons, 1.5px, lucide-ish but original)
const Icon = ({ name, size = 16, stroke = 'currentColor' }) => {
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke, strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' };
switch (name) {
case 'sun': return (
<svg {...common}><circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.5 5.5l1.4 1.4M17.1 17.1l1.4 1.4M5.5 18.5l1.4-1.4M17.1 6.9l1.4-1.4"/></svg>
);
case 'star': return (
<svg {...common}><path d="M12 3.5l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.2-4.1 5.8-.8z"/></svg>
);
case 'star-filled': return (
<svg {...common} fill="currentColor"><path d="M12 3.5l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.2-4.1 5.8-.8z"/></svg>
);
case 'calendar': return (
<svg {...common}><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
);
case 'user': return (
<svg {...common}><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/></svg>
);
case 'flag': return (
<svg {...common}><path d="M5 21V4M5 4h11l-2 4 2 4H5"/></svg>
);
case 'inbox': return (
<svg {...common}><path d="M3 13h5l1 2h6l1-2h5M3 13l3-8h12l3 8v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
);
case 'folder': return (
<svg {...common}><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
);
case 'search': return (
<svg {...common}><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
);
case 'plus': return (
<svg {...common}><path d="M12 5v14M5 12h14"/></svg>
);
case 'bell': return (
<svg {...common}><path d="M6 8a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6M10 20a2 2 0 0 0 4 0"/></svg>
);
case 'repeat': return (
<svg {...common}><path d="M17 3l3 3-3 3M20 6H7a4 4 0 0 0-4 4v1M7 21l-3-3 3-3M4 18h13a4 4 0 0 0 4-4v-1"/></svg>
);
case 'note': return (
<svg {...common}><path d="M7 4h7l5 5v11a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM14 4v5h5"/></svg>
);
case 'tag': return (
<svg {...common}><path d="M3 12V5a2 2 0 0 1 2-2h7l9 9-9 9z"/><circle cx="8" cy="8" r="1.4" fill="currentColor"/></svg>
);
case 'more': return (
<svg {...common}><circle cx="5" cy="12" r="1.3" fill="currentColor"/><circle cx="12" cy="12" r="1.3" fill="currentColor"/><circle cx="19" cy="12" r="1.3" fill="currentColor"/></svg>
);
case 'sort': return (
<svg {...common}><path d="M7 4v16M7 20l-3-3M7 4l-3 3M14 8h7M14 12h5M14 16h3"/></svg>
);
case 'eye': return (
<svg {...common}><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>
);
case 'grip': return (
<svg {...common}><circle cx="9" cy="6" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="18" r="1" fill="currentColor"/><circle cx="15" cy="6" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="18" r="1" fill="currentColor"/></svg>
);
case 'trash': return (
<svg {...common}><path d="M4 7h16M10 11v6M14 11v6M5 7l1 13a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1l1-13M9 7V4h6v3"/></svg>
);
case 'x': return (
<svg {...common}><path d="M6 6l12 12M18 6L6 18"/></svg>
);
case 'close': return (
<svg {...common} strokeWidth="1.3"><path d="M5 5l10 10M15 5L5 15"/></svg>
);
case 'min': return (
<svg {...common} strokeWidth="1.3"><path d="M5 10h10"/></svg>
);
case 'max': return (
<svg {...common} strokeWidth="1.3"><rect x="5" y="5" width="10" height="10"/></svg>
);
case 'sliders': return (
<svg {...common}><path d="M4 7h10M18 7h2M4 12h4M12 12h8M4 17h14M20 17h0"/><circle cx="14" cy="7" r="2" fill="var(--surface)"/><circle cx="10" cy="12" r="2" fill="var(--surface)"/><circle cx="18" cy="17" r="2" fill="var(--surface)"/></svg>
);
case 'check': return (
<svg {...common}><path d="M4 12l5 5 11-11"/></svg>
);
case 'windows': return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor"><path d="M3 5.5L11 4.2v7.3H3zM3 12.5h8v7.3L3 18.5zM12 4l9-1.5V12h-9zM12 12.5h9V20.5L12 19z"/></svg>
);
case 'pulse': return (
<svg {...common}><path d="M3 12h4l2-6 4 12 2-8 2 2h4"/></svg>
);
case 'branch': return (
<svg {...common}><circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="7" r="2"/><path d="M6 7v10M6 13c0-4 12-2 12-4"/></svg>
);
case 'terminal': return (
<svg {...common}><rect x="3" y="4.5" width="18" height="15" rx="2"/><path d="M7 10l3 2-3 2M13 14h5"/></svg>
);
case 'diff': return (
<svg {...common}><path d="M9 3v12M9 15a3 3 0 0 0 3 3h3M15 21v-9M15 9a3 3 0 0 1-3-3H9"/><circle cx="9" cy="18" r="2"/><circle cx="15" cy="6" r="2"/></svg>
);
case 'play': return (
<svg {...common} fill="currentColor" stroke="none"><path d="M7 5v14l12-7z"/></svg>
);
case 'pause': return (
<svg {...common}><rect x="7" y="5" width="3.5" height="14" rx="0.5" fill="currentColor" stroke="none"/><rect x="13.5" y="5" width="3.5" height="14" rx="0.5" fill="currentColor" stroke="none"/></svg>
);
case 'stop': return (
<svg {...common}><rect x="6" y="6" width="12" height="12" rx="1" fill="currentColor" stroke="none"/></svg>
);
case 'folder-open': return (
<svg {...common}><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2M3 7v11a2 2 0 0 0 2 2h13.5a2 2 0 0 0 2-1.5L22 10H6a2 2 0 0 0-2 1.5L3 18"/></svg>
);
case 'external': return (
<svg {...common}><path d="M14 4h6v6M10 14l10-10M20 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h6"/></svg>
);
case 'copy': return (
<svg {...common}><rect x="8" y="8" width="12" height="12" rx="1.5"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg>
);
case 'send': return (
<svg {...common}><path d="M4 12l16-8-5 18-4-8z"/></svg>
);
case 'cpu': return (
<svg {...common}><rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/></svg>
);
default: return null;
}
};
window.Icon = Icon;

View File

@@ -0,0 +1,650 @@
// The three islands: ListsIsland, TasksIsland, DetailsIsland
const { useState, useEffect, useRef, useMemo } = React;
// ---------- Helpers ----------
const fmtDate = (iso) => {
if (!iso) return null;
const d = new Date(iso);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const target = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const diff = Math.round((target - today) / 86400000);
if (diff === 0) return 'Today';
if (diff === 1) return 'Tomorrow';
if (diff === -1) return 'Yesterday';
if (diff < 0) return `${Math.abs(diff)}d overdue`;
if (diff < 7) return d.toLocaleDateString(undefined, { weekday: 'short' });
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
};
const isToday = (iso) => {
if (!iso) return false;
const d = new Date(iso); const n = new Date();
return d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate();
};
const isOverdue = (iso) => {
if (!iso) return false;
const d = new Date(iso); const n = new Date();
return d < new Date(n.getFullYear(), n.getMonth(), n.getDate());
};
const STATUS_LABEL = {
idle: 'Idle', queued: 'Queued', running: 'Running',
review: 'Review', done: 'Done', error: 'Error',
};
const relTime = (iso) => {
if (!iso) return '';
const diff = Math.max(0, Date.now() - new Date(iso).getTime());
const s = Math.floor(diff / 1000);
if (s < 60) return s + 's ago';
const m = Math.floor(s / 60);
if (m < 60) return m + 'm ago';
const h = Math.floor(m / 60);
if (h < 24) return h + 'h ago';
return Math.floor(h / 24) + 'd ago';
};
const logTime = (iso) => {
const d = new Date(iso);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
};
// ---------- Checkbox ----------
const Checkbox = ({ done, onToggle, size }) => (
<div
className={`check ${done ? 'done' : ''}`}
style={size ? { width: size, height: size } : undefined}
onClick={(e) => { e.stopPropagation(); onToggle(); }}
role="checkbox"
aria-checked={done}
>
<svg viewBox="0 0 24 24"><path d="M5 12l4.5 4.5L19 7"/></svg>
</div>
);
// ---------- Lists Island ----------
const ListsIsland = ({ activeList, setActiveList, counts, search, setSearch }) => {
return (
<div className="island">
<div className="island-header">
<div className="island-eyebrow"><span className="dot"/><span>Navigator</span></div>
<h2 className="island-title">Lists</h2>
</div>
<div className="search-wrap">
<Icon name="search" size={14} />
<input
placeholder="Search tasks…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<span className="kbd">K</span>
</div>
<div className="island-body">
<div className="list-section-label">Smart lists</div>
{SEED_LISTS.map((l) => (
<div
key={l.id}
className={`list-item ${activeList === l.id ? 'active' : ''}`}
onClick={() => setActiveList(l.id)}
>
<div className="icon"><Icon name={l.icon} size={15} /></div>
<div className="label">{l.name}</div>
<div className="count">{counts[l.id] ?? ''}</div>
</div>
))}
<div className="list-section-label">My lists</div>
{SEED_USER_LISTS.map((l) => (
<div
key={l.id}
className={`list-item ${activeList === l.id ? 'active' : ''}`}
onClick={() => setActiveList(l.id)}
>
<div className="swatch" style={{ background: l.color }} />
<div className="label">{l.name}</div>
<div className="count">{counts[l.id] ?? ''}</div>
</div>
))}
<button className="new-list-btn">
<Icon name="plus" size={14} />
<span>New list</span>
</button>
</div>
<div className="island-footer" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 28, height: 28, borderRadius: '50%',
background: 'linear-gradient(135deg, var(--moss), var(--sage))',
display: 'grid', placeItems: 'center',
fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--deep)', fontWeight: 600
}}>AK</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, color: 'var(--text)' }}>Aoife Kelly</div>
<div style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--text-faint)' }}>rider.island / local</div>
</div>
<button className="icon-btn" style={{ width: 26, height: 26 }} title="Settings">
<Icon name="more" size={14} />
</button>
</div>
</div>
);
};
// ---------- Tasks Island ----------
const TaskRow = ({ task, selected, onSelect, onToggle, onStar, leaving, entering }) => {
const [starPulse, setStarPulse] = useState(false);
const handleStar = (e) => {
e.stopPropagation();
setStarPulse(true);
setTimeout(() => setStarPulse(false), 400);
onStar();
};
const list = SEED_USER_LISTS.find((l) => l.id === task.list);
const overdue = isOverdue(task.due) && !task.done;
const today = isToday(task.due);
return (
<div
className={`task ${task.done ? 'done' : ''} ${selected ? 'selected' : ''} ${leaving ? 'leaving' : ''} ${entering ? 'entering' : ''}`}
onClick={onSelect}
>
<Checkbox done={task.done} onToggle={onToggle} />
<div className="task-body">
<div className="task-title">{task.title}</div>
<div className="task-meta">
{task.agent && (
<span className={`status-chip ${task.agent.status}`}>
<span className={`status-dot ${task.agent.status}`} />
{STATUS_LABEL[task.agent.status]}
</span>
)}
{list && (
<span className="chip" style={{ color: list.color }}>
<span style={{ width: 6, height: 6, borderRadius: 2, background: list.color, display: 'inline-block' }} />
{list.name}
</span>
)}
{task.agent?.branch && (
<span className="chip" title={task.agent.branch}>
<Icon name="branch" size={10} /> {task.agent.branch.replace('agent/', '')}
</span>
)}
{task.agent?.diff && task.agent.diff.files > 0 && (
<span className="chip">
<span className="diff-stats">
<span className="add">+{task.agent.diff.additions}</span>
<span className="del">{task.agent.diff.deletions}</span>
</span>
</span>
)}
{task.due && !task.agent && (
<span className={`chip ${overdue ? 'overdue' : today ? 'due-today' : ''}`}>
<Icon name="calendar" size={10} /> {fmtDate(task.due)}
</span>
)}
{task.subtasks && task.subtasks.length > 0 && (
<span className="subcount">
{task.subtasks.filter((s) => s.done).length}/{task.subtasks.length} steps
</span>
)}
{task.tags && task.tags.map((t) => <span key={t} className="tag">{t}</span>)}
</div>
{task.agent && task.agent.status === 'running' && task.agent.log && task.agent.log.length > 0 && (() => {
const last = task.agent.log[task.agent.log.length - 1];
return (
<div className="task-agent-line">
<span className="prompt"></span>
<span className="txt">{last.m}</span>
<span className="mini-cursor" />
</div>
);
})()}
</div>
<button
className={`star-btn ${task.starred ? 'on' : ''} ${starPulse ? 'pulse' : ''}`}
onClick={handleStar}
title={task.starred ? 'Unstar' : 'Mark important'}
>
<Icon name={task.starred ? 'star-filled' : 'star'} size={15} />
</button>
</div>
);
};
const TasksIsland = ({
tasks, selectedId, setSelected,
onToggle, onStar, onAdd,
leavingIds, enteringIds,
activeList, showCompleted, setShowCompleted,
}) => {
const [newTitle, setNewTitle] = useState('');
const inputRef = useRef(null);
const now = new Date();
const dateLine = now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
const activeTasks = tasks.filter((t) => !t.done);
const doneTasks = tasks.filter((t) => t.done);
const overdueTasks = activeTasks.filter((t) => isOverdue(t.due));
const todayTasks = activeTasks.filter((t) => !isOverdue(t.due));
const listMeta = SEED_LISTS.find((l) => l.id === activeList) || SEED_USER_LISTS.find((l) => l.id === activeList);
const title = activeList === 'myday' ? 'My Day' : (listMeta?.name || 'Tasks');
const eyebrow = activeList === 'myday' ? dateLine : `${activeTasks.length} open · ${doneTasks.length} done`;
const handleSubmit = (e) => {
e.preventDefault();
if (newTitle.trim()) {
onAdd(newTitle.trim());
setNewTitle('');
}
};
return (
<div className="island">
<div className="tasks-head">
<div className="tasks-meta">
<div>
<div className="tasks-date">{activeList === 'myday' ? 'My Day' : 'List'}</div>
<h1 className="tasks-title">{title}</h1>
<div className="tasks-subtitle">
{activeList === 'myday' ? dateLine : eyebrow}
<span className="sep">·</span>
{activeTasks.length} open
</div>
</div>
</div>
<div className="tasks-actions">
<button className="icon-btn" title="Sort"><Icon name="sort" size={15} /></button>
<button className={`icon-btn ${showCompleted ? 'active' : ''}`} onClick={() => setShowCompleted((v) => !v)} title="Show completed">
<Icon name="eye" size={15} />
</button>
<button className="icon-btn" title="More"><Icon name="more" size={15} /></button>
</div>
</div>
<form className="add-task" onSubmit={handleSubmit}>
<div className="plus">+</div>
<input
ref={inputRef}
placeholder="Add a task…"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
/>
<span className="hint">ENTER</span>
</form>
<div className="island-body">
{overdueTasks.length > 0 && (
<>
<div className="tasks-group-label" style={{ color: 'var(--blood)' }}>Overdue</div>
{overdueTasks.map((t) => (
<TaskRow
key={t.id}
task={t}
selected={selectedId === t.id}
onSelect={() => setSelected(t.id)}
onToggle={() => onToggle(t.id)}
onStar={() => onStar(t.id)}
leaving={leavingIds.includes(t.id)}
entering={enteringIds.includes(t.id)}
/>
))}
</>
)}
{todayTasks.length > 0 && (
<>
{overdueTasks.length > 0 && <div className="tasks-group-label">Tasks</div>}
{todayTasks.map((t) => (
<TaskRow
key={t.id}
task={t}
selected={selectedId === t.id}
onSelect={() => setSelected(t.id)}
onToggle={() => onToggle(t.id)}
onStar={() => onStar(t.id)}
leaving={leavingIds.includes(t.id)}
entering={enteringIds.includes(t.id)}
/>
))}
</>
)}
{activeTasks.length === 0 && (
<div style={{ padding: '40px 24px', textAlign: 'center', color: 'var(--text-faint)' }}>
<div style={{ fontFamily: 'var(--mono)', fontSize: 11, letterSpacing: '0.12em', textTransform: 'uppercase' }}>
All clear
</div>
<div style={{ fontSize: 12, marginTop: 6 }}>The harbor is calm. Add a task above.</div>
</div>
)}
{showCompleted && doneTasks.length > 0 && (
<>
<div className="tasks-group-label">Completed · {doneTasks.length}</div>
{doneTasks.map((t) => (
<TaskRow
key={t.id}
task={t}
selected={selectedId === t.id}
onSelect={() => setSelected(t.id)}
onToggle={() => onToggle(t.id)}
onStar={() => onStar(t.id)}
leaving={leavingIds.includes(t.id)}
entering={enteringIds.includes(t.id)}
/>
))}
</>
)}
</div>
</div>
);
};
// ---------- Worktree + Terminal sub-components ----------
const WorktreeCard = ({ agent, onOpenDiff, onOpenWorktree }) => {
if (!agent) return null;
return (
<div className="worktree-card">
<div className="row">
<span className="k">Worktree</span>
<span className="v path" title={agent.worktree}>{agent.worktree}</span>
<button className="copy-btn" title="Copy path"><Icon name="copy" size={12} /></button>
</div>
<div className="row">
<span className="k">Branch</span>
<span className="v">
<span className="branch"><Icon name="branch" size={11} /> {agent.branch}</span>
<span style={{ color: 'var(--text-faint)', marginLeft: 8 }}> {agent.baseBranch}</span>
</span>
</div>
<div className="row">
<span className="k">Diff</span>
<span className="v">
{agent.diff.files > 0 ? (
<span className="diff-stats">
<span>{agent.diff.files} files</span>
<span className="add">+{agent.diff.additions}</span>
<span className="del">{agent.diff.deletions}</span>
<span className="bars">
{Array.from({ length: 5 }).map((_, i) => {
const total = agent.diff.additions + agent.diff.deletions || 1;
const addShare = Math.round((agent.diff.additions / total) * 5);
return <span key={i} className={i < addShare ? 'add' : 'del'} />;
})}
</span>
</span>
) : <span style={{ color: 'var(--text-faint)' }}>No changes yet</span>}
</span>
</div>
{agent.commits > 0 && (
<div className="row">
<span className="k">Commits</span>
<span className="v">{agent.commits} on branch</span>
</div>
)}
<div className="action-row">
<button className="btn primary grow" onClick={onOpenDiff} disabled={agent.diff.files === 0}>
<Icon name="diff" size={12} /> Open diff
</button>
<button className="btn" onClick={onOpenWorktree} title="Open worktree folder">
<Icon name="folder-open" size={12} /> Worktree
</button>
<button className="btn icon-only" title="Open in editor">
<Icon name="external" size={12} />
</button>
</div>
</div>
);
};
const SessionTerminal = ({ agent, onInput }) => {
const bodyRef = useRef(null);
const [draft, setDraft] = useState('');
useEffect(() => {
if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
}, [agent?.log?.length]);
if (!agent) return null;
const running = agent.status === 'running';
const statusLabel = running ? 'LIVE' : STATUS_LABEL[agent.status];
return (
<div className="terminal">
<div className="terminal-head">
<div className="dots"><span className="r"/><span className="y"/><span className="g"/></div>
<span className="lbl">claude-session · {agent.branch}</span>
{running
? <span className="live"><span className="d"/>LIVE</span>
: <span className="live" style={{ color: 'var(--text-faint)' }}>{statusLabel}</span>}
</div>
<div className="terminal-body" ref={bodyRef}>
{(agent.log || []).map((l, i) => (
<div key={i} className={`log-line ${l.k}`}>
<span className="ts">{logTime(l.t)}</span>
<span className="tag">{l.k === 'msg' ? 'claude' : l.k === 'tool' ? 'tool' : l.k === 'sys' ? 'sys' : l.k === 'stdout' ? 'out' : l.k === 'stderr' ? 'err' : l.k}</span>
<span className="m">{l.m}</span>
</div>
))}
{running && (
<div className="log-line msg">
<span className="ts">{logTime(new Date().toISOString())}</span>
<span className="tag">claude</span>
<span className="m"><span className="cursor-block"/></span>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 6, padding: '8px 10px', borderTop: '1px solid var(--line)', background: 'var(--surface-2)' }}>
<span style={{ fontFamily: 'var(--mono)', color: 'var(--accent)', fontSize: 11, alignSelf: 'center' }}></span>
<input
placeholder={running ? 'Send a message to the agent…' : 'Agent not running'}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && draft.trim()) { onInput(draft); setDraft(''); } }}
disabled={!running}
style={{ flex: 1, fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--text)' }}
/>
<button className="btn icon-only" disabled={!draft.trim()}><Icon name="send" size={12} /></button>
</div>
</div>
);
};
// ---------- Details Island ----------
const DetailsIsland = ({ task, onUpdate, onDelete, onToggle, onStar, onAgentAction, onOpenDiff, onOpenWorktree, onAgentInput }) => {
if (!task) {
return (
<div className="island">
<div className="details-empty">
<div>
<div className="glyph"><Icon name="note" size={22} /></div>
<div className="label">No task selected</div>
<div className="hint">Pick a task from the middle<br/>to see its details here.</div>
</div>
</div>
</div>
);
}
const list = SEED_USER_LISTS.find((l) => l.id === task.list);
const created = task.created ? new Date(task.created).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '—';
const due = task.due ? new Date(task.due).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }) : 'None';
const toggleSub = (sid) => {
const next = task.subtasks.map((s) => s.id === sid ? { ...s, done: !s.done } : s);
onUpdate({ ...task, subtasks: next });
};
const addSub = (title) => {
if (!title.trim()) return;
const next = [...(task.subtasks || []), { id: 's' + Date.now(), title: title.trim(), done: false }];
onUpdate({ ...task, subtasks: next });
};
const [subDraft, setSubDraft] = useState('');
return (
<div className="island">
<div className="island-header" style={{ paddingBottom: 10 }}>
<div className="island-eyebrow">
<span className="dot" />
<span>Logbook</span>
<span style={{ marginLeft: 'auto', color: 'var(--text-faint)' }}>#{task.id}</span>
</div>
<h2 className="island-title" style={{ fontSize: 14, fontWeight: 500, color: 'var(--text-dim)' }}>
{task.agent ? 'Agent task' : 'Task details'}
</h2>
</div>
{task.agent && (
<div className="agent-strip">
<span className={`status-chip ${task.agent.status}`}>
<span className={`status-dot ${task.agent.status}`} />
{STATUS_LABEL[task.agent.status]}
</span>
<div className="meta">
<Icon name="cpu" size={10} /> {task.agent.model}
<span className="sep">·</span>
{task.agent.turns} turns
<span className="sep">·</span>
{(task.agent.tokens / 1000).toFixed(1)}k tok
{task.agent.startedAt && <><span className="sep">·</span>{relTime(task.agent.startedAt)}</>}
</div>
{task.agent.status === 'running' ? (
<button className="btn danger icon-only" onClick={() => onAgentAction(task.id, 'stop')} title="Stop agent"><Icon name="stop" size={12} /></button>
) : task.agent.status === 'idle' || task.agent.status === 'error' || task.agent.status === 'queued' ? (
<button className="btn primary" onClick={() => onAgentAction(task.id, 'start')}><Icon name="play" size={12} /> {task.agent.status === 'error' ? 'Retry' : 'Dispatch'}</button>
) : null}
</div>
)}
<div className="island-body">
<div className="details-title-row">
<Checkbox done={task.done} onToggle={() => onToggle(task.id)} />
<textarea
className="details-title"
value={task.title}
onChange={(e) => onUpdate({ ...task, title: e.target.value })}
rows={2}
/>
<button
className={`star-btn ${task.starred ? 'on' : ''}`}
style={{ opacity: 1 }}
onClick={() => onStar(task.id)}
>
<Icon name={task.starred ? 'star-filled' : 'star'} size={16} />
</button>
</div>
{task.agent && (
<div className="details-section">
<div className="details-section-label">Worktree</div>
<WorktreeCard
agent={task.agent}
onOpenDiff={() => onOpenDiff(task.id)}
onOpenWorktree={() => onOpenWorktree(task.id)}
/>
</div>
)}
{task.agent && (
<div className="details-section">
<div className="details-section-label">
Session output
<span style={{ marginLeft: 'auto', float: 'right', color: 'var(--text-mute)', fontFamily: 'var(--mono)', fontSize: 10 }}>
{(task.agent.log || []).length} lines
</span>
</div>
<SessionTerminal
agent={task.agent}
onInput={(msg) => onAgentInput(task.id, msg)}
/>
</div>
)}
{(task.subtasks || []).length > 0 && (
<div className="details-section">
<div className="details-section-label">Steps · {task.subtasks.filter(s => s.done).length}/{task.subtasks.length}</div>
{task.subtasks.map((s) => (
<div key={s.id} className={`subtask-row ${s.done ? 'done' : ''}`}>
<Checkbox done={s.done} onToggle={() => toggleSub(s.id)} />
<div className="label">{s.title}</div>
</div>
))}
<div className="subtask-add">
<div className="check" style={{ width: 16, height: 16, borderStyle: 'dashed' }} />
<input
placeholder="Add step"
value={subDraft}
onChange={(e) => setSubDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { addSub(subDraft); setSubDraft(''); } }}
/>
</div>
</div>
)}
<div className="details-section">
<div className="meta-row">
<span className="key">List</span>
<span className="val" style={{ color: list?.color || 'var(--text)' }}>
{list ? list.name : '—'}
</span>
</div>
<div className="meta-row">
<span className="key">Due</span>
<span className={`val ${isOverdue(task.due) && !task.done ? 'peat' : isToday(task.due) ? 'accent' : 'muted'}`}>{due}</span>
</div>
{!task.agent && (
<div className="meta-row">
<span className="key">Reminder</span>
<span className="val muted">{task.reminder || 'None'}</span>
</div>
)}
<div className="meta-row">
<span className="key">Important</span>
<span className={`val ${task.starred ? 'peat' : 'muted'}`}>{task.starred ? 'Starred' : 'No'}</span>
</div>
</div>
<div className="details-section">
<div className="details-section-label">Notes</div>
<textarea
className="notes-area"
placeholder="Add a note for the agent…"
value={task.notes || ''}
onChange={(e) => onUpdate({ ...task, notes: e.target.value })}
/>
</div>
{(task.tags || []).length > 0 && (
<div className="details-section" style={{ borderBottom: 0 }}>
<div className="details-section-label">Tags</div>
<div>
{task.tags.map((t) => <span key={t} className="tag-chip">{t}</span>)}
<span className="tag-chip" style={{ borderStyle: 'dashed', cursor: 'pointer' }}>+ add</span>
</div>
</div>
)}
</div>
<div className="island-footer" style={{ display: 'flex', gap: 8, justifyContent: 'space-between' }}>
<button className="icon-btn" title="Delete" onClick={() => onDelete(task.id)}>
<Icon name="trash" size={14} />
</button>
<div style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--text-faint)', alignSelf: 'center' }}>
Created {created}
</div>
<button className="icon-btn" title="Close">
<Icon name="x" size={14} />
</button>
</div>
</div>
);
};
window.ListsIsland = ListsIsland;
window.TasksIsland = TasksIsland;
window.DetailsIsland = DetailsIsland;

View File

@@ -0,0 +1,201 @@
// Diff modal + Worktree modal
const { useState: useStateM, useEffect: useEffectM } = window.React;
// Fake diff hunks per task
const DIFF_HUNKS = {
t1: [
{ file: 'src/middleware/auth.ts', adds: 48, dels: 22, hunks: [
{ header: '@@ -12,7 +12,9 @@ export function authMiddleware(', lines: [
{ k: 'ctx', n1: 12, n2: 12, t: ' const session = await getSession(req);' },
{ k: 'del', n1: 13, n2: null, t: ' if (!session) return unauthorized();' },
{ k: 'del', n1: 14, n2: null, t: ' const user = await lookupUser(session.userId);' },
{ k: 'add', n1: null, n2: 13, t: ' if (!session || session.expired) {' },
{ k: 'add', n1: null, n2: 14, t: ' return unauthorized("expired_or_missing");' },
{ k: 'add', n1: null, n2: 15, t: ' }' },
{ k: 'add', n1: null, n2: 16, t: ' const user = await pool.withConnection(c => lookupUser(c, session.userId));' },
{ k: 'ctx', n1: 15, n2: 17, t: ' req.user = user;' },
{ k: 'ctx', n1: 16, n2: 18, t: ' return next();' },
]},
{ header: '@@ -42,4 +44,6 @@ export function guard(', lines: [
{ k: 'ctx', n1: 42, n2: 44, t: ' return async (req, res, next) => {' },
{ k: 'del', n1: 43, n2: null, t: ' const s = await redis.get(req.cookies.sid);' },
{ k: 'add', n1: null, n2: 45, t: ' const s = await store.get(req.cookies.sid);' },
{ k: 'add', n1: null, n2: 46, t: ' if (s) store.touch(req.cookies.sid);' },
{ k: 'ctx', n1: 44, n2: 47, t: ' next();' },
]},
]},
{ file: 'src/lib/session/index.ts', adds: 31, dels: 14, hunks: [
{ header: '@@ -1,8 +1,14 @@', lines: [
{ k: 'del', n1: 1, n2: null, t: 'import { createClient } from "redis";' },
{ k: 'add', n1: null, n2: 1, t: 'import { SessionStore } from "./store";' },
{ k: 'add', n1: null, n2: 2, t: 'import { Pool } from "./pool";' },
{ k: 'ctx', n1: 2, n2: 3, t: '' },
{ k: 'del', n1: 3, n2: null, t: 'export const redis = createClient({ url: process.env.REDIS_URL });' },
{ k: 'add', n1: null, n2: 4, t: 'export const pool = new Pool({ size: 16 });' },
{ k: 'add', n1: null, n2: 5, t: 'export const store = new SessionStore(pool);' },
]},
]},
{ file: 'src/lib/session/ttl.ts', adds: 12, dels: 4, hunks: [] },
{ file: 'src/lib/session/store.ts', adds: 38, dels: 0, hunks: [] },
],
t2: [
{ file: 'src/pages/settings.tsx', adds: 32, dels: 2, hunks: [
{ header: '@@ -4,6 +4,8 @@ import { Section } from "../ui";', lines: [
{ k: 'ctx', n1: 4, n2: 4, t: 'import { useTheme } from "../hooks/useTheme";' },
{ k: 'add', n1: null, n2: 5, t: 'import { ThemeToggle } from "../ui/ThemeToggle";' },
{ k: 'ctx', n1: 5, n2: 6, t: '' },
{ k: 'ctx', n1: 6, n2: 7, t: 'export default function Settings() {' },
{ k: 'add', n1: null, n2: 8, t: ' const [theme, setTheme] = useTheme();' },
]},
]},
{ file: 'src/hooks/useTheme.ts', adds: 24, dels: 0, hunks: [] },
{ file: 'src/theme/tokens.css', adds: 10, dels: 8, hunks: [] },
{ file: 'src/ui/ThemeToggle.tsx', adds: 26, dels: 2, hunks: [] },
],
};
const DiffModal = ({ task, onClose }) => {
useEffectM(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const files = DIFF_HUNKS[task.id] || [
{ file: 'No diff available yet', adds: 0, dels: 0, hunks: [] }
];
const [activeFile, setActiveFile] = useStateM(0);
const current = files[activeFile];
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal diff-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Icon name="diff" size={14} />
<div>
<div className="modal-title">Diff · {task.agent.branch}</div>
<div className="modal-sub">
{task.agent.worktree} · {files.length} files ·
<span className="add" style={{ marginLeft: 6 }}>+{task.agent.diff.additions}</span>
<span className="del" style={{ marginLeft: 6 }}>{task.agent.diff.deletions}</span>
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn"><Icon name="external" size={12} /> Open in editor</button>
<button className="btn primary"><Icon name="check" size={12} /> Approve & merge</button>
<button className="icon-btn" onClick={onClose}><Icon name="close" size={12} /></button>
</div>
</div>
<div className="modal-body diff-body">
<div className="diff-sidebar">
{files.map((f, i) => (
<div key={f.file} className={`diff-file-tab ${i === activeFile ? 'active' : ''}`} onClick={() => setActiveFile(i)}>
<div className="diff-file-name" title={f.file}>{f.file}</div>
<div className="diff-file-stats">
<span className="add">+{f.adds}</span>
<span className="del">{f.dels}</span>
</div>
</div>
))}
</div>
<div className="diff-view">
<div className="diff-file-header">
<Icon name="note" size={12} />
<span>{current.file}</span>
<span style={{ marginLeft: 'auto' }} className="diff-stats">
<span className="add">+{current.adds}</span>
<span className="del">{current.dels}</span>
</span>
</div>
{current.hunks.length === 0 ? (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-faint)', fontFamily: 'var(--mono)', fontSize: 11 }}>
Select a hunk no detail preview available for this file.
</div>
) : current.hunks.map((h, hi) => (
<div key={hi} className="diff-hunk">
<div className="diff-hunk-header">{h.header}</div>
{h.lines.map((ln, li) => (
<div key={li} className={`diff-line ${ln.k}`}>
<span className="ln">{ln.n1 ?? ''}</span>
<span className="ln">{ln.n2 ?? ''}</span>
<span className="sign">{ln.k === 'add' ? '+' : ln.k === 'del' ? '' : ' '}</span>
<span className="t">{ln.t}</span>
</div>
))}
</div>
))}
</div>
</div>
</div>
</div>
);
};
const WorktreeModal = ({ task, onClose }) => {
useEffectM(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const fakeTree = [
{ kind: 'dir', path: 'src', children: [
{ kind: 'dir', path: 'middleware', children: [
{ kind: 'file', path: 'auth.ts', mod: true },
]},
{ kind: 'dir', path: 'lib/session', children: [
{ kind: 'file', path: 'index.ts', mod: true },
{ kind: 'file', path: 'ttl.ts', mod: true },
{ kind: 'file', path: 'store.ts', added: true },
]},
]},
{ kind: 'file', path: 'package.json' },
{ kind: 'file', path: 'README.md' },
];
const render = (nodes, depth = 0) => nodes.map((n) => (
n.kind === 'dir' ? (
<React.Fragment key={n.path}>
<div className="tree-row" style={{ paddingLeft: 10 + depth * 14 }}>
<Icon name="folder" size={12} /> <span>{n.path}</span>
</div>
{render(n.children, depth + 1)}
</React.Fragment>
) : (
<div key={n.path} className={`tree-row ${n.mod ? 'mod' : ''} ${n.added ? 'added' : ''}`} style={{ paddingLeft: 10 + depth * 14 }}>
<Icon name="note" size={12} />
<span>{n.path}</span>
{n.mod && <span className="tree-badge mod">M</span>}
{n.added && <span className="tree-badge add">A</span>}
</div>
)
));
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal worktree-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<div>
<div className="modal-title"><Icon name="folder-open" size={14} /> {task.agent.worktree}</div>
<div className="modal-sub">{task.agent.branch} {task.agent.baseBranch}</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn"><Icon name="terminal" size={12} /> Open terminal</button>
<button className="icon-btn" onClick={onClose}><Icon name="close" size={12} /></button>
</div>
</div>
<div className="modal-body" style={{ padding: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '10px 16px', borderBottom: '1px solid var(--line)', fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--text-mute)' }}>
Filesystem preview modified files marked <span style={{ color: 'var(--peat)' }}>M</span>, additions <span style={{ color: 'var(--moss-bright)' }}>A</span>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 8, fontFamily: 'var(--mono)', fontSize: 12 }}>
{render(fakeTree)}
</div>
</div>
</div>
</div>
);
};
window.DiffModal = DiffModal;
window.WorktreeModal = WorktreeModal;

File diff suppressed because it is too large Load Diff

102
docs/mailbox-proposal.md Normal file
View File

@@ -0,0 +1,102 @@
# Task Mailbox — Push Messages Into Running Sessions
**Status:** PARKED (2026-06-04) — not building this.
**Why parked:** The generic Claude-Mailbox plugin (the `mcp__mailbox__*` tools used in normal sessions) already covers the core need — cross-session messaging, inbox checks, a sender — at the harness level for any project. Integrating it directly into ClaudeDo (task/worktree-scoped inboxes, per-worktree CLAUDE.md + hook seeding, UI badges, `send_to_peer`) is a sizable build (migration + MCP tools + SignalR + UI + hooks) for marginal gain over the plugin. Revisit only if the generic plugin proves insufficient for the parallel-session workflow. The original proposal is kept below for reference.
---
**Context:** the user runs parallel Claude sessions (e.g. backend + frontend) and wants to push messages into a session while it's busy inside a subagent. A shared folder works for one-offs; this turns it into a first-class ClaudeDo feature so every future parallel-session project gets it for free.
## Problem
Claude CLI processes one turn at a time. While a subagent (or any long tool) runs, no new user input can be injected. The harness offers no mid-execution interrupt. The workable window is *between* tool calls — so we need a cheap "inbox check" the agent can poll at natural checkpoints, plus a UI affordance and a cross-session sender.
## Design
### 1. Data
New table `task_messages`:
| col | type | notes |
|---|---|---|
| `id` | INTEGER PK | |
| `task_id` | TEXT FK → tasks.id | recipient |
| `sender` | TEXT | `'user'` \| `'task:<id>'` (for cross-session) |
| `body` | TEXT | markdown |
| `created_at` | TEXT | ISO |
| `delivered_at` | TEXT NULL | set when inbox pulls it |
EF Core migration + repository. Async, CancellationToken, matches existing conventions.
### 2. Worker MCP tools (extend existing `mcp__claudedo__*` server)
- **`check_inbox(task_id)`** → returns undelivered messages for this task and marks them delivered. Idempotent. Empty array if nothing pending.
- **`send_to_task(task_id, body)`** → inserts a row. Callable from *any* session — this is how the frontend session tells the backend session something.
- **`inbox_status(task_id)`** → `{ pending: int }` for a cheap "is there anything?" poll.
All three run in-proc in the Worker, go through the existing repository layer.
### 3. SignalR additions on `WorkerHub`
Server methods (UI → Worker):
- `SendTaskMessage(taskId, body)` — UI calls this; worker inserts the row and fires `TaskMessageQueued`.
Client events (Worker → UI):
- `TaskMessageQueued(taskId, pendingCount)` — so the UI can show an unread badge.
- `TaskMessageDelivered(taskId, pendingCount)` — when the agent pulls it, badge clears.
### 4. UI
On every `Running` task row + detail pane:
- "Send to session" textarea + Enter to submit → `SendTaskMessage`.
- Unread badge showing `pendingCount`.
- Read-only message timeline (who sent what, when delivered).
### 5. Agent-side poll discipline
Two complementary mechanisms so it's robust whether or not the agent remembers:
**a) CLAUDE.md instruction** (seeded by worker into each worktree's `CLAUDE.md`):
> After every subagent completes and before starting the next step, call `mcp__claudedo__check_inbox`. Treat returned messages as user input with priority over the current plan.
**b) PostToolUse hook on `Agent`** (written into the worktree's `.claude/settings.json` by the Worker when it creates the tree):
- Runs `mcp__claudedo__inbox_status` via a tiny CLI shim the worker ships.
- If `pending > 0`, the hook emits a system reminder: "Inbox has N pending messages — call `mcp__claudedo__check_inbox` now."
- Keeps the burden off the agent's memory. Belt + suspenders.
### 6. Cross-session pattern
Backend session and frontend session are just two tasks with known IDs. Either can call `send_to_task(other_id, body)` via the MCP server. No shared folder needed — the DB is already the shared channel.
To make this ergonomic:
- A "linked tasks" concept: tag two tasks as peers at creation time. The Worker exposes `send_to_peer(body)` as sugar around `send_to_task` so neither session needs to hardcode the other's UUID.
## Limits (honest)
- Messages arrive *between* tool calls, not mid-tool. A 20-minute subagent still blocks 20 minutes. Splitting work into shorter subagents is still the right discipline.
- If the agent ignores the CLAUDE.md instruction, the hook catches it next tool call — but we can't force immediate consumption.
- `-p` (print) mode with stdin prompt is one-shot and can't be extended. This design targets *interactive* sessions (Planning Sessions already use this mode). For queued `-p` runs, the mailbox is effectively a post-run instruction carrier.
## Why this is the repeatable "Grundgerüst"
Once this lands in ClaudeDo, the workflow becomes:
1. Create two linked tasks (`backend`, `frontend`) with `working_dir` set.
2. Start each — each gets its own worktree, its own Planning Session terminal, its own inbox with `check_inbox` + `send_to_peer`.
3. Push messages from the UI or from the other session. No per-project scaffolding, no custom hooks, no shared folder.
Every future parallel-session project inherits the mailbox.
## Build order (suggested)
1. Migration + repo + model. Tests first.
2. MCP tools (`check_inbox`, `send_to_task`, `inbox_status`) + unit tests.
3. SignalR method + events + UI textarea/badge.
4. Worker writes CLAUDE.md addendum + `.claude/settings.json` hook into each new worktree.
5. Linked-tasks sugar (`send_to_peer`).
6. Manual verification: queue a long subagent, send a message, confirm it's picked up at the next tool boundary.
## Open questions
- Should messages be deleted or soft-kept after delivery? Leaning soft-kept for the timeline UI.
- Priority / interrupt semantics — do we want a "high priority" flag that the agent should surface immediately vs. batch?
- Should `send_to_peer` also work when the peer is `Queued` (i.e. not yet running)? Probably yes — deliver on start.

View File

@@ -1,193 +1,30 @@
# ClaudeDo — Offene Punkte
Stand: 2026-04-13 nach Slice F. Branch `main` @ `48e4aab`. Alle Tests grün (38/38), Build 0 Warnings.
Dieses Dokument listet alles, was noch fehlt — gruppiert nach Aufwand/Risiko und mit konkreten Datei-Pointern, damit wir es in der IDE der Reihe nach durchgehen können.
Stand: 2026-06-04. **Nur noch offene Punkte.** Was erledigt ist, steht in den Commits und im Code — nicht hier.
---
## 1. Verification (vor allem anderen)
## Manuelle Verifikation (offen)
Die in `plan.md` definierten Verification-Steps sind teilweise nur durch Build/Tests abgedeckt. Diese sollten manuell einmal durchlaufen werden, BEVOR wir Polish bauen — damit wir wissen, was tatsächlich kaputt ist.
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.
| # | Plan | Status | Was tun |
|---|------|--------|---------|
| 1 | Schema-Init | Auto verifiziert (Worker startet ohne Crash, WAL-Files entstehen) | OK |
| 1a | SignalR-Endpoint | Manuell verifiziert (HTTP 400 auf `/hub` ohne Handshake) | OK |
| 1b | Hub-Roundtrip `Ping` | **Nicht getestet** | Test-Client schreiben oder UI starten und im Log nach "pong" schauen |
| 2 | `claude --version` Preflight | **Nicht implementiert** | `Worker/Program.cs`: vor `app.Run()` einmal `claude --version` shellen und bei Exit≠0 abbrechen |
| 3 | Smoke-Spawn (`claude -p` mit Prompt "ping") | **Nicht getestet** | Integrationstest schreiben oder einmal manuell laufen lassen |
| 4 | E2E Happy Path (Non-Worktree) | **Nicht getestet** | UI starten → Liste "Test" anlegen → Task mit Tag `agent` + Status `queued` + Description "Schreibe ein Haiku über Intralogistik" → Run abwarten → Result prüfen |
| 5 | Worktree Happy Path | **Nicht getestet** | Manueller Test mit echtem Repo (z.B. einem temp-Repo) |
| 6 | No-Changes-Run | **Nicht getestet** | Prompt der nichts ändert → `head_commit` bleibt NULL |
| 7 | Kein Git-Repo | **Nicht getestet** | working_dir auf `C:\Temp` → Task `failed`, keine `worktrees`-Row |
| 8 | Merge-UI | **Nicht getestet** (UI ruft `GitService.MergeFfOnlyAsync`, aber nie ausgeführt) | Manuell |
| 9 | Override-Parallelität | Tests vorhanden für Slot-Logik, **End-to-End nicht** | UI: zwei Tasks queuen, `Run Now` auf der zweiten → beide laufen parallel |
| 10 | Schedule | Logik per Test abgedeckt, **End-to-End nicht** | Task mit `scheduled_for = now+2min` |
| 11 | Worker-Offline-Erkennung | UI hat Status-Bar, aber **nicht visuell verifiziert** | Worker killen, schauen ob Status auf "offline" wechselt |
| 12 | Live-Stream | **Nicht getestet** | Während Run TaskDetail öffnen, beobachten ob ndjson-Zeilen erscheinen |
| 13 | Wake-up (UI ruft `WakeQueue` nach Anlage) | Implementiert in `TaskListViewModel`, **nicht visuell verifiziert** | Tasks nach Anlage in <1s gepickert |
- **Worktree-Pipeline:**
- 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`.
**Vorschlag:** Wir machen einmal Step 4 (Haiku-Happy-Path) gemeinsam — wenn das läuft, ist die ganze Pipeline lebendig.
## Offene Code-Punkte
- **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.
---
## 2. UI-Polish (kritisch für Benutzbarkeit)
## Bewusst verworfen (nicht erneut vorschlagen)
Im aktuellen Stand kompiliert die UI, aber mehrere Stellen sind als `// TODO` markiert. Reihenfolge nach Schmerz:
### 2.1 Folder-Picker für `Working Directory`
- **Datei:** `src/ClaudeDo.Ui/Views/ListEditorView.axaml` + `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs`
- **Aktuell:** plain `TextBox` — Pfad muss getippt werden.
- **Soll:** Button "…" daneben → öffnet `IStorageProvider.OpenFolderPickerAsync`, schreibt Pfad ins Feld.
- **Aufwand:** klein, ~30 Zeilen.
### 2.2 Delete-Confirmation
- **Dateien:** `MainWindowViewModel.DeleteList`, `TaskListViewModel.DeleteTask`
- **Aktuell:** löscht direkt ohne Rückfrage. Datenverlust-Risiko.
- **Soll:** Mini-Dialog "Wirklich löschen?" mit Ja/Nein.
- **Aufwand:** klein, generisches `ConfirmDialog` lohnt sich (1× bauen, mehrfach nutzen).
### 2.3 Markdown-Rendering für Result + Description
- **Datei:** `src/ClaudeDo.Ui/Views/TaskDetailView.axaml`
- **Aktuell:** `TextBox IsReadOnly="True"` mit Plaintext.
- **Soll:** `Markdown.Avalonia` Package einbinden und auf `MarkdownScrollViewer` umstellen.
- **Aufwand:** mittel — Package + ein paar XAML-Anpassungen. Theme-Integration kann nerven.
### 2.4 Live-Log Auto-Scroll
- **Datei:** `src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs` (oder im VM)
- **Aktuell:** ndjson-Zeilen werden angehängt, aber Scrollposition bleibt stehen.
- **Soll:** Bei jeder neuen Zeile `ScrollViewer.ScrollToEnd()` solange User nicht manuell hochgescrollt hat (Sticky-Bottom-Pattern).
- **Aufwand:** klein, ein attached behavior reicht.
### 2.5 Diff-Viewer
- **Datei:** `TaskDetailViewModel.ShowDiffAsync`
- **Aktuell:** `Process.Start("cmd", "/k git diff …")` — separates Konsolenfenster, hässlich.
- **Soll:** entweder unified-diff inline anzeigen (`git diff` Output in `TextBox` mit Mono-Font + Color für +/-) oder einen externen Diff-Tool-Hook (`git difftool`).
- **Aufwand:** mittel. MVP: einfach nur den Diff-Output in einem Modal.
### 2.6 Status-Bar Active-Tasks Live-Update
- **Datei:** `StatusBarViewModel`
- **Risiko:** das Slot-State-Update kommt vom WorkerClient, aber `RunNowCommand.NotifyCanExecuteChanged` triggert nicht pro Item bei `IsConnected`-Wechsel (vom Slice-F-Agent dokumentiert).
- **Soll:** Über `WeakReferenceMessenger` (CommunityToolkit.Mvvm) eine Connection-Change-Message verteilen, an die alle `TaskItemViewModel` lauschen.
- **Aufwand:** klein, aber muss sauber gemacht werden.
### 2.7 Settings-Dialog
- **Datei:** *neu*`Views/SettingsDialog.axaml` + VM
- **Aktuell:** `~/.todo-app/ui.config.json` muss von Hand editiert werden.
- **Soll:** Dialog mit Feldern: DB-Pfad, SignalR-Port, Default-Tags. Persistiert zurück in JSON.
- **Aufwand:** mittel. Achtung: Port-Wechsel braucht Worker-Restart.
---
## 3. Worker-Robustheit
### 3.1 CLI-Preflight beim Worker-Start
- **Datei:** `src/ClaudeDo.Worker/Program.cs`
- **Soll:** vor `app.Run()` `claude --version` ausführen; bei Fehler `app.Logger.LogCritical` + `Environment.Exit(1)`.
- **Aufwand:** klein, ~20 Zeilen. Liefert Verification Step 2.
### 3.2 Worktree-Cleanup beim Anlege-Failed
- **Datei:** `src/ClaudeDo.Worker/Runner/WorktreeManager.cs`
- **Aktuell:** Wenn `WorktreeAddAsync` zwischen `CreateAsync`-Schritten failt (z.B. Branch existiert schon), bleibt evtl. ein halbangelegter Worktree-Dir auf der Platte.
- **Soll:** try/finally — bei Fehler `git worktree remove --force` als Best-Effort-Cleanup.
- **Aufwand:** klein.
### 3.3 Logging über `Microsoft.Extensions.Logging` strukturieren
- **Datei:** alle Worker-Komponenten
- **Aktuell:** ILogger wird benutzt, aber kein File-Sink konfiguriert.
- **Soll:** Optional Serilog oder einfach `AddFile` (Karambolage.Extensions.Logging.File) — Service-Modus braucht persistente Logs außerhalb der Console.
- **Aufwand:** klein.
### 3.4 Tag-Negation / Exclusion (Plan-TODO)
- **Plan-Sektion:** "Tag-Modell"
- **Aktuell:** Tags sind rein additiv (`list_tags task_tags`).
- **Soll:** Mechanismus, um auf Task-Ebene einen List-Tag auszuschließen. Z.B. neue Tabelle `task_tag_exclusions` ODER ein Prefix `!tag` im task_tags-Eintrag.
- **Aufwand:** mittel — Schema + Repo + Tests + UI.
---
## 4. Service-Deployment (Plan-Sektion „Worker als Windows-Service")
### 4.1 Windows-Service-Hosting in Code
- **Datei:** `src/ClaudeDo.Worker/Program.cs`
- **Pakete:** `Microsoft.Extensions.Hosting.WindowsServices`
- **Soll:**
```csharp
builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker");
builder.Logging.AddEventLog(...);
```
- **Aufwand:** klein.
### 4.2 Pfad-Auflösung absolut machen
- Bereits in `WorkerConfig.Load` per `Paths.Expand` gemacht — verifizieren, dass auch `cfg.ClaudeBin` ggf. in Service-PATH gefunden wird.
### 4.3 Install-Skripte / Doku
- **Datei:** *neu* — `docs/install-service.md` oder `scripts/install-service.cmd`
- **Inhalt:** `dotnet publish` + `sc.exe create` + `sc.exe failure` + Hinweis auf `obj=` (User-Account) wegen Claude-CLI-Session.
- **Aufwand:** klein.
### 4.4 (später) Installer-Projekt
- WiX/MSIX, registriert Service + UI-Shortcut. Plan-Sektion „Offene Punkte".
---
## 5. Tests / CI
### 5.1 GitHub-Actions / Gitea-Actions Pipeline
- **Datei:** *neu* — `.gitea/workflows/ci.yml` (oder `.github/workflows/ci.yml`)
- **Inhalt:** `dotnet restore` → `dotnet build --no-restore` → `dotnet test --no-build`. Auf Push + PR.
- **Aufwand:** klein.
### 5.2 Echter SignalR-Roundtrip-Test
- **Datei:** *neu* — `tests/ClaudeDo.Worker.Tests/Hub/WorkerHubTests.cs`
- **Soll:** mit `WebApplicationFactory` + `HubConnectionBuilder` testen, dass `Ping`, `GetActive`, `RunNow`-Throw-Verhalten korrekt sind. Plan-Verification 1b + 9.
- **Aufwand:** mittel.
### 5.3 Smoke-Test gegen echten `claude`
- **Datei:** *neu* — `tests/ClaudeDo.Worker.Tests/Runner/ClaudeProcessSmokeTest.cs`
- **Soll:** Real-CLI-Test, der mit `[Fact(Skip="..."]` ausgegraut bleibt und nur lokal aktiviert wird, wenn `CLAUDE_AUTHENTICATED=1` Env-Var gesetzt ist.
- **Aufwand:** klein.
---
## 6. Dokumentation
### 6.1 README.md
- Komplett fehlt. Mind. 1× kurz: was ist es, wie starten (Worker + UI), wo Config.
- **Aufwand:** klein.
### 6.2 `docs/architecture.md`
- In `plan.md` schon teilweise enthalten — kann entweder konsolidiert oder explizit ausgegliedert werden.
### 6.3 ADRs für die getroffenen Entscheidungen
- Z.B. „SignalR vs. SQLite-Polling für IPC", „Worktree pro Task", „SignalR über Loopback ohne Auth".
- **Aufwand:** klein, hilfreich für später.
---
## 7. Bekannte Code-Schulden / Smells
| Stelle | Issue |
|---|---|
| `WorkerHub.GetActive` returnt `IReadOnlyList<object>` mit anonymen Typen | Sollte ein expliziter DTO sein (`ActiveTaskDto`), den Worker UND Ui teilen. Aktuell duplizieren beide das Schema. |
| `TaskRunner` führt eine `if (list.WorkingDir != null)` Verzweigung mitten in der Methode | Strategy-Pattern (`IRunStrategy`: SandboxStrategy, WorktreeStrategy) wenn die Methode wächst. Aktuell noch klein genug. |
| `App.Services` als public static `ServiceProvider` | Service-Locator-Antipattern. Toleriert, weil nur in `App.OnFrameworkInitializationCompleted` verwendet. Falls mehr Code drauf zugreift → echtes DI durchziehen. |
| Embedded `schema.sql` ohne Versionierung | Solange das Schema nicht in Production läuft, OK. Sobald User-Daten existieren → `migrations/` Folder + Version-Tabelle. |
| CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer. |
---
## Empfohlene Reihenfolge für die nächste Session
1. **Verification Step 4** zusammen durchspielen → falls etwas grundlegend kaputt ist, jetzt finden, nicht später.
2. **CLI-Preflight (3.1)** + **Folder-Picker (2.1)** + **Delete-Confirm (2.2)** — kleine, isolierte Wins.
3. **Auto-Scroll (2.4)** + **Active-Tasks Live-Update (2.6)** — User-Experience im Detail-Pane.
4. **Markdown-Rendering (2.3)** — größer, lohnt sich aber für Lesbarkeit.
5. **Worktree-Cleanup (3.2)** — Robustheit, bevor wir Worktrees ernsthaft nutzen.
6. **CI-Pipeline (5.1)** — automatisches Sicherheitsnetz für alles weitere.
7. **Service-Deployment (4)** — wenn die App lokal stabil läuft.
8. **Settings-Dialog (2.7)** + **Diff-Viewer (2.5)** — Polish.
9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird.
Punkte 13 sind ein realistischer Block für eine Session.
- **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`.
- **`architecture.md` / ADRs** — die per-Projekt-`CLAUDE.md`-Dateien sind die lebende Doku; ADRs lohnen solo nicht.
- **Task-Mailbox-Integration** — geparkt; das generische `mcp__mailbox__*`-Plugin reicht (Begründung in `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).

View File

@@ -49,7 +49,9 @@ Schema in 3NF. Keine Mehrwert-Felder (z.B. JSON-Arrays), keine transitiven Abhä
- `list_id` TEXT NOT NULL REFERENCES `lists(id)` ON DELETE CASCADE
- `title` TEXT NOT NULL
- `description` TEXT NULL
- `status` TEXT NOT NULL — `manual` | `queued` | `running` | `done` | `failed` (`running` bleibt persistiert für Crash-Recovery: stale `running`-Tasks werden beim Worker-Start auf `failed` gesetzt)
- `status` TEXT NOT NULL — Lifecycle-only: `idle` | `queued` | `running` | `done` | `failed` | `cancelled` (`running` bleibt persistiert für Crash-Recovery: stale `running`-Tasks werden beim Worker-Start auf `failed` gesetzt). Planungs-Hierarchie und Chain-Blocking laufen über zwei separate Felder.
- `planning_phase` TEXT NOT NULL DEFAULT `'none'` — Parent-only Marker: `none` | `active` (Planung läuft) | `finalized` (Plan committed, Children existieren). Ein Parent kann `status='idle'` sein und gleichzeitig `planning_phase='finalized'` (für Re-Runs).
- `blocked_by_task_id` TEXT NULL REFERENCES `tasks(id)` ON DELETE SET NULL — Vorgänger in einem sequenziellen Subtask-Chain. Ein `queued`-Row mit `blocked_by_task_id IS NOT NULL` wird vom Picker übersprungen.
- `scheduled_for` TIMESTAMP NULL — "nicht vor"
- `result` TEXT NULL (Markdown)
- `log_path` TEXT NULL — Pfad zur ndjson-Log-Datei
@@ -229,36 +231,21 @@ Beispiel: `feat(lager-app): add barcode scan retry logic`
DB-Zugriff via Microsoft.Data.Sqlite + Repository-Layer (`TaskRepository`, `ListRepository`). Git-Operationen (UI + Worker) über gemeinsamen `GitService` in `ClaudeDo.Data`. MVVM via CommunityToolkit.Mvvm.
## Worker als Windows-Service (Ziel-Deployment)
## Worker-Deployment (Autostart via Startup-Shortcut)
Initial läuft der Worker als Console-Prozess (lokales Dev-Setup). Im Endzustand soll er als **Windows-Service** automatisch starten.
Der Worker läuft als **WinExe** (kein Konsolenfenster) — kein Windows-Service, kein Scheduled Task.
**Code-seitig:**
- Paket `Microsoft.Extensions.Hosting.WindowsServices` referenzieren.
- In `Program.cs`: `builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker")`.
- Logging zusätzlich über `EventLog` (`builder.Logging.AddEventLog(...)`), damit Service-Fehler im Windows Event Viewer landen.
- Alle Pfade in `worker.config.json` **absolut** auflösen (`%USERPROFILE%` / `~` expandieren) — der Service-Working-Directory ist standardmäßig `C:\Windows\System32`.
- `StaleTaskRecovery` (siehe oben) sorgt nach Service-Restart automatisch für das Aufräumen hängender `running`-Tasks.
- Restart-Verhalten via `sc.exe failure`-Konfig oder beim Install.
**Autostart:** Der Installer legt eine Verknüpfung `ClaudeDo Worker.lnk` im Startup-Ordner des Users an (`%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\`). Dafür nutzt `ClaudeDo.Installer` den Helper `AutostartShortcut` (mit extrahiertem `ShortcutFactory` COM-Helper). Beim Windows-Logon startet Windows die Verknüpfung automatisch — ohne Elevated-Rechte und mit vollem Zugriff auf die `~/.claude/`-Session des Users.
**Install:**
- Veröffentlichen mit `dotnet publish -c Release -r win-x64 --self-contained false`.
- Service registrieren:
```cmd
sc.exe create ClaudeDoWorker binPath= "C:\Path\To\ClaudeDo.Worker.exe" start= auto
sc.exe failure ClaudeDoWorker reset= 60 actions= restart/5000/restart/10000/restart/30000
```
- Später optional: kleines `ClaudeDo.Installer`-Projekt (WiX oder MSIX), das das auch macht.
**Manueller Start (App-seitig):** Der Installer-Step `StartWorkerStep` startet den Worker beim Install/Update via `Process.Start` direkt. Die App (`IslandsShellViewModel`) startet den Worker **nicht** selbst. Stattdessen: ist der Worker ~12 Sekunden nach App-Start noch offline, erscheint einmalig ein `WorkerConnectionModal` mit drei Optionen (Start Worker / Rerun Installer / Dismiss). Der Connection-Status-Pill in der Fußzeile ist ein klickbarer Button, der das Modal auf Anfrage erneut öffnet.
**Auth-Konflikt mit "User-CLI-Session" beachten:**
Der Worker-Service läuft per Default unter `LocalSystem` — der hat **keinen Zugriff** auf die `~/.claude/`-Session des interaktiven Users, in der der CLI-Login liegt. Optionen:
**Stop/Uninstall:** `StopWorkerStep` beendet den Worker via prozessbasiertem Kill (kein `schtasks /End` mehr). `UninstallRunner` löscht die Startup-`.lnk`. Als Migrations-Schritt für ältere Installationen löscht der Uninstaller auch den Legacy-Scheduled-Task „ClaudeDoWorker" und den Legacy-Windows-Service (best-effort).
1. **Empfohlen:** Service unter dem **User-Account** laufen lassen (`sc.exe config ClaudeDoWorker obj= ".\<username>" password= "..."` oder via `services.msc` → "Log On As"). Dann greift die bestehende `claude login`-Session des Users. Voraussetzung: User-Account hat das Recht "Log on as a service".
2. **Fallback:** Wieder auf API-Key wechseln (`ANTHROPIC_API_KEY` als Umgebungsvariable des Service oder im `worker.config.json`). Dann ist der Service unabhängig vom User-Profil — verliert aber den Vorteil "kein Key-Handling".
**Logging:** Serilog-File-Sink nach `~/.todo-app/logs/worker-*.log`. Single-Instance-Mutex verhindert parallele Instanzen.
Entscheidung wird beim Service-Deployment getroffen, bleibt für die initiale Console-Variante irrelevant. Service-Modus erfordert keine Schema- oder API-Änderungen am Worker.
**Pfade:** `WorkerConfig.Load` expandiert `~`/`%USERPROFILE%` für alle Pfad-Felder.
**SignalR im Service-Modus:** Bindung bleibt `127.0.0.1:47821`. Da die UI auf demselben Rechner läuft, ist Loopback-Erreichbarkeit gegeben — Windows-Firewall greift bei Loopback nicht.
**SignalR:** Bindung bleibt `127.0.0.1:47821`. Da die UI auf demselben Rechner läuft, ist Loopback-Erreichbarkeit gegeben — Windows-Firewall greift bei Loopback nicht.
## Project-Layout (Monorepo)
@@ -317,4 +304,4 @@ Vorteil Monorepo: gemeinsames `schema.sql`, atomische Änderungen über UI+Worke
- Bulk-Discard alter Worktrees.
- Anzeige der ndjson-Message-Chronik im UI.
- Windows Job Objects für garantierten Child-Cleanup beim Worker-Crash.
- Installer-Projekt (`ClaudeDo.Installer`, WiX/MSIX), das den Service registriert + UI shortcut anlegt.
- Install-Skripte/Doku für manuelles Deployment ohne Installer.

217
docs/prompts-inventory.md Normal file
View File

@@ -0,0 +1,217 @@
# ClaudeDo — Prompt & CLI Inventory
Snapshot of every string ClaudeDo sends to Claude CLI, plus the CLI-flag surface that shapes each run. Intended as a working doc for tomorrow's prompt-tuning pass.
Date: 2026-04-24
> **Update 2026-06-04 — prompts externalized.** All prose prompts now live as
> editable files under `~/.todo-app/prompts/`, each seeded from a bundled default in
> `src/ClaudeDo.Data/PromptFiles.cs` (read via `ReadOrDefault` / `Render`, which
> substitutes only named `{tokens}`):
> `system.md`, `planning-system.md`, `planning-initial.md` (`{title}`/`{description}`),
> `retry.md`, `daily-prep.md` (`{date}`/`{maxTasks}`), `weekly-report.md`
> (`{start}`/`{end}`; German output). The old `agent.md` and `planning.md` are
> retired — `system.md` is the single appended system prompt (the agent/manual split
> is gone), and the planning system prompt is `planning-system.md`. Daily-prep and
> retry prompts are now English; retry leans on the resumed session and appends the
> captured stderr only when it's a real error (not the generic "exited with code N").
> The system prompt instructs the agent to emit `CLAUDEDO_BLOCKED: <reason>` on its
> own line for any true blocker; `StreamAnalyzer` collects every marker, strips them
> from the result, and `TaskRunner` folds them into the review result as a
> "⚠ Roadblocks" section. All six prompt files are editable from Settings → Files.
---
## 1. Task-execution prompts (agent-tagged tasks → Claude CLI)
Used for every "agent" task that the queue picks up or that `RunNow` dispatches.
Orchestration lives in `src/ClaudeDo.Worker/Runner/TaskRunner.cs` and `ClaudeArgsBuilder.cs`.
### 1.1 User prompt (stdin) — `TaskRunner.RunAsync` ~L101L110
Plain text, no template around it:
```
{task.Title}
{task.Description?.Trim()} ← only if non-empty
## Sub-Tasks ← only if subtasks exist
- [ ] {subtask.Title} ← "[x]" if completed
...
```
Notes
- Title is included verbatim — no leading `#` heading.
- No role tags, no XML, no delimiters between title and description — just blank lines.
- Sub-Tasks section uses markdown checkboxes. This is the only structural scaffolding.
- No context about the project, working dir, or git state is added here.
### 1.2 Retry prompt (on failure, when a session ID exists) — `TaskRunner` ~L126
```
The previous attempt failed with:
{result.ErrorMarkdown}
Try again and fix the issues.
```
Fired once per task via `--resume <session_id>`; if the retry also fails, the task is marked Failed.
### 1.3 Follow-up prompt (multi-turn `ContinueAsync`) — `TaskRunner.ContinueAsync` L159
The UI/hub supplies `followUpPrompt` as-is; no wrapping. The session is resumed via `--resume`. So the effective "prompt template" is whatever the user types in the Continue textbox.
### 1.4 System prompt — merged in `TaskRunner` ~L413L418
Built by `TaskRunner.MergeInstructions(global, list, task)` which concatenates three optional strings with `\n\n`:
1. `AppSettings.DefaultClaudeInstructions` (global, set in Settings modal, default `""`)
2. `list_config.SystemPrompt` (per-list override)
3. `task.SystemPrompt` (per-task override)
The merged string is passed as `--append-system-prompt <instructions>` to the CLI. Empty/whitespace → flag is omitted entirely.
**Currently the global `DefaultClaudeInstructions` ships as empty string** (see `AppSettingsEntity.cs` L9). Anything in the system prompt today is whatever the user typed into Settings / List-Settings / Task-Settings.
### 1.5 CLI args — `ClaudeArgsBuilder.Build` (`ClaudeArgsBuilder.cs`)
Always on:
- `-p`
- `--output-format stream-json`
- `--verbose`
- `--permission-mode {auto|acceptEdits|plan|default}` (legacy `bypassPermissions``auto`)
Conditional:
- `--model {sonnet|opus|haiku|...}` — from `task.Model ?? list.Model ?? AppSettings.DefaultModel` (default `sonnet`)
- `--max-turns {n}``AppSettings.DefaultMaxTurns` (default `100`)
- `--append-system-prompt "{merged instructions}"` — see 1.4
- `--agents '[{"file":"{path}"}]'` — from task or list override, points at an agent `.md`
- `--resume {session_id}` — for retries and `ContinueAsync`
Unused but pre-declared:
- `ResultSchema` — a `{summary, files_changed, commit_type}` JSON schema is serialized but **never attached** to args in `Build`. Dead code today; relevant if we turn on `--output-schema`.
---
## 2. Planning-agent prompts (`/plan` / Planning session)
Used by the Planning feature, which spawns a Claude session inside a git worktree with MCP tools so the agent can create Subtasks under the parent.
Source: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`.
### 2.1 System prompt — `BuildSystemPrompt()` L290L308
```
You are a planning assistant for ClaudeDo.
Your role is to help break down a task into smaller, actionable subtasks.
Your final goal WILL ALWAYS be the creation of Subtasks
ALWAYS invoke the `superpowers:brainstorming` skill via the Skill tool at the
start of every planning session, and follow its process end-to-end. It guides
you through clarifying questions, approach exploration, and design approval
BEFORE any subtasks are created. Do not create child tasks until the user has
approved a design.
NEVER Change files yourself.
ALWAYS Use the available MCP tools (mcp__claudedo__*) to create child tasks once the
design is approved. When you are done planning, finalize the session.
Be concise and focused. Each subtask should be independently executable.
```
Written to `{session-dir}/system-prompt.md` at session start and fed via `--append-system-prompt`.
Notes / known oddities
- Trailing space on "NEVER Change files yourself. " and on the blank line above the ALWAYS/MCP block.
- Mixes voice ("Your role is", "ALWAYS invoke") — could be tightened.
- Implicitly relies on the `superpowers:brainstorming` skill being installed in the worktree's Claude config.
- Does not name the MCP tools explicitly (the `mcp__claudedo__*` wildcard assumes the agent discovers them via tool listing).
### 2.2 Initial prompt — `BuildInitialPrompt(task)` L310L323
```
# Task: {task.Title}
{task.Description} ← only if non-empty
---
Please analyze this task and break it down into concrete subtasks.
```
Written to `{session-dir}/initial-prompt.txt`; the Windows Terminal launcher pipes it to the Claude CLI on start.
### 2.3 Planning session CLI flags
`PlanningSessionManager` itself does not build CLI args — the `WindowsTerminalPlanningLauncher` does. Relevant facts:
- Permission mode: **plan** (per recent commit `8e9f09a` "run planning agent in plan permission mode and enforce brainstorming skill").
- Runs with an `.mcp.json` that points at our local MCP server (`http://127.0.0.1:{port}/mcp`) with a per-session bearer token.
- `.claude/settings.local.json` sets `"enableAllProjectMcpServers": true` so the MCP tools auto-activate.
---
## 3. Commit-message template (not a prompt, but agent-visible)
Built by `CommitMessageBuilder.Build` (`CommitMessageBuilder.cs`). Format:
```
{commitType}({listSlug}): {title ≤60 chars}
{description ≤400 chars} ← only if set
ClaudeDo-Task: {taskId}
```
- `commitType` comes from `task.CommitType` (default `chore`, list default configurable).
- Slug = lowercased list name with non-alphanumerics stripped, runs collapsed to `-`.
- The agent sees the resulting commit in `git log` during retries and follow-ups, so phrasing here bleeds into model behavior on multi-turn work.
---
## 4. Where each prompt is edited (UI surface)
| Prompt slot | Edited in | Stored as |
|-------------------------------------|--------------------------------------------|--------------------------------------------|
| Global `DefaultClaudeInstructions` | Settings modal (`SettingsModalViewModel`) | `app_settings.DefaultClaudeInstructions` |
| Per-list system prompt | List-Settings modal | `list_config.SystemPrompt` |
| Per-task system prompt | Details island / task agent settings | `tasks.system_prompt` |
| Per-task agent file | Details island | `tasks.agent_path` (absolute `.md` path) |
| Default model / max turns / perms | Settings modal | `app_settings.*` |
| Planning system prompt | **Hard-coded** in `PlanningSessionManager` | not editable from UI |
| Planning initial prompt template | **Hard-coded** in `PlanningSessionManager` | not editable from UI |
| Retry prompt | **Hard-coded** in `TaskRunner` | not editable |
| Task prompt structure (title/desc) | **Hard-coded** in `TaskRunner` | not editable |
---
## 5. Things worth reviewing tomorrow
1. **Task-execution prompt has no frame at all.** Just title + description. Consider whether a thin wrapper (goal / constraints / done-criteria) improves agent focus without bloating small tasks.
2. **Global DefaultClaudeInstructions is empty out of the box.** This is the cleanest place to put project-wide guardrails (commit format, branch etiquette, verify-before-done, no force push). Right now nothing is there.
3. **Planning system prompt**:
- Typo-level: trailing spaces, inconsistent capitalization ("ALWAYS"/"NEVER"/"Always").
- "Your final goal WILL ALWAYS be the creation of Subtasks" conflicts slightly with "Do not create child tasks until the user has approved a design" — rewordable.
- Does not state how many subtasks is reasonable, nor how granular.
- Does not describe the MCP tool surface; the agent has to discover `mcp__claudedo__*` tools.
4. **Retry prompt is minimal.** `"Try again and fix the issues."` — could be firmer about not repeating the same failure mode.
5. **Sub-Tasks block** is dumped as plain checkboxes with no instruction ("please complete all open items", "do them in order", etc.). If the user relies on subtasks for ordering, that intent isn't conveyed.
6. **ResultSchema is defined but unused.** Decide: drop it, or wire it up (`--output-schema`) and start asking for structured summaries.
7. **Commit-message template** never tells the agent what `commit_type` to pick when it has flexibility — the value is hard-coded per task. Consider exposing as a prompt hint or inferring from diffs.
---
## 6. File pointers
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — user/retry/follow-up prompts, MergeInstructions
- `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs` — CLI args + ResultSchema
- `src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs` — commit template
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — planning system + initial prompts
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs` — planning CLI invocation
- `src/ClaudeDo.Data/Models/AppSettingsEntity.cs` — global defaults
- `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` — UI for global defaults
- `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs` — UI for per-list overrides

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,705 @@
# Logic Bug Fixes 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:** Fix confirmed logic bugs across Worker, App/Ui, and Installer found in the 2026-04-17 three-agent review.
**Architecture:** Each bug is an isolated change to one or two files. Group by priority (Critical → High → Medium → Info). TDD where the bug is observable via xUnit integration test (Worker, Data); for UI/Installer bugs without test harness, do a focused manual repro and guard with a regression comment referencing the commit.
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm, EF Core + SQLite, SignalR, xUnit (Worker tests only).
---
## File Map
**Worker:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — remove premature `RunCreated` broadcast
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — emit `RunCreated` after run row insert
- Modify: `src/ClaudeDo.Worker/Services/QueueService.cs` — add slot-collision guard on `RunNow`/`ContinueTask`
- Modify: `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs` — extend quoting to cover whitespace/newline
- Test: `tests/ClaudeDo.Worker.Tests/ClaudeArgsBuilderTests.cs` — regression for newline in system prompt
- Test: `tests/ClaudeDo.Worker.Tests/QueueServiceSlotGuardTests.cs` — regression for RunNow-while-queued
**Ui:**
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — guard nullability in `AddTask`, harden `OnTaskUpdated`
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs` — defer `_taskId` assignment until after cancel check
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs` — init TCS before dialog shown
**Installer:**
- Modify: `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs` — remove inline start; reject CurrentUser without password
- Modify: `src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs` — rename-before-extract rollback
- Modify: `src/ClaudeDo.Installer/Core/UninstallRunner.cs` — add `removeAppData` parameter
- Modify: `src/ClaudeDo.Installer/Steps/WriteConfigStep.cs` — expand `~` in `UiDbPath`
- Verify: `src/ClaudeDo.Installer/App.xaml.cs` — confirm Avalonia vs WPF usings
---
## Critical
### Task 1: Worker — fix `RunCreated` broadcast ordering (W1)
Bug: `WorkerHub.RunNow` fires `RunCreated` before the run row is inserted by `RunOnceAsync`. UI can receive an event for a row that does not yet exist.
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:35-50`
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:234-256` (`RunOnceAsync`)
- [ ] **Step 1: Remove premature broadcast from WorkerHub**
In `src/ClaudeDo.Worker/Hub/WorkerHub.cs`, replace the body of `RunNow`:
```csharp
public async Task RunNow(string taskId)
{
try
{
await _queue.RunNow(taskId);
}
catch (InvalidOperationException)
{
throw new HubException("override slot busy");
}
catch (KeyNotFoundException)
{
throw new HubException("task not found");
}
}
```
- [ ] **Step 2: Emit `RunCreated` inside `RunOnceAsync` after row insert**
In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, find `RunOnceAsync`. After the `runRepo.AddAsync(run, ct);` block (~line 256), add:
```csharp
await _broadcaster.RunCreated(taskId, runNumber, isRetry);
```
Then remove the existing `await _broadcaster.RunCreated(task.Id, 2, true);` on line 128 (inside the auto-retry block in `RunAsync`) and the `await _broadcaster.RunCreated(taskId, nextRunNumber, false);` on line 219 (in `ContinueAsync`), since `RunOnceAsync` now broadcasts unconditionally.
- [ ] **Step 3: Build and run Worker tests**
Run: `dotnet build ClaudeDo.slnx && dotnet test tests/ClaudeDo.Worker.Tests`
Expected: all existing tests pass.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/Runner/TaskRunner.cs
git commit -m "fix(worker): emit RunCreated after run row exists"
```
---
### Task 2: Ui — harden `OnTaskUpdated` against async void crash (U2)
Bug: `TaskListViewModel.OnTaskUpdated` is `async void` with no try/catch. A DB error escapes to `TaskScheduler.UnobservedTaskException` and can crash the process.
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs:328-332`
- [ ] **Step 1: Wrap handler body in try/catch**
Replace the existing method with:
```csharp
private async void OnTaskUpdated(string taskId)
{
if (CurrentListId is null) return;
try
{
await RefreshSingleAsync(taskId);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[TaskListViewModel] OnTaskUpdated failed for {taskId}: {ex}");
}
}
```
- [ ] **Step 2: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
git commit -m "fix(ui): swallow DB errors in TaskListViewModel.OnTaskUpdated"
```
---
### Task 3: Installer — reject CurrentUser service registration without password (I1)
Bug: `RegisterServiceStep` passes `obj=.\<user>` to `sc.exe create` with no `password=`. SCM rejects it with exit 5 / 1069 and the user gets an opaque error.
**Files:**
- Modify: `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs`
- [ ] **Step 1: Read the current file**
Read `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs` to confirm the exact shape of the `obj=` branch and the outer `StepResult` return.
- [ ] **Step 2: Replace CurrentUser branch with early failure**
Where the step builds `obj=".\\<username>"` for the `CurrentUser` account option, replace it with:
```csharp
if (ctx.ServiceAccount == ServiceAccountType.CurrentUser)
{
return StepResult.Fail(
"Service cannot run as Current User without a password. " +
"Select 'Local System' or extend ServicePage to capture a password.");
}
```
Keep the `LocalSystem` branch (which passes `obj= LocalSystem` with no password requirement) unchanged.
- [ ] **Step 3: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs
git commit -m "fix(installer): reject CurrentUser service account without password"
```
---
## High
### Task 4: Worker — guard slot collision on `RunNow` and `ContinueTask` (W2)
Bug: Queue slot and override slot have no guard against operating on the same `taskId`. `TaskRunner.MarkRunningAsync` can overwrite `started_at`.
**Files:**
- Modify: `src/ClaudeDo.Worker/Services/QueueService.cs:59-115`
- Test: `tests/ClaudeDo.Worker.Tests/QueueServiceSlotGuardTests.cs` (new)
- [ ] **Step 1: Write failing test**
Create `tests/ClaudeDo.Worker.Tests/QueueServiceSlotGuardTests.cs`:
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Services;
using Xunit;
namespace ClaudeDo.Worker.Tests;
public class QueueServiceSlotGuardTests : WorkerTestBase
{
[Fact]
public async Task RunNow_rejects_task_already_active_in_queue_slot()
{
var queue = ServiceProvider.GetRequiredService<QueueService>();
var task = await SeedAgentTaskAsync(listId: await SeedListAsync(), title: "blocker");
// Prime queue slot by wake signal.
queue.WakeQueue();
await WaitForActiveSlotAsync("queue", task.Id);
// RunNow on the same id must throw InvalidOperationException.
await Assert.ThrowsAsync<InvalidOperationException>(() => queue.RunNow(task.Id));
}
}
```
(Helpers `WorkerTestBase`, `SeedAgentTaskAsync`, `SeedListAsync`, `WaitForActiveSlotAsync` exist in the test project — follow the pattern from existing tests.)
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~QueueServiceSlotGuardTests"`
Expected: FAIL — currently `RunNow` succeeds and creates a duplicate slot.
- [ ] **Step 3: Add guard in `RunNow`**
In `src/ClaudeDo.Worker/Services/QueueService.cs`, inside the `lock (_lock)` block in `RunNow` (~line 69), add before the existing override check:
```csharp
if (_queueSlot?.TaskId == taskId)
throw new InvalidOperationException("task is already running in queue slot");
```
- [ ] **Step 4: Add same guard in `ContinueTask`**
In the `lock (_lock)` block in `ContinueTask` (~line 97), add:
```csharp
if (_queueSlot?.TaskId == taskId)
throw new InvalidOperationException("task is already running in queue slot");
```
- [ ] **Step 5: Run tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Services/QueueService.cs tests/ClaudeDo.Worker.Tests/QueueServiceSlotGuardTests.cs
git commit -m "fix(worker): guard against same task in queue and override slot"
```
---
### Task 5: Worker — quote CLI args with tab/newline/carriage-return (W3)
Bug: `ClaudeArgsBuilder.Escape` only quotes on space/quote. System prompts with newlines pass through unquoted and corrupt the argument list.
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs:56-64`
- Test: `tests/ClaudeDo.Worker.Tests/ClaudeArgsBuilderTests.cs` (new or existing)
- [ ] **Step 1: Write failing test**
If `ClaudeArgsBuilderTests.cs` does not exist, create it:
```csharp
using ClaudeDo.Worker.Runner;
using Xunit;
namespace ClaudeDo.Worker.Tests;
public class ClaudeArgsBuilderTests
{
[Fact]
public void Build_quotes_system_prompt_with_newline()
{
var builder = new ClaudeArgsBuilder();
var args = builder.Build(new ClaudeRunConfig(
Model: null,
SystemPrompt: "line1\nline2",
AgentPath: null,
ResumeSessionId: null));
Assert.Contains("--append-system-prompt \"line1\\nline2\"", args);
}
[Fact]
public void Build_quotes_system_prompt_with_tab()
{
var builder = new ClaudeArgsBuilder();
var args = builder.Build(new ClaudeRunConfig(
Model: null,
SystemPrompt: "col1\tcol2",
AgentPath: null,
ResumeSessionId: null));
Assert.Contains("\"col1", args);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilderTests"`
Expected: FAIL — newline is passed through unquoted.
- [ ] **Step 3: Extend `Escape` condition and escape newline/tab**
In `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs`, replace `Escape`:
```csharp
private static string Escape(string value)
{
if (value.Contains(' ') || value.Contains('"') || value.Contains('\'')
|| value.Contains('\t') || value.Contains('\n') || value.Contains('\r'))
{
var escaped = value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\n", "\\n")
.Replace("\r", "\\r")
.Replace("\t", "\\t");
return $"\"{escaped}\"";
}
return value;
}
```
- [ ] **Step 4: Run tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilderTests"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs tests/ClaudeDo.Worker.Tests/ClaudeArgsBuilderTests.cs
git commit -m "fix(worker): escape newline/tab in CLI args"
```
---
### Task 6: Installer — remove inline service start from `RegisterServiceStep` (I2)
Bug: `RegisterServiceStep` calls `sc.exe start` inline. `StartServiceStep` exists separately. If the update path ever wires both, the service is started twice.
**Files:**
- Modify: `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs`
- Modify: `src/ClaudeDo.Installer/App.xaml.cs` (pipeline) — ensure `StartServiceStep` is in the fresh-install pipeline
- [ ] **Step 1: Read current pipeline wiring in App.xaml.cs**
Read `src/ClaudeDo.Installer/App.xaml.cs` around line 112 to confirm the list of steps passed into `InstallerService`.
- [ ] **Step 2: Remove inline `sc.exe start` from RegisterServiceStep**
Delete the block (~lines 72-77) that runs `sc.exe start <service>` when `ctx.AutoStart == true`.
- [ ] **Step 3: Add `StartServiceStep` to the fresh-install pipeline if missing**
In `App.xaml.cs`, append `new StartServiceStep(...)` after `RegisterServiceStep` in the step list. Gate its execution internally on `ctx.AutoStart` (it already handles exit code 1056).
- [ ] **Step 4: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs src/ClaudeDo.Installer/App.xaml.cs
git commit -m "fix(installer): move service start out of RegisterServiceStep"
```
---
### Task 7: Installer — rollback-safe extract in `DownloadAndExtractStep` (I3)
Bug: Old `app/` and `worker/` are deleted before extraction. If extraction throws, user is left with no binaries and no recovery path.
**Files:**
- Modify: `src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs:70-95`
- [ ] **Step 1: Read the current delete/extract sequence**
Read `src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs` around lines 70-95 to identify the exact `Directory.Delete` and `ZipFile.ExtractToDirectory` calls and which `ctx` paths they reference.
- [ ] **Step 2: Replace delete-before-extract with rename-then-commit**
Wrap the delete+extract block:
```csharp
var appDir = Path.Combine(ctx.InstallRoot, "app");
var workDir = Path.Combine(ctx.InstallRoot, "worker");
var appBak = appDir + ".bak";
var workBak = workDir + ".bak";
// Stash existing dirs.
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
if (Directory.Exists(workBak)) Directory.Delete(workBak, recursive: true);
if (Directory.Exists(appDir)) Directory.Move(appDir, appBak);
if (Directory.Exists(workDir)) Directory.Move(workDir, workBak);
try
{
ZipFile.ExtractToDirectory(zipPath, ctx.InstallRoot, overwriteFiles: true);
}
catch
{
// Roll back to previous binaries.
if (Directory.Exists(appDir)) Directory.Delete(appDir, recursive: true);
if (Directory.Exists(workDir)) Directory.Delete(workDir, recursive: true);
if (Directory.Exists(appBak)) Directory.Move(appBak, appDir);
if (Directory.Exists(workBak)) Directory.Move(workBak, workDir);
throw;
}
// Success — drop stash.
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
if (Directory.Exists(workBak)) Directory.Delete(workBak, recursive: true);
```
- [ ] **Step 3: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
git commit -m "fix(installer): rollback-safe extract with .bak stash"
```
---
### Task 8: Installer — gate `~/.todo-app` deletion behind explicit consent (I4)
Bug: Uninstaller always deletes user data (db, logs, configs). Reinstalling a different version silently destroys all tasks.
**Files:**
- Modify: `src/ClaudeDo.Installer/Core/UninstallRunner.cs:60-80`
- Modify: `src/ClaudeDo.Installer/Views/UninstallPage.xaml(.cs)` (or equivalent) — add a checkbox
- [ ] **Step 1: Read `UninstallRunner.RunAsync` signature**
Read `src/ClaudeDo.Installer/Core/UninstallRunner.cs` around lines 1-90 to get current signature.
- [ ] **Step 2: Add `removeAppData` parameter to `RunAsync`**
Change signature to:
```csharp
public async Task<UninstallResult> RunAsync(bool removeAppData, CancellationToken ct = default)
```
Guard the deletion:
```csharp
if (removeAppData)
{
var appData = Paths.Expand("~/.todo-app");
if (Directory.Exists(appData))
Directory.Delete(appData, recursive: true);
}
```
- [ ] **Step 3: Wire a "Remove user data" checkbox on the uninstall page**
In the uninstall view/VM, add `[ObservableProperty] private bool _removeAppData;` (default `false`) and pass it into `RunAsync(RemoveAppData)`.
- [ ] **Step 4: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Installer/Core/UninstallRunner.cs src/ClaudeDo.Installer/Views/UninstallPage.xaml src/ClaudeDo.Installer/Views/UninstallPage.xaml.cs
git commit -m "fix(installer): make user-data deletion on uninstall opt-in"
```
---
## Medium
### Task 9: Ui — guard `AddTask` against null `CurrentListId` after await (U1)
Bug: `AddTask` awaits `editor.LoadAgentsAsync`. Between `CanAddTask` and `listRepo.GetByIdAsync(CurrentListId)` on line 164, a concurrent `LoadAsync(null)` could null the id. Compiler warns CS8604.
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs:157-170`
- [ ] **Step 1: Capture `CurrentListId` before the first `await`**
Replace the start of `AddTask`:
```csharp
[RelayCommand(CanExecute = nameof(CanAddTask))]
private async Task AddTask()
{
var listId = CurrentListId;
if (listId is null) return;
string defaultCommitType;
using (var context = _dbFactory.CreateDbContext())
{
var listRepo = new ListRepository(context);
var list = await listRepo.GetByIdAsync(listId);
defaultCommitType = list?.DefaultCommitType ?? "chore";
}
var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForCreate(listId, defaultCommitType);
// …rest unchanged, but use `listId` consistently where CurrentListId was read
```
Audit the rest of the method: replace every subsequent read of `CurrentListId` with `listId`.
- [ ] **Step 2: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds with no CS8604 on this method.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
git commit -m "fix(ui): capture CurrentListId before await in AddTask"
```
---
### Task 10: Ui — defer `_taskId` assignment in `TaskDetailViewModel.LoadAsync` (U3)
Bug: `_taskId = taskId` is set at line 87, before the previous `_loadCts` is cancelled. If load is cancelled, `_taskId` has been clobbered but `HasWorktree` / `CanWorktreeAction` still reflect the previous task.
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs:76-90`
- [ ] **Step 1: Reset stale worktree state when starting a new load**
Replace the start of `LoadAsync`:
```csharp
public async Task LoadAsync(string taskId)
{
var oldCts = _loadCts;
var cts = new CancellationTokenSource();
_loadCts = cts;
oldCts?.Cancel();
oldCts?.Dispose();
var ct = cts.Token;
_taskId = taskId;
// Clear stale worktree state so buttons don't act on the previous task.
HasWorktree = false;
WorktreeState = "";
BranchName = null;
DiffStat = null;
WorktreePath = null;
OnPropertyChanged(nameof(CanWorktreeAction));
LiveText = "";
_formatter = new StreamLineFormatter();
// …rest unchanged
```
- [ ] **Step 2: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs
git commit -m "fix(ui): reset stale worktree state on TaskDetail reload"
```
---
### Task 11: Ui — initialize TCS before dialog shown in `TaskEditorViewModel` (U4)
Bug: `ShowAndWaitAsync` creates a fresh `_tcs` only when called. If `Save` fires before `ShowAndWaitAsync` (possible if `ShowDialogAsync` is ever awaited), the result is dropped.
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs:72-80, 260-264`
- Modify: `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs` (same pattern — apply identically)
- [ ] **Step 1: Reset `_tcs` at the start of `InitForCreate` and `InitForEditAsync`**
In `TaskEditorViewModel.cs`, at the top of `InitForCreate`:
```csharp
public void InitForCreate(string listId, string defaultCommitType = "chore")
{
_tcs = new TaskCompletionSource<TaskEntity?>();
_editId = null;
// …rest unchanged
```
Same first line at the top of `InitForEditAsync` and `InitForEdit`.
- [ ] **Step 2: Remove re-assignment in `ShowAndWaitAsync`**
```csharp
public Task<TaskEntity?> ShowAndWaitAsync() => _tcs.Task;
```
- [ ] **Step 3: Apply the same pattern to `ListEditorViewModel`**
Mirror the same three edits in `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs` (reset TCS in its `InitForCreate` / `InitForEdit`, strip the creation in `ShowAndWaitAsync`).
- [ ] **Step 4: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs
git commit -m "fix(ui): init editor TCS before dialog can complete"
```
---
### Task 12: Installer — expand `~` in `UiDbPath` (I5)
Bug: `workerCfg.DbPath = Paths.Expand(ctx.DbPath)` but `uiCfg.DbPath = ctx.UiDbPath` is stored as-is. If UI cannot expand `~` at runtime on Windows, DB path is unresolvable.
**Files:**
- Modify: `src/ClaudeDo.Installer/Steps/WriteConfigStep.cs:31-34`
- [ ] **Step 1: Expand UiDbPath symmetrically**
Change the assignment:
```csharp
uiCfg.DbPath = Paths.Expand(ctx.UiDbPath);
```
- [ ] **Step 2: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Installer/Steps/WriteConfigStep.cs
git commit -m "fix(installer): expand ~ in UiDbPath"
```
---
## Info
### Task 13: Installer — verify App.xaml.cs WPF-vs-Avalonia usings (I6)
Suspected bug: `src/ClaudeDo.Installer/App.xaml.cs` uses `System.Windows` (WPF). If the project is Avalonia, wrong base class is inherited.
**Files:**
- Read: `src/ClaudeDo.Installer/App.xaml.cs`
- Read: `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
- [ ] **Step 1: Inspect the csproj for the UI framework SDK**
Read `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` and look for `<UseWPF>` or `<PackageReference Include="Avalonia" ... />`.
- [ ] **Step 2: Decision fork**
- If WPF (`<UseWPF>true</UseWPF>`): `System.Windows` is correct. Stop. No fix needed.
- If Avalonia: replace `using System.Windows;` with `using Avalonia;` and change `Application` / `StartupEventArgs` / `ExitEventArgs` to Avalonia equivalents (`Avalonia.Application`, lifetime `OnFrameworkInitializationCompleted`).
- [ ] **Step 3: Build**
Run: `dotnet build ClaudeDo.slnx`
Expected: build succeeds.
- [ ] **Step 4: Commit only if changed**
```bash
git add src/ClaudeDo.Installer/App.xaml.cs
git commit -m "fix(installer): use Avalonia application base class"
```
---
## Out of Scope
Deferred / not fixed in this plan:
- `TryScheduleTrampolineDelete` PID-less delay (I6 in review, low severity) — `ping -n 3` is flaky but rarely hit
- `AvailableAgents` being `List<T>` instead of `ObservableCollection<T>` (U5/info) — current `OnPropertyChanged` pattern works; revisit only if a bug manifests
---
## Self-Review Notes
- Every Worker bug (W1W3) has a regression test or tested path.
- Every UI fix names the exact file:line and shows the replacement snippet.
- Installer Task 3 (I1) does not guess a password-capture UI — it deliberately returns `StepResult.Fail`, leaving the UX change for a later plan.
- Task 13 (I6) is a conditional task with a decision fork; no speculative rewrite.
- Types are consistent: `RunCreated(taskId, runNumber, isRetry)` in Task 1 matches the existing `HubBroadcaster.RunCreated` signature used at `TaskRunner.cs:128,219`.

View File

@@ -0,0 +1,209 @@
# UI Polish — Design Parity Follow-up
> Follow-up to the islands rewrite. Closes visible gaps between the current state and the handoff mock. Execute with subagent-driven development; phases B/C/D can run in parallel.
**Goal:** Bring the rewrite to pixel-level parity with `docs/UI Rewrite/design_handoff_claudedo/ClaudeDo-standalone.html`.
**Tech stack:** Avalonia 12, CommunityToolkit.Mvvm. No new dependencies.
**Reference files:**
- Source of truth: `docs/UI Rewrite/design_handoff_claudedo/ClaudeDo-standalone.html`
- CSS measurements: `docs/UI Rewrite/design_handoff_claudedo/styles.css`
- JSX component structure: `docs/UI Rewrite/design_handoff_claudedo/islands.jsx`, `app.jsx`
- Tokens: `src/ClaudeDo.Ui/Design/Tokens.axaml`
- Styles: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
**Rules for all phases:**
- Use existing token brushes (`MossBrush`, `PeatBrush`, `AccentSoftBrush`, etc.) — do NOT hard-code hex.
- Use `Classes="foo"` + selectors in `IslandStyles.axaml` for reusable styling; inline AXAML setters for one-off values only.
- Icons: use `Projektion.Avalonia` `PathIcon` with `Data="{StaticResource IconKey}"`. Define new `StreamGeometry` resources in `IslandStyles.axaml` under an `<Icons>` section when needed. Pull the SVG paths from the JSX reference.
- Read the relevant JSX + CSS file in the handoff before implementing each component — those are the source of truth for exact measurements/paddings/colors.
- Do not touch the data layer, Worker, SignalR, or command wiring. This is a view/style-only pass.
---
## Phase A — Shell + title bar (sequential, run first)
One subagent. Small blast radius; prerequisite for the visual "feel."
### Task A1 — Custom title bar
**Files:**
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` + `.axaml.cs`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (add title-bar styles + window-control icon-button style)
- [ ] Replace the current title bar Grid with a 3-section layout:
- Left: brand block — checkbox-style green glyph + `CLAUDEDO` (mono, uppercase, tracking 1.4, 11px) + separator dot + current-list name eyebrow-style (mono uppercase, `TextDim`). Bind the list name to `Shell.Lists.SelectedList.Name.ToUpperInvariant()`.
- Middle: draggable strip (`PointerPressed → BeginMoveDrag`).
- Right: three frameless icon buttons (minimize / maximize-restore / close). Close button hover turns `BloodBrush`. Use `PathIcon` with inline `StreamGeometry` for the Lucide-style icons: `Minus`, `Square`, `X` — the exact SVG `d` strings are in `icons.jsx`.
- [ ] Title bar height: 36px, background `DeepBrush`, bottom border 1px `LineBrush`.
- [ ] Remove the character glyphs currently used for the window controls (`—`, `▢`, `✕`) — use PathIcons instead.
- [ ] Commit: `style(ui): custom title bar with brand and window controls`
### Task A2 — Background + island shadow
**Files:**
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` (background layer)
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (island shadow adjust)
- [ ] Under the three-island Grid, add a `Border` filling the whole row with a subtle radial gradient from `DeepBrush` (center) to `VoidBrush` (edges). Use a `RadialGradientBrush` with 2 stops; keep opacity light.
- [ ] In `IslandStyles.axaml`, bump the `Border.island` `BoxShadow` to match the token `IslandShadow` value exactly (`0 20 40 #59000000, 0 2 4 #4D000000`). Verify by inspecting the current style — if it's already set, no-op.
- [ ] Commit: `style(ui): background gradient and stronger island shadow`
---
## Phase B — Lists island polish (parallel with C, D)
### Task B1 — Icon geometries + eyebrow rename + sections
**Files:**
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (add `StreamGeometry` icon resources at the top)
- `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs` (map `IconKey` strings → resource keys, add section grouping)
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`
- [ ] Extract the SVG path `d` strings from `icons.jsx` for: `Sun`, `Activity` (pulse), `Star`, `Calendar`, `Eye`, `Inbox`, `Folder`, `Search`, `Plus`, `MoreHorizontal`. Define each as an `x:Key="Icon.Sun"` `StreamGeometry` in `IslandStyles.axaml`.
- [ ] Change Lists eyebrow from `WORKSPACE` to `NAVIGATOR`.
- [ ] Add two section-header rows in the ItemsControl: `SMART LISTS` (above items of `Kind=Smart` + `Virtual`) and `MY LISTS` (above items of `Kind=User`). Simplest approach: two separate `ItemsControl`s bound to filtered subsets; or wrap items in a `CollectionViewSource` grouping. Pick the simplest working approach.
- [ ] Per-item icon: bind `PathIcon Data="{DynamicResource Icon.{IconKey}}"` via a tiny `IconGeometryConverter` (takes `IconKey` string → looks up resource). Icon color: `TextMute` default; `AccentBrush` (moss) when `IsActive`.
- [ ] User-list items: use a 6px circle with `MossBrush` / `PeatBrush` / `SageBrush` dot instead of folder icon (map per list index mod colors, or single color if simpler).
- [ ] Active state: remove solid fill. Use `AccentSoftBrush` (~10% moss) + left 2px accent bar + `AccentBrush` icon + `TextBrush` text.
- [ ] Commit: `style(ui): lists icons, section headers, active state`
### Task B2 — Search bar + keyboard hint + footer buttons
**Files:**
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (search style + kbd chip style)
- [ ] Search `TextBox`: wrap in a `Grid ColumnDefinitions="Auto,*,Auto"` — left `PathIcon Data="{Icon.Search}"` (14px, `TextFaint`), middle TextBox, right `Border Classes="kbd"` with `⌘K` (or `Ctrl K` on Win). The `kbd` chip: mono 10px, `Surface2` bg, `LineBrush` border, padding `6,2`, radius 4.
- [ ] Under the items list, add:
- `+ New list` button — plain icon+text row, `PathIcon Data="{Icon.Plus}"`, hover tint.
- User profile row — avatar circle (initials fallback, seed from `Environment.UserName`), name (`Environment.UserName`), subtitle `{MachineName} / local` mono dim, right `PathIcon Data="{Icon.MoreHorizontal}"`.
- [ ] Commit: `style(ui): lists search icon, kbd hint, footer actions`
---
## Phase C — Tasks island polish (parallel with B, D)
### Task C1 — Header + add-task row styling
**Files:**
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (subtitle format, header toolbar properties)
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (kbd-enter, add-task row)
- [ ] Subtitle format: change from `{open} open · {running} running · {review} in review` to `{Weekday}, {Month} {Day} · {open} open` to match the mock. Keep the running/review counts visible but move them into a right-aligned mono pill row next to the title (or drop if cleaner).
- [ ] Eyebrow: keep current `MONTAG · APR. 20` pattern. Title remains list name.
- [ ] Right-side icon toolbar: three `Button Classes="icon-btn"``Sort` icon, `Eye` icon (toggle completed), `MoreHorizontal`. Icons: pull paths from `icons.jsx`. Wire `Eye` to an `IsShowingCompleted` observable (persist in a private field for now; no DB change).
- [ ] Add-task row: wrap the `TextBox` in a `Border` with `Surface2` bg, rounded 8px, 14px padding. Prepend a circular `PathIcon Data="{Icon.Plus}"` (20px circle, `Surface3` bg). Append a `Border Classes="kbd"` with `ENTER` text (only visible when `NewTaskTitle` has focus — bind visibility to `TextBox.IsFocused`).
- [ ] Commit: `style(ui): tasks header toolbar and add-task row`
### Task C2 — Task row chips + states
**Files:**
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` (expose a few more flags: `IsOverdue`, `Tags`, `StepsCount`, `StepsCompleted`)
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (chip variants, selected accent, done state, live-tail meter)
- [ ] Chip set per row (ItemsControl or StackPanel):
- Status chip (already present) — ensure color maps per Status → token brush (idle/queued/running/review/error).
- List chip — small colored bullet (6px circle in `MossBrush` or similar) + list name.
- Branch chip — `PathIcon Data="{Icon.GitBranch}"` (12px) + branch name (mono 10px).
- Diff chip — `+N` moss + ` ` + `M` blood.
- Tags — one chip per tag (`#refactor` style, `Surface2` bg, mono 10px, `TextDim`).
- [ ] Selected state: add 2px `AccentBrush` left border on the row Border when `IsSelected=true` (style selector `Border.task-row.selected`). Background shifts to `AccentSoftBrush`.
- [ ] Done state: strike-through title + fade opacity to 0.5. Add `Border.task-row:has(.done)` equivalent via the existing `Done` binding — simpler: a `TextBlock` style selector that flips `TextDecorations`.
- [ ] Live-tail row (only visible when `Status == Running` and `LiveTail != null`): a `Border` under the chip row with mono 11px ellipsized text + a slim 3px progress `Rectangle` with `MossBrush`. For now the progress is static 30% — wire it to a future `ProgressFraction` property (leave as 0.3 fallback).
- [ ] Ensure `task-row` Border has `Transitions` for `Background` + `Margin` (smooth hover + select).
- [ ] Commit: `style(ui): task row chip set, selected/done states, live tail`
### Task C3 — Section dividers (OVERDUE / TASKS / COMPLETED)
**Files:**
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (group the ObservableCollection into sections)
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` (group headers)
- [ ] Add grouping: transform `Items` into three sub-collections:
- `OverdueItems` — tasks with `ScheduledFor < Today` and not Done.
- `OpenItems` — remaining not-Done tasks.
- `CompletedItems` — tasks with `Done=true`.
- [ ] Expose as three `ObservableCollection<TaskRowViewModel>` on the VM. Recompute inside `LoadForList`.
- [ ] View: three `ItemsControl`s stacked in a `StackPanel`, each preceded by a section header `TextBlock``OVERDUE` (only if non-empty), `TASKS`, `COMPLETED · {N}`. Eyebrow style, `TextFaint`.
- [ ] Commit: `style(ui): task section dividers overdue/tasks/completed`
---
## Phase D — Details island polish (parallel with B, C)
### Task D1 — Header + task row restyle
**Files:**
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` (expose `TaskIdBadge` like `#T1`, computed from task id prefix)
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
- [ ] Top header block:
- Eyebrow `LOGBOOK` + right-aligned `#T{shortId}` badge (first 3 hex chars of `Task.Id`, mono `TextFaint`).
- Title: keep editable title `TextBox` but reduce size and match mock.
- [ ] Under header, a new "task strip" row: `Ellipse` checkbox (bound to `Task.Done` toggle) + title + right-aligned star button. This is separate from the editable title (mock shows both title as editable heading AND a task-row-style strip with check/star).
- [ ] Commit: `style(ui): details header with logbook eyebrow and task-id badge`
### Task D2 — Agent strip v2
**Files:**
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` (add `Turns`, `TokensFormatted`, `ElapsedFormatted`, `DiffAdditions`, `DiffDeletions`, `CommitsOnBranch` if not present — most exist)
- `src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (diff meter bar style)
- [ ] Layout (three rows):
- Row 1: pulsing status dot + status label (`RUNNING` etc.) + mono model name + right-aligned stop button (only visible when Running).
- Row 2: `WORKTREE` section label + worktree path mono, with a copy-to-clipboard `PathIcon Data="{Icon.Copy}"` button at the end.
- Row 3: Branch line — `PathIcon Data="{Icon.GitBranch}"` + branch mono + arrow `←` + `main` + commits count chip.
- Row 4: `DIFF` label + `+{additions}` (moss) + `{deletions}` (blood) + a slim 4px progress-meter `Rectangle` showing additions vs deletions ratio (moss-filled portion).
- [ ] Action buttons row: `Open diff`, `Worktree`, external-link `→` (opens file:// to worktree path in OS explorer).
- [ ] Agent strip should use `AgentStripStyle.Classes` bound to the running status so colors shift.
- [ ] Commit: `style(ui): agent strip with worktree panel and diff meter`
### Task D3 — Session terminal styling
**Files:**
- `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (terminal header, log-line columns, `LIVE` chip)
- `src/ClaudeDo.Ui/ViewModels/Islands/LogLineViewModel.cs` (add `TimestampFormatted` property)
- [ ] Top bar of the terminal `Border`: three colored dots (red/yellow/green, 8px `Ellipse`) + `claude-session · {branch}` mono text + right-aligned `LIVE` chip (moss bg, white text, pulsing animation when a task is actively running).
- [ ] Log lines: two-column layout — timestamp (mono 10px, `TextFaint`, fixed 70px width) + kind marker (e.g. `TOOL`, `CLAUDE`, `OUT`) + text. Kind marker uses attribute selector `[Tag=log-tool]`, color-mapped.
- [ ] Line number/timestamp: add `TimestampFormatted` to `LogLineViewModel` populated as `DateTime.Now.ToString("HH:mm:ss")` on construction. (If real timestamps arrive via SignalR later, swap source.)
- [ ] Ensure auto-scroll still works (existing logic).
- [ ] Commit: `style(ui): session terminal header, line columns, LIVE chip`
### Task D4 — Subtasks, notes, metadata footer
**Files:**
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (subtask row style)
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` (delete-task command, close-detail command)
- [ ] Subtasks: each row is a compact `Border` with rounded 6px, hover background. Check is an `Ellipse` matching the task-row style (not default WinForms-style CheckBox). Completed items get strike-through + fade.
- [ ] Notes `TextBox`: `Surface2` bg, 12px padding, watermark `Notes...`, auto-saves on `LostFocus` (call repository `Update`).
- [ ] Bottom metadata bar (sticky at the bottom of the Details island — anchor via `DockPanel.Dock="Bottom"`):
- Left: `PathIcon Data="{Icon.Trash}"` delete button (prompts confirmation before calling `TaskRepository.DeleteAsync`).
- Middle: `Created {Month Day}` mono `TextFaint`.
- Right: close-details `PathIcon Data="{Icon.X}"` (clears `SelectedTask` on `TasksIslandViewModel`).
- [ ] Commit: `style(ui): subtasks, notes, details metadata footer`
---
## Execution order
```
Phase A (A1 → A2) [sequential, 1 subagent]
Phase B, C, D [parallel, 3 subagents, one per phase]
Final build + smoke
```
Phase A is sequential because it touches `MainWindow.axaml` and `IslandStyles.axaml` root setup.
Phases B, C, D each own a distinct island. Only potential conflict: all three add icon geometries to `IslandStyles.axaml`. Mitigation: Phase B is responsible for adding the `StreamGeometry` icon resources (it needs the most). Phases C and D reference those keys without redefining.
Final pass: run the app, eyeball against the mock, note remaining gaps.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,803 @@
# Continue & Reset Buttons for Failed Tasks — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add two buttons (Continue, Reset) to the details pane for `Failed` tasks so the user can either nudge the agent to continue or discard the worktree and return the task to `Manual`.
**Architecture:** Spec is at `docs/superpowers/specs/2026-04-21-continue-and-reset-failed-tasks-design.md`. Backend adds one git-discard helper, one task-repository method, a small orchestration service, and a new hub method `ResetTask`. `ContinueTask` is already wired in the hub. UI adds two commands in `DetailsIslandViewModel` and a button row in `DetailsIslandView`.
**Tech Stack:** .NET 8, EF Core (SQLite), ASP.NET Core SignalR, Avalonia 12, CommunityToolkit.Mvvm, xUnit 2.5 (integration tests with real SQLite + real git).
---
## File Structure
**New files:**
- `src/ClaudeDo.Worker/Services/TaskResetService.cs` — orchestrates the reset (load task, discard worktree, reset DB row, broadcast).
- `tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs` — integration tests for the orchestration.
**Modified files:**
- `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` — add `DiscardAsync`.
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — add `ResetToManualAsync`.
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add `ResetTask` endpoint; DI-inject the new service.
- `src/ClaudeDo.Worker/Program.cs` — register `TaskResetService` in DI.
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — add `ContinueTaskAsync` and `ResetTaskAsync` wrappers.
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — add observable properties and commands.
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` — add button row bound to the new commands.
- `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs` — add `DiscardAsync` test.
- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs` — add `ResetToManualAsync` test.
---
## Task 1: `WorktreeManager.DiscardAsync` (TDD)
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/WorktreeManager.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs`
`GitService` already exposes `WorktreeRemoveAsync(workingDir, path, force, ct)` and `BranchDeleteAsync(workingDir, branch, force, ct)` — verify via `git grep -n "public async Task WorktreeRemoveAsync\|public async Task BranchDeleteAsync" src/ClaudeDo.Data/Git`. If either is missing, stop and add the git wrapper first.
- [ ] **Step 1: Add the failing test**
Add at the bottom of `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs` (before the `Dispose` method):
```csharp
[Fact]
public async Task DiscardAsync_RemovesWorktreeAndBranch_AndSetsStateDiscarded()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, db) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
var worktreePath = ctx.WorktreePath;
WorktreeEntity wt;
using (var readCtx = db.CreateContext())
wt = (await new WorktreeRepository(readCtx).GetByTaskIdAsync(task.Id))!;
await mgr.DiscardAsync(wt, list.WorkingDir!, CancellationToken.None);
Assert.False(Directory.Exists(worktreePath), "worktree directory should be gone");
using var readCtx2 = db.CreateContext();
var row = await new WorktreeRepository(readCtx2).GetByTaskIdAsync(task.Id);
Assert.NotNull(row);
Assert.Equal(WorktreeState.Discarded, row!.State);
// Branch should no longer exist on the main repo.
var branchList = await new GitService().RunForOutputAsync(repo.RepoDir, new[] { "branch", "--list", ctx.BranchName }, CancellationToken.None);
Assert.True(string.IsNullOrWhiteSpace(branchList),
$"branch {ctx.BranchName} should be deleted, got: {branchList}");
}
```
Note on `RunForOutputAsync`: if `GitService` does not expose a generic run helper, replace the branch-check with a direct `System.Diagnostics.Process` invocation of `git branch --list <branch>` in the test. If such a helper exists with a different name, use it.
- [ ] **Step 2: Run test, verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DiscardAsync_RemovesWorktreeAndBranch" -v minimal`
Expected: FAIL — `DiscardAsync` does not exist.
- [ ] **Step 3: Implement `DiscardAsync`**
Add to `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` after `CommitIfChangedAsync`:
```csharp
public async Task DiscardAsync(WorktreeEntity wt, string workingDir, CancellationToken ct)
{
// Remove the git worktree first; --force drops uncommitted changes (user already confirmed).
try
{
await _git.WorktreeRemoveAsync(workingDir, wt.Path, force: true, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "git worktree remove failed for {Path}", wt.Path);
throw;
}
// Delete the branch. If worktree removal succeeded but branch delete fails,
// we still record the worktree as Discarded — the folder is gone, and a dangling
// branch is recoverable; leaving the DB out of sync is worse.
try
{
await _git.BranchDeleteAsync(workingDir, wt.BranchName, force: true, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "git branch -D {Branch} failed after worktree removal; continuing", wt.BranchName);
}
using var context = _dbFactory.CreateDbContext();
var wtRepo = new WorktreeRepository(context);
await wtRepo.SetStateAsync(wt.TaskId, WorktreeState.Discarded, ct);
_logger.LogInformation("Discarded worktree for task {TaskId} (branch {Branch})", wt.TaskId, wt.BranchName);
}
```
- [ ] **Step 4: Run test, verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DiscardAsync_RemovesWorktreeAndBranch" -v minimal`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/WorktreeManager.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs
git commit -m "feat(worker): add WorktreeManager.DiscardAsync for task reset"
```
---
## Task 2: `TaskRepository.ResetToManualAsync` (TDD)
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs`
- [ ] **Step 1: Add the failing test**
Add to `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs` (follow the existing test style in that file — reuse any helpers it already has for creating a list + task):
```csharp
[Fact]
public async Task ResetToManualAsync_ClearsResultFields_AndSetsStatusManual()
{
using var db = new DbFixture();
using var ctx = db.CreateContext();
var listRepo = new ListRepository(ctx);
var taskRepo = new TaskRepository(ctx);
var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", CreatedAt = DateTime.UtcNow };
await listRepo.AddAsync(list);
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = list.Id,
Title = "T",
CreatedAt = DateTime.UtcNow,
Status = TaskStatus.Failed,
StartedAt = DateTime.UtcNow.AddMinutes(-5),
FinishedAt = DateTime.UtcNow,
Result = "boom",
};
await taskRepo.AddAsync(task);
await taskRepo.ResetToManualAsync(task.Id);
using var readCtx = db.CreateContext();
var after = await new TaskRepository(readCtx).GetByIdAsync(task.Id);
Assert.NotNull(after);
Assert.Equal(TaskStatus.Manual, after!.Status);
Assert.Null(after.StartedAt);
Assert.Null(after.FinishedAt);
Assert.Null(after.Result);
}
```
- [ ] **Step 2: Run test, verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ResetToManualAsync_ClearsResultFields" -v minimal`
Expected: FAIL — `ResetToManualAsync` does not exist.
- [ ] **Step 3: Implement `ResetToManualAsync`**
Add to `src/ClaudeDo.Data/Repositories/TaskRepository.cs` inside the `#region Status transitions` block, after `FlipAllRunningToFailedAsync`:
```csharp
public async Task ResetToManualAsync(string taskId, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Manual)
.SetProperty(t => t.StartedAt, (DateTime?)null)
.SetProperty(t => t.FinishedAt, (DateTime?)null)
.SetProperty(t => t.Result, (string?)null), ct);
}
```
- [ ] **Step 4: Run test, verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ResetToManualAsync_ClearsResultFields" -v minimal`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs
git commit -m "feat(data): add TaskRepository.ResetToManualAsync"
```
---
## Task 3: `TaskResetService` (TDD)
**Files:**
- Create: `src/ClaudeDo.Worker/Services/TaskResetService.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs`
This service orchestrates Task 1 + Task 2, plus the "reject if Running" safety check and the SignalR broadcast.
- [ ] **Step 1: Add the failing test — happy path**
Create `tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs`:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Services;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Services;
public class TaskResetServiceTests : IDisposable
{
private readonly List<GitRepoFixture> _fixtures = new();
private readonly List<DbFixture> _dbFixtures = new();
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
[Fact]
public async Task ResetAsync_FailedTaskWithWorktree_ClearsEverything_AndPreservesRunHistory()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture(); _fixtures.Add(repo);
var db = new DbFixture(); _dbFixtures.Add(db);
var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow };
var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "T", CreatedAt = DateTime.UtcNow };
using (var seed = db.CreateContext())
{
await new ListRepository(seed).AddAsync(list);
await new TaskRepository(seed).AddAsync(task);
}
var wtMgr = new WorktreeManager(new GitService(), db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
// Seed a Failed task with a run row (we'll assert it's preserved).
using (var ctx = db.CreateContext())
{
await new TaskRepository(ctx).MarkFailedAsync(task.Id, DateTime.UtcNow, "it broke");
await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = task.Id,
RunNumber = 1,
IsRetry = false,
Prompt = "p",
SessionId = "s1",
FinishedAt = DateTime.UtcNow,
});
}
var broadcaster = new FakeHubBroadcaster();
var svc = new TaskResetService(db.CreateFactory(), wtMgr, broadcaster, NullLogger<TaskResetService>.Instance);
await svc.ResetAsync(task.Id, CancellationToken.None);
using var readCtx = db.CreateContext();
var after = await new TaskRepository(readCtx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Manual, after!.Status);
Assert.Null(after.Result);
Assert.Null(after.StartedAt);
Assert.Null(after.FinishedAt);
var wtAfter = await new WorktreeRepository(readCtx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Discarded, wtAfter!.State);
Assert.False(Directory.Exists(wtCtx.WorktreePath));
var runs = await new TaskRunRepository(readCtx).GetByTaskIdAsync(task.Id);
Assert.Single(runs);
Assert.Contains(task.Id, broadcaster.TaskUpdatedIds);
Assert.Contains(task.Id, broadcaster.WorktreeUpdatedIds);
}
[Fact]
public async Task ResetAsync_RunningTask_Throws_AndDoesNotMutate()
{
var db = new DbFixture(); _dbFixtures.Add(db);
var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", CreatedAt = DateTime.UtcNow };
var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "T", CreatedAt = DateTime.UtcNow, Status = TaskStatus.Running, StartedAt = DateTime.UtcNow };
using (var seed = db.CreateContext())
{
await new ListRepository(seed).AddAsync(list);
await new TaskRepository(seed).AddAsync(task);
}
var wtMgr = new WorktreeManager(new GitService(), db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance);
var svc = new TaskResetService(db.CreateFactory(), wtMgr, new FakeHubBroadcaster(), NullLogger<TaskResetService>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.ResetAsync(task.Id, CancellationToken.None));
using var readCtx = db.CreateContext();
var after = await new TaskRepository(readCtx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Running, after!.Status);
}
public void Dispose()
{
foreach (var f in _fixtures) f.Dispose();
foreach (var d in _dbFixtures) d.Dispose();
}
private sealed class FakeHubBroadcaster : HubBroadcaster
{
public List<string> TaskUpdatedIds { get; } = new();
public List<string> WorktreeUpdatedIds { get; } = new();
public FakeHubBroadcaster() : base(new FakeHubContext()) { }
public new Task TaskUpdated(string taskId) { TaskUpdatedIds.Add(taskId); return Task.CompletedTask; }
public new Task WorktreeUpdated(string taskId) { WorktreeUpdatedIds.Add(taskId); return Task.CompletedTask; }
}
}
```
Check existing fakes: the test file assumes `FakeHubContext` exists under `ClaudeDo.Worker.Tests.Infrastructure` (the Worker.Tests CLAUDE.md lists `FakeHubContext`, `FakeHubClients`, `FakeClientProxy`). If `HubBroadcaster` methods are not virtual, the `new` keyword above will not intercept calls — instead, use the real `HubBroadcaster` with `FakeHubContext` and inspect the fake's recorded calls. Adjust the test implementation to use whichever approach matches the existing test conventions (see `QueueServiceTests` for precedent).
- [ ] **Step 2: Run tests, verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskResetServiceTests" -v minimal`
Expected: FAIL — `TaskResetService` does not exist.
- [ ] **Step 3: Implement `TaskResetService`**
Create `src/ClaudeDo.Worker/Services/TaskResetService.cs`:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Services;
public sealed class TaskResetService
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeManager _wtManager;
private readonly HubBroadcaster _broadcaster;
private readonly ILogger<TaskResetService> _logger;
public TaskResetService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeManager wtManager,
HubBroadcaster broadcaster,
ILogger<TaskResetService> logger)
{
_dbFactory = dbFactory;
_wtManager = wtManager;
_broadcaster = broadcaster;
_logger = logger;
}
public async Task ResetAsync(string taskId, CancellationToken ct)
{
bool worktreeChanged = false;
using (var ctx = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(ctx);
var task = await taskRepo.GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot reset a running task. Cancel it first.");
var listRepo = new ListRepository(ctx);
var list = await listRepo.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
var wtRepo = new WorktreeRepository(ctx);
var wt = await wtRepo.GetByTaskIdAsync(taskId, ct);
if (wt is not null && wt.State == Data.Models.WorktreeState.Active && list.WorkingDir is not null)
{
// DiscardAsync uses its own DbContext internally; we close this one first.
ctx.Dispose();
await _wtManager.DiscardAsync(wt, list.WorkingDir, ct);
worktreeChanged = true;
}
}
using (var ctx = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(ctx);
await taskRepo.ResetToManualAsync(taskId, ct);
}
await _broadcaster.TaskUpdated(taskId);
if (worktreeChanged)
await _broadcaster.WorktreeUpdated(taskId);
_logger.LogInformation("Reset task {TaskId} to Manual (worktree discarded: {Discarded})", taskId, worktreeChanged);
}
}
```
Note: the `ctx.Dispose()` inside a `using` block works because `Dispose` is idempotent. If you prefer, refactor to scope the first block with `{ }` + explicit `await using` and move the dispose before the call.
- [ ] **Step 4: Run tests, verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskResetServiceTests" -v minimal`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Services/TaskResetService.cs tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs
git commit -m "feat(worker): add TaskResetService for discard + reset flow"
```
---
## Task 4: Wire `TaskResetService` into DI and add `WorkerHub.ResetTask`
**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs`
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- [ ] **Step 1: Register the service in DI**
Open `src/ClaudeDo.Worker/Program.cs`. Locate the block where `QueueService`, `WorktreeManager`, `HubBroadcaster`, `WorktreeMaintenanceService`, etc. are registered (look for `builder.Services.AddSingleton<QueueService>` or similar). Add next to them:
```csharp
builder.Services.AddSingleton<TaskResetService>();
```
Match the lifetime of sibling services (most are `AddSingleton`). If the sibling services use a different lifetime, match it.
- [ ] **Step 2: Inject it into `WorkerHub`**
Modify `src/ClaudeDo.Worker/Hub/WorkerHub.cs`:
In the field block (near `_wtMaintenance`):
```csharp
private readonly TaskResetService _resetService;
```
In the constructor signature, append `TaskResetService resetService` and assign it. The full updated constructor:
```csharp
public WorkerHub(
QueueService queue,
AgentFileService agentService,
HubBroadcaster broadcaster,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService wtMaintenance,
TaskResetService resetService)
{
_queue = queue;
_agentService = agentService;
_broadcaster = broadcaster;
_dbFactory = dbFactory;
_wtMaintenance = wtMaintenance;
_resetService = resetService;
}
```
- [ ] **Step 3: Add the `ResetTask` hub method**
Add inside `WorkerHub` (place it near `ContinueTask` for symmetry):
```csharp
public async Task ResetTask(string taskId)
{
try
{
await _resetService.ResetAsync(taskId, CancellationToken.None);
}
catch (InvalidOperationException ex)
{
throw new HubException(ex.Message);
}
catch (KeyNotFoundException)
{
throw new HubException("task not found");
}
}
```
- [ ] **Step 4: Build the worker to verify wiring**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: SUCCESS (no compile errors). Note: `dotnet build ClaudeDo.slnx` requires .NET 9 — build individual csproj files instead.
- [ ] **Step 5: Run the full worker test suite**
Run: `dotnet test tests/ClaudeDo.Worker.Tests -v minimal`
Expected: PASS (all existing tests plus the new ones from Tasks 1-3).
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Program.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs
git commit -m "feat(worker): expose ResetTask hub method"
```
---
## Task 5: Add `ContinueTaskAsync` and `ResetTaskAsync` to `WorkerClient`
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- [ ] **Step 1: Add both methods**
Open `src/ClaudeDo.Ui/Services/WorkerClient.cs`. Next to `RunNowAsync` (around line 166):
```csharp
public async Task ContinueTaskAsync(string taskId, string followUpPrompt)
{
await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt);
}
public async Task ResetTaskAsync(string taskId)
{
await _hub.InvokeAsync("ResetTask", taskId);
}
```
If the existing `RunNowAsync` fires a local event first (e.g. `RunNowRequestedEvent?.Invoke(taskId)`), do **not** mirror that — Continue/Reset don't need UI-local optimistic state; we rely on `TaskUpdated` broadcasts.
- [ ] **Step 2: Build the UI project**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: SUCCESS.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): add ContinueTaskAsync and ResetTaskAsync to WorkerClient"
```
---
## Task 6: Add commands and state to `DetailsIslandViewModel`
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
Background you need before editing:
- The VM already has a `Task` property (`TaskRowViewModel?`) that represents the selected task.
- Status is tracked via `AgentStatusLabel` and exposed as `IsRunning`/`IsDone`/`IsFailed`.
- `TaskRowViewModel` may not currently hold the latest `SessionId`. You need a way to read the latest run's `SessionId` for the selected task — query `TaskRunRepository` during the existing task-load flow. If the VM already loads task runs (search for `TaskRunRepository` usage in `DetailsIslandViewModel`), piggyback on that; otherwise add a DB query inside the task-load method.
- [ ] **Step 1: Add observable properties for button visibility/enablement**
In the observable-property block (after `_promptInput`, around line 29), add:
```csharp
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
[NotifyCanExecuteChangedFor(nameof(ResetCommand))]
private bool _showFailedActions;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
private string? _latestRunSessionId;
```
Also hook `AgentStatusLabel` changes to refresh `ShowFailedActions`. Update the existing `OnAgentStatusLabelChanged` partial method:
```csharp
partial void OnAgentStatusLabelChanged(string value)
{
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsDone));
OnPropertyChanged(nameof(IsFailed));
ShowFailedActions = value == "Failed";
}
```
- [ ] **Step 2: Populate `LatestRunSessionId` during task load**
Find the method in `DetailsIslandViewModel` that loads details for the selected task (likely named `LoadAsync`, `OnTaskChanged`, or similar — search for where `Turns`, `Tokens`, or `AgentStatusLabel` are assigned). Inside that method, after loading the task entity:
```csharp
using var runCtx = _dbFactory.CreateDbContext();
var runRepo = new TaskRunRepository(runCtx);
var latestRun = await runRepo.GetLatestByTaskIdAsync(Task.Id);
LatestRunSessionId = latestRun?.SessionId;
```
Verify the method name `GetLatestByTaskIdAsync` exists on `TaskRunRepository` (it is used in `TaskRunner.ContinueAsync`). If the name differs, use whatever is exposed. Make sure this runs inside the same cancellation-safe block as the other loads — copy the existing pattern verbatim.
Also ensure `LatestRunSessionId` is reset to `null` when the selected task clears. If the VM has an `OnTaskChanged` partial method that clears other fields, add `LatestRunSessionId = null;` there too.
- [ ] **Step 3: Add the two commands**
Add at the end of the class (next to `RunNowAsync` / `CanRunNow`):
```csharp
[RelayCommand(CanExecute = nameof(CanContinue))]
private async System.Threading.Tasks.Task ContinueAsync()
{
if (Task == null) return;
await _worker.ContinueTaskAsync(Task.Id, "Continue working on this task.");
}
private bool CanContinue() =>
Task != null
&& _worker.IsConnected
&& ShowFailedActions
&& !string.IsNullOrEmpty(LatestRunSessionId);
[RelayCommand(CanExecute = nameof(CanReset))]
private async System.Threading.Tasks.Task ResetAsync()
{
if (Task == null) return;
if (ConfirmAsync == null) return;
var confirmed = await ConfirmAsync(
$"Discard worktree and reset task?\nThis deletes branch claudedo/{Task.Id.Replace("-", "")} and all uncommitted changes.");
if (!confirmed) return;
await _worker.ResetTaskAsync(Task.Id);
}
private bool CanReset() =>
Task != null
&& _worker.IsConnected
&& ShowFailedActions;
```
Also update the worker-connection PropertyChanged handler (around line 112) to notify the new commands:
```csharp
_worker.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(WorkerClient.IsConnected))
{
RunNowCommand.NotifyCanExecuteChanged();
ContinueCommand.NotifyCanExecuteChanged();
ResetCommand.NotifyCanExecuteChanged();
}
};
```
- [ ] **Step 4: Build the UI project**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: SUCCESS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
git commit -m "feat(ui): add Continue and Reset commands to DetailsIslandViewModel"
```
---
## Task 7: Add the button row to `DetailsIslandView.axaml`
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
- [ ] **Step 1: Inspect the existing layout**
Read `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` end-to-end so you understand the grid layout. The `AgentStripView` sits at `Grid.Row="0"`. Decide whether to add a new grid row below it or to extend the agent strip itself. Simplest: add the button row to `AgentStripView.axaml`, since that control already contains `RunNowCommand` / `StopCommand` buttons and is bound to the same VM.
- [ ] **Step 2: Add the buttons to `AgentStripView.axaml`**
Open `src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml`. Locate the existing `RunNowCommand` button (around line 49). After it, add:
```xml
<Button
Content="Continue"
Command="{Binding ContinueCommand}"
IsVisible="{Binding ShowFailedActions}"
ToolTip.Tip="Resume the failed Claude session with 'Continue working on this task.'"
Margin="4,0,0,0"/>
<Button
Content="Reset"
Command="{Binding ResetCommand}"
IsVisible="{Binding ShowFailedActions}"
ToolTip.Tip="Discard the worktree and return the task to Manual"
Margin="4,0,0,0"/>
```
Match the style (classes, padding, height) of the surrounding `RunNow` / `Stop` buttons — copy their `Classes`, `Padding`, and `Height` attributes verbatim so the row stays visually consistent.
For the Continue button's disabled-with-tooltip affordance when there's no session_id: the `CanExecute` binding already disables the button; Avalonia shows tooltips on disabled controls when `ToolTip.ShowOnDisabled="True"` — set that on the Continue button and add a second tooltip hinting at the reason is unnecessary since the button will simply be greyed out. If you want an explicit "No session to resume" hint, add a `Classes.disabled` trigger or use a `MultiBinding`; skip this refinement unless it is trivial in the existing theme.
- [ ] **Step 3: Wire the confirmation dialog**
The VM's `ResetAsync` uses `ConfirmAsync` (a `Func<string, Task<bool>>` already declared on the VM at line 100). Search the codebase for where `ConfirmAsync` is assigned on the `DetailsIslandViewModel` instance — there is an existing assignment because `DeleteTaskCommand` already uses it. No new wiring needed; the same dialog will handle Reset confirmations.
- [ ] **Step 4: Launch the UI and smoke-test**
1. In one terminal: `dotnet run --project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
2. In another terminal: `dotnet run --project src/ClaudeDo.App/ClaudeDo.App.csproj`
3. Create a task that will fail (e.g. a task pointing at a non-existent working dir, or type something into Claude's prompt that makes it error). Wait for status `Failed`.
4. Verify the Continue and Reset buttons appear in the details pane.
5. Click Reset → confirm → verify the task row flips to `Manual`, the worktree directory is gone from disk, and the branch is gone from `git branch --list | grep claudedo/` in the target repo.
6. Create another failing task. Click Continue → verify a new run starts (status flips to `Running`), resumes the same Claude session, and completes (Done or Failed again).
7. Verify on a task that has no session_id (e.g. cancel before Claude emits anything), the Continue button is disabled.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml
git commit -m "feat(ui): add Continue and Reset buttons to agent strip"
```
---
## Task 8: Update project docs
**Files:**
- Modify: `src/ClaudeDo.Worker/CLAUDE.md`
- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
- [ ] **Step 1: Update Worker CLAUDE.md**
Under the `SignalR Hub` section, extend the `WorkerHub methods` line:
```
**WorkerHub** methods: `Ping()`, `GetActive()`, `RunNow(taskId)`, `CancelTask(taskId)`, `WakeQueue()`, `ContinueTask(taskId, prompt)`, `ResetTask(taskId)`, `GetAgents()`, `RefreshAgents()`
```
Under `Key Components`, add one line:
```
- **TaskResetService** — discards a failed task's worktree and resets the task row to Manual; preserves run history.
```
- [ ] **Step 2: Update UI CLAUDE.md**
Extend the `WorkerClient` description to mention the two new methods:
```
- **WorkerClient** — ... Methods: StartAsync, RunNowAsync, CancelTaskAsync, ContinueTaskAsync, ResetTaskAsync, WakeQueueAsync. Events: ...
```
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md
git commit -m "docs: note ResetTask hub method and TaskResetService"
```
---
## Self-Review
**Spec coverage:**
- Continue button (canned prompt, one-click, disabled without session) → Task 6 (`ContinueCommand`, `CanContinue`) + Task 7 (button).
- Reset button (always enabled on Failed, confirm dialog) → Task 6 (`ResetCommand`, `CanReset`) + Task 7 (button + confirm).
- Buttons only on Failed → `ShowFailedActions` drives `IsVisible` (Task 6, Task 7).
- Hub `ResetTask` → Task 4.
- `WorktreeManager.DiscardAsync` → Task 1.
- `TaskRepository.ResetToManualAsync` → Task 2.
- Reject reset on Running → Task 3.
- Worktree-remove failure leaves task Failed → Task 3 (`DiscardAsync` throws before `ResetToManualAsync` is called).
- Run history preserved → Task 2 and Task 3 assertions.
- Tests — WorktreeManager.DiscardAsync, TaskRepository.ResetToManualAsync, TaskResetService full flow, reject running → Tasks 1, 2, 3.
- Test for "ResetTask rejects running" → Task 3 test 2.
- Test for "worktree remove failure leaves task Failed" → covered implicitly by the code structure (Task 3 does not call `ResetToManualAsync` if `DiscardAsync` throws). If you want an explicit test, add one in Task 3 by injecting a failure; marking optional as the control flow is straightforward.
**Placeholder scan:** no TBDs; every code step has code; commands include expected output.
**Type consistency:** `DiscardAsync(WorktreeEntity wt, string workingDir, ct)` used consistently (Tasks 1 and 3). `ResetToManualAsync(taskId, ct)` used consistently (Tasks 2 and 3). `ContinueTaskAsync`/`ResetTaskAsync` on `WorkerClient` match the hub method names. `ShowFailedActions`, `LatestRunSessionId`, `CanContinue`, `CanReset` referenced consistently across Task 6 and Task 7.

View File

@@ -0,0 +1,189 @@
# Open Items Consolidation — 2026-04-21
Consolidates everything still open from `docs/open.md`, `docs/improvement-plan.md`, and the 2026-04-16 subtask-tree spec. Today's plans/specs (stream-formatter, settings-modal, continue-and-reset) are explicitly out of scope — those are tracked separately.
Grouped by priority and sorted by UX impact vs. effort. Each item lists **Soll**, **Dateien**, **Aufwand**, **Risiko**.
---
## P1 — UX blockers and robustness
### 1. Auto-Reconnect (ex IP-1)
**Soll:** `HubConnectionBuilder.WithAutomaticReconnect(...)` + event handlers for `Reconnecting`/`Reconnected`/`Closed`. Exponential backoff.
**Dateien:** `src/ClaudeDo.Ui/Services/WorkerClient.cs`
**Aufwand:** klein (~30 Zeilen)
**Risiko:** klein
### 2. Reconnect-State in StatusBar (ex IP-7)
**Soll:** States `connected | connecting | reconnecting | offline`, farb-codiert. Depends on #1.
**Dateien:** `src/ClaudeDo.Ui/ViewModels/StatusBarViewModel.cs`, StatusBar view
**Aufwand:** klein
**Risiko:** klein
### 3. Folder-Picker für Working Directory (ex open.md 2.1)
**Soll:** Button neben Pfad-TextBox → `IStorageProvider.OpenFolderPickerAsync`.
**Dateien:** `src/ClaudeDo.Ui/Views/ListEditorView.axaml(.cs)`, `ViewModels/ListEditorViewModel.cs`
**Aufwand:** klein (~30 Zeilen)
**Risiko:** klein
### 4. Markdown-Rendering für Result/Description (ex open.md 2.3)
**Soll:** `Markdown.Avalonia` Paket einbinden, `MarkdownScrollViewer` statt readonly `TextBox` in Details-Island.
**Dateien:** `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`, `Views/Islands/DetailsIslandView.axaml`
**Aufwand:** mittel (Theme-Integration kann zicken)
**Risiko:** kleinmittel
### 5. Live-Log Auto-Scroll (ex open.md 2.4)
**Soll:** Sticky-Bottom-Pattern: `ScrollToEnd()` auf neue Zeilen, außer User hat manuell hochgescrollt.
**Dateien:** `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml(.cs)`
**Aufwand:** klein
**Risiko:** klein
### 6. CLI-Preflight beim Worker-Start (ex open.md 3.1)
**Soll:** Startup-Check `claude --version` + Login-Status. Wenn fehlt → laut failen mit Hinweis, nicht still in Queue idlen.
**Dateien:** `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs`, Worker startup (Program.cs / HostedService)
**Aufwand:** klein
**Risiko:** klein
---
## P2 — Daten & Features
### 7. Notes-Mode (ex IP-2)
**Soll:** Neue Spalte `lists.kind` (`agent` | `notes`). Worker filtert auf `kind = 'agent'`. UI versteckt Run/Schedule/Worktree-Felder für `notes`.
**Dateien:**
- Migration + `TaskList` Entity + `IEntityTypeConfiguration`
- `src/ClaudeDo.Worker/Queue/QueueService.cs` (Filter)
- `ViewModels/ListEditorViewModel.cs`, `Views/Islands/*` (conditional visibility)
**Aufwand:** mittel
**Risiko:** mittel (Default für bestehende Listen = `agent`)
### 8. Subtask-Tree im TaskList-Island (ex 2026-04-16 spec)
**Soll:** Indented Subtasks unter Parent-Task, Expand/Collapse, Chevron-Spalte, Count-Indikator. Batch-Query `GetCountsByTaskIdsAsync`.
**Dateien:** `ViewModels/TaskItemViewModel.cs` (+ `Subtasks`, `IsExpanded`, `HasSubtasks`), `Views/Islands/TaskListView.axaml`, `Data/Repositories/SubtaskRepository.cs`
**Aufwand:** mittel
**Risiko:** kleinmittel (spec ist fertig)
### 9. Tag-Repository `GetAllKnownTagsAsync` (ex IP-8)
**Soll:** Distinct-Query über alle Tags. Voraussetzung für #10.
**Dateien:** neuer `Data/Repositories/TagRepository.cs` (oder in bestehendem Repo)
**Aufwand:** klein
**Risiko:** klein
### 10. Tag Multi-Select Control (ex IP-4)
**Soll:** AutoCompleteBox / Chips statt Freitext. Datenquelle aus #9.
**Dateien:** `Views/Islands/DetailsIslandView.axaml` (Tag-Sektion), ggf. neues `TagPickerControl`
**Aufwand:** kleinmittel
**Risiko:** klein
### 11. Worktree-Cleanup bei Anlegefehler (ex open.md 3.2)
**Soll:** Wenn `git worktree add` teilweise anlegt dann failed → best-effort `git worktree remove --force` + DB-Row nicht persistieren.
**Dateien:** `src/ClaudeDo.Data/Services/GitService.cs`, `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
**Aufwand:** klein
**Risiko:** klein
### 12. Tag-Negation / Exclusion (ex open.md 3.4)
**Soll:** Queue respektiert `task_tag_exclusions` laut Plan. Aktuell nur `agent`-Include.
**Dateien:** `src/ClaudeDo.Worker/Queue/QueueService.cs`
**Aufwand:** klein
**Risiko:** klein
---
## P3 — Tests, CI, Docs
### 13. Gitea-Actions CI Pipeline (ex open.md 5.1)
**Soll:** `.gitea/workflows/ci.yml`: restore → build → test auf push/PR. Nur `release.yml` existiert bisher.
**Dateien:** neu — `.gitea/workflows/ci.yml`
**Aufwand:** klein
**Risiko:** klein
### 14. SignalR Roundtrip-Test (ex open.md 5.2)
**Soll:** `WebApplicationFactory` + `HubConnectionBuilder` testen `Ping`, `GetActive`, `RunNow`-Throw-Verhalten.
**Dateien:** neu — `tests/ClaudeDo.Worker.Tests/Hub/WorkerHubTests.cs`
**Aufwand:** mittel
**Risiko:** klein
### 15. Claude-CLI Smoke-Test (ex open.md 5.3)
**Soll:** `[Fact(Skip=...)]` Real-CLI-Test, aktiviert nur wenn `CLAUDE_AUTHENTICATED=1`.
**Dateien:** neu — `tests/ClaudeDo.Worker.Tests/Runner/ClaudeProcessSmokeTest.cs`
**Aufwand:** klein
**Risiko:** klein
### 16. README ausbauen (ex open.md 6.1)
**Soll:** Ist 107 Zeilen. Ergänzen: Screenshots, Quickstart (Worker + UI starten), Konfiguration, Troubleshooting.
**Dateien:** `README.md`
**Aufwand:** klein
**Risiko:** keiner
### 17. `docs/architecture.md` herausziehen (ex open.md 6.2)
**Soll:** Architektur-Sektion aus `plan.md` in eigenes Dokument.
**Dateien:** neu — `docs/architecture.md`
**Aufwand:** klein
**Risiko:** keiner
### 18. ADRs für Kern-Entscheidungen (ex open.md 6.3)
**Soll:** Kurze ADRs (1 Seite) für: SignalR vs. SQLite-Polling; Worktree pro Task; SignalR über Loopback ohne Auth; EF Core statt Dapper.
**Dateien:** neu — `docs/adr/0001-*.md``0004-*.md`
**Aufwand:** klein
**Risiko:** keiner
### 19. Strukturiertes Logging (ex open.md 3.3)
**Soll:** `Console.WriteLine` / manuelle Log-Zeilen durch `ILogger<T>` ersetzen. Log-Levels, Scope für TaskId.
**Dateien:** `src/ClaudeDo.Worker/**` (query-able via `grep Console.Write`)
**Aufwand:** mittel
**Risiko:** klein
---
## P4 — Service-Deployment (später)
### 20. Windows-Service Hosting in Code (ex open.md 4.1)
**Soll:** `.UseWindowsService()` + `Microsoft.Extensions.Hosting.WindowsServices` Paket.
**Aufwand:** klein
### 21. Absolute Pfad-Auflösung (ex open.md 4.2)
**Soll:** Config-Pfade immer absolut auflösen (Service läuft in `C:\Windows\System32`).
**Aufwand:** klein
### 22. Install-Skripte / Doku (ex open.md 4.3)
**Soll:** `sc create` / PowerShell-Skript, Doku in README.
**Aufwand:** klein
---
## Empfohlene Reihenfolge
**Block 1 — Sofortige UX-Wins (1 Session):**
1. → 2. (Auto-Reconnect + StatusBar) — zusammenhängend, klein
3. Folder-Picker
5. Log Auto-Scroll
**Block 2 — Content-Qualität (1 Session):**
4. Markdown-Rendering
6. CLI-Preflight
**Block 3 — Daten-Features (2 Sessions):**
7. Notes-Mode (mit Migration)
8. Subtask-Tree
9. → 10. Tag-Repo + Multi-Select
**Block 4 — Worker-Robustheit:**
11. Worktree-Cleanup
12. Tag-Exclusion
19. Strukturiertes Logging
**Block 5 — Tests + CI + Docs:**
13. CI Pipeline
14. → 15. Hub-Tests + Smoke-Test
16. → 17. → 18. README + architecture.md + ADRs
**Block 6 — Service-Deployment (wenn gewünscht):**
20. → 21. → 22.
---
## Nicht im Plan
- Alles aus heute (2026-04-21): Stream-Formatter-Rewrite, Settings-Modal, Continue-and-Reset — sind eigene Plans.
- UI-Rewrite-Islands, UI-Polish-Design-Parity, UX-Redesign, Worker-CLI-Modernization, EF-Core-Migration, Installer-Download-Mode, Logic-Bug-Fixes — bereits gemerged (siehe git log).
- IP-3 (Doppelklick) und IP-5 (Kontextmenü) — vermutlich im Zuge des UI-Rewrites erledigt; falls nicht, trivial nachzuziehen.

View File

@@ -0,0 +1,614 @@
# Stream Formatter Rewrite — 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:** Rewrite `StreamLineFormatter` so Claude CLI stream-json messages (system/init, assistant text, assistant tool_use, user tool_result, result) render as compact readable lines in the Details pane.
**Architecture:** Single-file rewrite of `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`. Public API (`FormatLine(string)` / `FormatFile(string)` / `Trim`) and constants unchanged. Internal dispatch switches on top-level `type`; per-type helpers return one or more `\n`-terminated display lines, concatenated into the return string.
**Tech Stack:** C# 12, .NET 8, `System.Text.Json` (already in use).
**Spec:** `docs/superpowers/specs/2026-04-21-stream-formatter-rewrite-design.md`
**Testing:** Skipped per user decision; verification is a manual build after each task and a final end-to-end run of a real task.
**Build command (repo uses csproj builds, not slnx, on .NET 8):**
```bash
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
---
## File Structure
- **Modify:** `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs` — complete rewrite of parsing logic; keeps public class surface.
No other files change. `DetailsIslandViewModel` and the Worker pipeline are unaffected.
---
## Task 1: Replace the dispatch skeleton
**Files:**
- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
Swap the old top-level `switch` for one that names every supported message type. Every branch returns `null` for now except `result` and `api_retry`, which keep their existing behavior. This gives us a clean compile before we fill in each branch.
- [ ] **Step 1: Overwrite the file with the new skeleton**
Replace the entire contents of `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs` with:
```csharp
using System.Text;
using System.Text.Json;
namespace ClaudeDo.Ui.Helpers;
public class StreamLineFormatter
{
private const int MaxLength = 50_000;
private const int MaxArgChars = 120;
public string? FormatLine(string line)
{
JsonDocument doc;
try
{
doc = JsonDocument.Parse(line);
}
catch (JsonException)
{
return line;
}
using (doc)
{
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
return null;
if (!root.TryGetProperty("type", out var typeProp))
return null;
return typeProp.GetString() switch
{
"system" => FormatSystem(root),
"assistant" => FormatAssistant(root),
"user" => FormatUser(root),
"result" => FormatResult(root),
_ => null,
};
}
}
private static string? FormatSystem(JsonElement root)
{
if (!root.TryGetProperty("subtype", out var subtypeProp))
return null;
return subtypeProp.GetString() switch
{
"api_retry" => "[Retrying API call...]\n",
_ => null,
};
}
private static string? FormatAssistant(JsonElement root) => null;
private static string? FormatUser(JsonElement root) => null;
private static string? FormatResult(JsonElement root)
{
if (root.TryGetProperty("result", out var resultProp))
return $"\n--- Result ---\n{resultProp.GetString()}\n";
return null;
}
public string FormatFile(string filePath)
{
var sb = new StringBuilder();
foreach (var line in File.ReadLines(filePath))
{
var formatted = FormatLine(line);
if (formatted is not null)
sb.Append(formatted);
}
return Trim(sb.ToString());
}
public static string Trim(string text)
{
if (text.Length <= MaxLength) return text;
var trimStart = text.Length - MaxLength;
var newlineAfter = text.IndexOf('\n', trimStart);
if (newlineAfter >= 0 && newlineAfter < trimStart + 200)
trimStart = newlineAfter + 1;
return text[trimStart..];
}
}
```
- [ ] **Step 2: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds, 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
git commit -m "refactor(ui): skeleton dispatch for StreamLineFormatter rewrite"
```
---
## Task 2: Add system/init formatting
**Files:**
- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
Emit `[session <id8> · <model>]` when the CLI announces the session at startup.
- [ ] **Step 1: Replace the `FormatSystem` method**
Find:
```csharp
private static string? FormatSystem(JsonElement root)
{
if (!root.TryGetProperty("subtype", out var subtypeProp))
return null;
return subtypeProp.GetString() switch
{
"api_retry" => "[Retrying API call...]\n",
_ => null,
};
}
```
Replace with:
```csharp
private static string? FormatSystem(JsonElement root)
{
if (!root.TryGetProperty("subtype", out var subtypeProp))
return null;
var subtype = subtypeProp.GetString();
switch (subtype)
{
case "api_retry":
return "[Retrying API call...]\n";
case "init":
{
var sessionId = root.TryGetProperty("session_id", out var sid)
? sid.GetString() : null;
var model = root.TryGetProperty("model", out var m)
? m.GetString() : null;
var shortId = sessionId is { Length: >= 8 }
? sessionId[..8]
: sessionId ?? "?";
var modelPart = string.IsNullOrEmpty(model) ? "" : $" · {model}";
return $"[session {shortId}{modelPart}]\n";
}
default:
return null;
}
}
```
- [ ] **Step 2: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds, 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
git commit -m "feat(ui): format system init message in StreamLineFormatter"
```
---
## Task 3: Add assistant text + thinking filter
**Files:**
- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
Iterate `message.content[]`. Emit each `text` block verbatim with a trailing `\n`; skip `thinking`. Leave `tool_use` for the next task (still returns nothing for now).
- [ ] **Step 1: Replace the `FormatAssistant` method**
Find:
```csharp
private static string? FormatAssistant(JsonElement root) => null;
```
Replace with:
```csharp
private static string? FormatAssistant(JsonElement root)
{
if (!TryGetContentArray(root, out var content))
return null;
var sb = new StringBuilder();
foreach (var block in content.EnumerateArray())
{
if (block.ValueKind != JsonValueKind.Object) continue;
if (!block.TryGetProperty("type", out var blockTypeProp)) continue;
switch (blockTypeProp.GetString())
{
case "text":
if (block.TryGetProperty("text", out var textProp))
{
var text = textProp.GetString();
if (!string.IsNullOrEmpty(text))
{
sb.Append(text);
if (!text.EndsWith('\n')) sb.Append('\n');
}
}
break;
case "tool_use":
// Filled in by a later task.
break;
case "thinking":
default:
// Filtered.
break;
}
}
return sb.Length == 0 ? null : sb.ToString();
}
private static bool TryGetContentArray(JsonElement root, out JsonElement content)
{
content = default;
if (!root.TryGetProperty("message", out var message)) return false;
if (message.ValueKind != JsonValueKind.Object) return false;
if (!message.TryGetProperty("content", out var c)) return false;
if (c.ValueKind != JsonValueKind.Array) return false;
content = c;
return true;
}
```
- [ ] **Step 2: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds, 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
git commit -m "feat(ui): render assistant text blocks, skip thinking"
```
---
## Task 4: Add tool_use block formatting
**Files:**
- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
Fill in the `tool_use` case inside `FormatAssistant`. Per-tool label/arg logic lives in a dedicated helper.
- [ ] **Step 1: Replace the `tool_use` case body**
Find:
```csharp
case "tool_use":
// Filled in by a later task.
break;
```
Replace with:
```csharp
case "tool_use":
sb.Append(FormatToolUse(block));
sb.Append('\n');
break;
```
- [ ] **Step 2: Add helper methods at the end of the class (before `FormatFile`)**
Insert just above the `public string FormatFile(string filePath)` method:
```csharp
private static string FormatToolUse(JsonElement block)
{
var name = block.TryGetProperty("name", out var nameProp)
? nameProp.GetString() ?? "?"
: "?";
JsonElement input = default;
var hasInput = block.TryGetProperty("input", out input)
&& input.ValueKind == JsonValueKind.Object;
var label = name;
if (hasInput && (name == "Task" || name == "Agent"))
{
var sub = GetStr(input, "subagent_type");
if (!string.IsNullOrEmpty(sub))
label = $"{name}: {sub}";
}
string? arg = hasInput ? BuildToolArg(name, input) : null;
return string.IsNullOrEmpty(arg)
? $"[{label}]"
: $"[{label}] {arg}";
}
private static string? BuildToolArg(string toolName, JsonElement input)
{
switch (toolName)
{
case "Read":
case "Write":
case "Edit":
case "NotebookEdit":
return Basename(GetStr(input, "file_path"));
case "Bash":
case "PowerShell":
{
var cmd = GetStr(input, "command");
return string.IsNullOrEmpty(cmd) ? null : "$ " + Truncate(cmd, MaxArgChars);
}
case "Grep":
{
var p = GetStr(input, "pattern");
return string.IsNullOrEmpty(p) ? null : $"\"{Truncate(p, MaxArgChars)}\"";
}
case "Glob":
return Truncate(GetStr(input, "pattern"), MaxArgChars);
case "Task":
case "Agent":
return Truncate(GetStr(input, "description"), MaxArgChars);
case "WebFetch":
return GetStr(input, "url");
case "WebSearch":
{
var q = GetStr(input, "query");
return string.IsNullOrEmpty(q) ? null : $"\"{Truncate(q, MaxArgChars)}\"";
}
case "TodoWrite":
return null;
default:
return null;
}
}
private static string? GetStr(JsonElement obj, string name)
=> obj.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String
? p.GetString()
: null;
private static string Basename(string? path)
{
if (string.IsNullOrEmpty(path)) return "";
var i = path.LastIndexOfAny(new[] { '/', '\\' });
return i < 0 ? path : path[(i + 1)..];
}
private static string Truncate(string? s, int max)
{
if (string.IsNullOrEmpty(s)) return "";
return s.Length <= max ? s : s[..max] + "…";
}
```
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds, 0 errors.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
git commit -m "feat(ui): render assistant tool_use blocks with per-tool args"
```
---
## Task 5: Add user tool_result formatting
**Files:**
- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
Iterate `message.content[]` for `tool_result` blocks and emit `→ <summary>` lines per the spec rules.
- [ ] **Step 1: Replace the `FormatUser` method**
Find:
```csharp
private static string? FormatUser(JsonElement root) => null;
```
Replace with:
```csharp
private static string? FormatUser(JsonElement root)
{
if (!TryGetContentArray(root, out var content))
return null;
var sb = new StringBuilder();
foreach (var block in content.EnumerateArray())
{
if (block.ValueKind != JsonValueKind.Object) continue;
if (!block.TryGetProperty("type", out var blockTypeProp)) continue;
if (blockTypeProp.GetString() != "tool_result") continue;
var summary = BuildToolResultSummary(root, block);
if (!string.IsNullOrEmpty(summary))
{
sb.Append("→ ");
sb.Append(summary);
sb.Append('\n');
}
}
return sb.Length == 0 ? null : sb.ToString();
}
private static string BuildToolResultSummary(JsonElement root, JsonElement block)
{
var isError = block.TryGetProperty("is_error", out var errProp)
&& errProp.ValueKind == JsonValueKind.True;
var contentText = ResolveContentText(block);
if (isError)
{
var msg = FirstNonEmptyLine(contentText);
return string.IsNullOrEmpty(msg) ? "error" : $"error: {Truncate(msg, MaxArgChars)}";
}
// tool_use_result.file.numLines shortcut for Read-style results
if (root.TryGetProperty("tool_use_result", out var tur)
&& tur.ValueKind == JsonValueKind.Object
&& tur.TryGetProperty("file", out var file)
&& file.ValueKind == JsonValueKind.Object
&& file.TryGetProperty("numLines", out var nl)
&& nl.ValueKind == JsonValueKind.Number
&& nl.TryGetInt32(out var lines))
{
return $"{lines} lines";
}
if (string.IsNullOrWhiteSpace(contentText))
return "ok";
var first = FirstNonEmptyLine(contentText);
return Truncate(first, MaxArgChars);
}
private static string ResolveContentText(JsonElement block)
{
if (!block.TryGetProperty("content", out var c))
return "";
if (c.ValueKind == JsonValueKind.String)
return c.GetString() ?? "";
if (c.ValueKind == JsonValueKind.Array)
{
var sb = new StringBuilder();
foreach (var part in c.EnumerateArray())
{
if (part.ValueKind != JsonValueKind.Object) continue;
if (!part.TryGetProperty("type", out var pt)) continue;
if (pt.GetString() != "text") continue;
if (part.TryGetProperty("text", out var t) && t.ValueKind == JsonValueKind.String)
{
if (sb.Length > 0) sb.Append('\n');
sb.Append(t.GetString());
}
}
return sb.ToString();
}
return "";
}
private static string FirstNonEmptyLine(string s)
{
if (string.IsNullOrEmpty(s)) return "";
foreach (var raw in s.Split('\n'))
{
var line = raw.TrimEnd('\r').Trim();
if (line.Length > 0) return line;
}
return "";
}
```
- [ ] **Step 2: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds, 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
git commit -m "feat(ui): render user tool_result blocks as one-line summaries"
```
---
## Task 6: Manual end-to-end verification
**Files:** none (verification only).
- [ ] **Step 1: Build everything the app needs**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` and `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: both succeed, 0 errors.
- [ ] **Step 2: Start the Worker in one terminal**
Run: `dotnet run --project src/ClaudeDo.Worker`
Expected: SignalR hub bound to `127.0.0.1:47821`, no crash.
- [ ] **Step 3: Start the App in another terminal**
Run: `dotnet run --project src/ClaudeDo.App`
Expected: UI opens, status bar shows online.
- [ ] **Step 4: Run any task tagged "agent" (e.g. "create a README")**
In the Details pane, verify the log shows:
- A `[session <id>…]` line at the top
- Plain prose lines for assistant text
- `[Read] <file>`, `[Bash] $ …`, `[Write] <file>` etc. for tool calls
- `→ <N> lines` / `→ ok` / `→ error: …` lines after each tool call
- A final `--- Result ---` block
- **No raw JSON anywhere**
- [ ] **Step 5: Spot-check the raw log file**
Open `~/.todo-app/logs/<task>.log` (or equivalent) and confirm the full JSON is still there for debugging — the formatter must not have altered persisted logs.
- [ ] **Step 6: If any issues surface, fix inline and re-verify**
Common gotchas to check for if you see blank lines or missing output:
- `message.content` sometimes absent → already guarded by `TryGetContentArray`
- Unknown tool name → should render `[<name>]` with no arg
- `tool_result.content` array form → covered by `ResolveContentText`
No further commit unless a fix was needed.
---
## Post-Implementation Self-Review
After the tasks above are done, verify:
1. Every message type listed in the spec's "Output format" table is implemented (`system/init`, `system/api_retry`, `system/other`, `assistant text`, `assistant tool_use`, `assistant thinking`, `user tool_result`, `result`, parse failure).
2. No `TODO` / `TBD` / commented-out stubs remain in `StreamLineFormatter.cs`.
3. Tool labels match the spec table exactly (`[Read]`, `[Bash] $ …`, `[Task: <sub>] <desc>`, etc.).
4. Public API surface (`FormatLine`, `FormatFile`, `Trim`, `MaxLength` behavior) is unchanged.
5. No edits outside `StreamLineFormatter.cs` (per the spec's non-goals).

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,852 @@
# Default Agents 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:** Ship ClaudeDo with 6 default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher) that seed into `~/.todo-app/agents/` on first launch, with a "Restore defaults" button in the settings modal.
**Architecture:** Bundled `.md` files in `src/ClaudeDo.Worker/DefaultAgents/` are copied to the Worker output folder. A new `DefaultAgentSeeder` service copies any missing file into the user's agents dir — run once at startup, and again on demand via a new `WorkerHub.RestoreDefaultAgents` method invoked by a button in `SettingsModalView`.
**Tech Stack:** .NET 8 / ASP.NET Core / SignalR / Avalonia 12 / CommunityToolkit.Mvvm / xUnit
---
## File Structure
**Create:**
- `src/ClaudeDo.Worker/DefaultAgents/code-reviewer.md`
- `src/ClaudeDo.Worker/DefaultAgents/test-writer.md`
- `src/ClaudeDo.Worker/DefaultAgents/debugger.md`
- `src/ClaudeDo.Worker/DefaultAgents/security-reviewer.md`
- `src/ClaudeDo.Worker/DefaultAgents/explorer.md`
- `src/ClaudeDo.Worker/DefaultAgents/researcher.md`
- `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
- `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
**Modify:**
- `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — add Content item group for `DefaultAgents\*.md`
- `src/ClaudeDo.Worker/Program.cs` — register seeder, run `SeedMissingAsync()` once at startup
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — inject `DefaultAgentSeeder`, add `RestoreDefaultAgents` method
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — add `RestoreDefaultAgentsAsync` method + `SeedResultDto` record
- `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` — add `RestoreDefaultAgentsCommand`
- `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` — add button section
- `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs` — add seeder integration test
---
### Task 1: Bundle default agent markdown files
**Files:**
- Create: `src/ClaudeDo.Worker/DefaultAgents/code-reviewer.md`
- Create: `src/ClaudeDo.Worker/DefaultAgents/test-writer.md`
- Create: `src/ClaudeDo.Worker/DefaultAgents/debugger.md`
- Create: `src/ClaudeDo.Worker/DefaultAgents/security-reviewer.md`
- Create: `src/ClaudeDo.Worker/DefaultAgents/explorer.md`
- Create: `src/ClaudeDo.Worker/DefaultAgents/researcher.md`
- [ ] **Step 1: Write `code-reviewer.md`**
```markdown
---
name: code-reviewer
description: Reviews code changes for bugs, logic errors, and convention violations. Flags only high-confidence issues.
---
You are a code reviewer. Your job is to inspect the diff for real problems, not nitpicks.
Focus on:
- Logic errors, off-by-one bugs, null/empty handling
- Broken invariants, race conditions, resource leaks
- Violations of the project's established conventions (read nearby code first)
- Missing error handling at system boundaries (external input, IO, network)
Skip:
- Style preferences the codebase doesn't enforce
- Speculative "what if" concerns
- Renaming for its own sake
Output: a short list of concrete issues with file:line references. If the diff is clean, say so in one sentence. Do not rewrite the code — call out the problem and let the implementer fix it.
```
- [ ] **Step 2: Write `test-writer.md`**
```markdown
---
name: test-writer
description: Generates unit and integration tests for existing or new code. Follows the project's test patterns and frameworks.
---
You are a test-writer. Your job is to write focused, useful tests for code under review.
Process:
1. Read the target code and identify the observable behavior.
2. Read existing tests nearby to match the framework, fixtures, naming, and assertion style.
3. Write tests covering the happy path, boundary conditions, and the specific failure modes that matter.
Rules:
- One behavior per test. Clear Arrange/Act/Assert.
- No tests for private implementation details — exercise public API.
- No mocks where real objects are cheap (in-memory DBs, temp dirs).
- Skip trivially-correct tests (getter returns what you set).
Output: the test file(s) ready to compile, matching the project's conventions. Include the command to run them.
```
- [ ] **Step 3: Write `debugger.md`**
```markdown
---
name: debugger
description: Systematic root-cause analysis for bugs, test failures, and unexpected behavior. Hypothesize, isolate, verify.
---
You are a debugger. You do NOT guess at fixes — you find the root cause first.
Process:
1. Reproduce. Get a minimal, deterministic repro. If you can't reproduce it, say so and stop.
2. Isolate. Narrow the failing path (bisect, binary search, or tracing).
3. Hypothesize. State a specific, falsifiable cause.
4. Verify. Prove the hypothesis by observation (logs, debugger, targeted print) — not by "this seems likely".
5. Fix at the root, not the symptom. If the only fix is a workaround, explain why.
Anti-patterns to avoid:
- Making changes to "see if it works"
- Adding try/catch to silence errors
- Declaring the bug fixed without reproducing the fix
Output: repro steps, root cause, and the minimal fix. Include evidence (log excerpt, command output) that proves the cause.
```
- [ ] **Step 4: Write `security-reviewer.md`**
```markdown
---
name: security-reviewer
description: Audits code for OWASP-class security issues — auth, injection, input handling, secret exposure.
---
You are a security reviewer. Focus on real, exploitable weaknesses — not theoretical hardening.
Check for:
- Injection: SQL, command, path traversal, XSS, template injection
- Auth: missing authorization, token handling, session fixation
- Input validation at system boundaries (HTTP, files, IPC)
- Secrets: hardcoded credentials, tokens in logs, leaked env vars
- Unsafe deserialization, XXE, SSRF
- Cryptography misuse (custom crypto, weak algorithms, fixed IVs)
Ignore:
- Internal trust-boundary assumptions the project already documents
- Defense-in-depth ideas with no concrete attack path
Output: a prioritized list — severity, file:line, the exploit path, the fix. If nothing is wrong, say so plainly.
```
- [ ] **Step 5: Write `explorer.md`**
```markdown
---
name: explorer
description: Fast codebase navigation — find files, search for patterns, answer "where/how" questions. Terse output.
---
You are an explorer. Your job is to find things in the codebase quickly and report back concisely.
Use:
- Glob/Grep for searches
- Read only for files you need to quote from
Do NOT:
- Refactor, edit, or "improve" anything
- Read files that aren't relevant to the question
- Dump raw tool output — summarize
Output style:
- Lead with the answer in one sentence.
- Back it up with file:line references.
- If you found nothing, say "no match" and what you searched for.
Keep responses short. The caller wants facts, not prose.
```
- [ ] **Step 6: Write `researcher.md`**
```markdown
---
name: researcher
description: General-purpose research and analysis for non-code tasks — summarize docs, investigate questions, draft prose.
---
You are a researcher. You handle tasks that don't fit the code-review/test/debug shape.
Good fits:
- Summarizing documents, specs, or long outputs
- Investigating an open question (what does X do, how does Y work, what are the tradeoffs)
- Drafting non-code text (release notes, emails, docs)
- Analyzing structured data (logs, CSV, JSON) and reporting findings
Process:
1. Restate the task in one sentence so you know what "done" looks like.
2. Gather just enough information — stop when you can answer, not when you run out of sources.
3. Distinguish facts ("the file says X") from inference ("so likely Y").
4. Cite sources (file:line, URL, log excerpt) for every claim.
Output: direct answer first, supporting evidence second. Keep it short unless asked for depth.
```
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Worker/DefaultAgents/
git commit -m "feat(worker): add bundled default agent definitions"
```
---
### Task 2: Wire bundled agents into build output
**Files:**
- Modify: `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
- [ ] **Step 1: Add Content item group for DefaultAgents**
Edit `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`. After the existing `<ItemGroup>` blocks and before the final `<PropertyGroup>`, add:
```xml
<ItemGroup>
<Content Include="DefaultAgents\*.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
```
- [ ] **Step 2: Build the worker and verify the files land in output**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds.
Then verify output:
Run: `ls src/ClaudeDo.Worker/bin/Debug/net8.0/DefaultAgents/`
Expected: all 6 `.md` files present.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
git commit -m "build(worker): ship DefaultAgents folder in build output"
```
---
### Task 3: Write DefaultAgentSeeder tests (failing)
**Files:**
- Create: `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
The service doesn't exist yet — these tests will fail to compile initially. That's fine; the next task implements it.
- [ ] **Step 1: Write the test file**
Create `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`:
```csharp
using ClaudeDo.Worker.Services;
namespace ClaudeDo.Worker.Tests.Services;
public sealed class DefaultAgentSeederTests : IDisposable
{
private readonly string _bundleDir;
private readonly string _targetDir;
public DefaultAgentSeederTests()
{
var root = Path.Combine(Path.GetTempPath(), $"claudedo_seeder_{Guid.NewGuid():N}");
_bundleDir = Path.Combine(root, "bundle");
_targetDir = Path.Combine(root, "target");
Directory.CreateDirectory(_bundleDir);
Directory.CreateDirectory(_targetDir);
}
public void Dispose()
{
try { Directory.Delete(Path.GetDirectoryName(_bundleDir)!, true); } catch { }
}
private async Task WriteBundleAsync(string name, string content)
{
await File.WriteAllTextAsync(Path.Combine(_bundleDir, name), content);
}
[Fact]
public async Task SeedMissing_CopiesAllFiles_WhenTargetEmpty()
{
await WriteBundleAsync("a.md", "A");
await WriteBundleAsync("b.md", "B");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(2, result.Copied);
Assert.Equal(0, result.Skipped);
Assert.True(File.Exists(Path.Combine(_targetDir, "a.md")));
Assert.True(File.Exists(Path.Combine(_targetDir, "b.md")));
}
[Fact]
public async Task SeedMissing_SkipsExistingFiles()
{
await WriteBundleAsync("a.md", "bundled");
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "user-modified");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(0, result.Copied);
Assert.Equal(1, result.Skipped);
var content = await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md"));
Assert.Equal("user-modified", content);
}
[Fact]
public async Task SeedMissing_MixedState_CopiesOnlyMissing()
{
await WriteBundleAsync("a.md", "A");
await WriteBundleAsync("b.md", "B");
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "existing");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.Equal(1, result.Skipped);
Assert.Equal("existing", await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md")));
Assert.Equal("B", await File.ReadAllTextAsync(Path.Combine(_targetDir, "b.md")));
}
[Fact]
public async Task SeedMissing_ReturnsZero_WhenBundleDirMissing()
{
var missingBundle = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}");
var seeder = new DefaultAgentSeeder(missingBundle, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(0, result.Copied);
Assert.Equal(0, result.Skipped);
}
[Fact]
public async Task SeedMissing_CreatesTargetDir_IfMissing()
{
await WriteBundleAsync("a.md", "A");
var missingTarget = Path.Combine(_targetDir, "nested", "created");
var seeder = new DefaultAgentSeeder(_bundleDir, missingTarget);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.True(File.Exists(Path.Combine(missingTarget, "a.md")));
}
[Fact]
public async Task SeedMissing_IgnoresNonMarkdownFiles()
{
await WriteBundleAsync("a.md", "A");
await File.WriteAllTextAsync(Path.Combine(_bundleDir, "readme.txt"), "not an agent");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.False(File.Exists(Path.Combine(_targetDir, "readme.txt")));
}
}
```
- [ ] **Step 2: Run tests to confirm they fail to compile**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DefaultAgentSeederTests"`
Expected: compile error — `The type or namespace name 'DefaultAgentSeeder' could not be found`.
This confirms the tests target the not-yet-written service.
---
### Task 4: Implement DefaultAgentSeeder
**Files:**
- Create: `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
- [ ] **Step 1: Write the service**
Create `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`:
```csharp
using Microsoft.Extensions.Logging;
namespace ClaudeDo.Worker.Services;
public sealed record SeedResult(int Copied, int Skipped);
public sealed class DefaultAgentSeeder
{
private readonly string _bundleDir;
private readonly string _targetDir;
private readonly ILogger<DefaultAgentSeeder>? _logger;
public DefaultAgentSeeder(string bundleDir, string targetDir, ILogger<DefaultAgentSeeder>? logger = null)
{
_bundleDir = bundleDir;
_targetDir = targetDir;
_logger = logger;
}
public async Task<SeedResult> SeedMissingAsync(CancellationToken ct = default)
{
if (!Directory.Exists(_bundleDir))
{
_logger?.LogWarning("DefaultAgents bundle dir not found: {Dir}", _bundleDir);
return new SeedResult(0, 0);
}
Directory.CreateDirectory(_targetDir);
int copied = 0;
int skipped = 0;
foreach (var src in Directory.EnumerateFiles(_bundleDir, "*.md"))
{
ct.ThrowIfCancellationRequested();
var fileName = Path.GetFileName(src);
var dst = Path.Combine(_targetDir, fileName);
if (File.Exists(dst))
{
skipped++;
continue;
}
try
{
using var input = File.OpenRead(src);
using var output = File.Create(dst);
await input.CopyToAsync(output, ct);
copied++;
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to copy default agent {File}", fileName);
}
}
return new SeedResult(copied, skipped);
}
}
```
- [ ] **Step 2: Run the tests and verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DefaultAgentSeederTests"`
Expected: 6 tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs
git commit -m "feat(worker): add DefaultAgentSeeder for first-launch agent seeding"
```
---
### Task 5: Wire seeder into Worker startup
**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs`
- [ ] **Step 1: Register seeder and run SeedMissingAsync at startup**
In `src/ClaudeDo.Worker/Program.cs`, replace the "Agent file management." block (currently lines 3639):
Find:
```csharp
// Agent file management.
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
Directory.CreateDirectory(agentsDir);
builder.Services.AddSingleton(new AgentFileService(agentsDir));
```
Replace with:
```csharp
// Agent file management.
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
Directory.CreateDirectory(agentsDir);
builder.Services.AddSingleton(new AgentFileService(agentsDir));
var defaultAgentsBundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
defaultAgentsBundleDir,
agentsDir,
sp.GetService<Microsoft.Extensions.Logging.ILogger<DefaultAgentSeeder>>()));
```
Then, after `var app = builder.Build();` and before `app.MapHub<WorkerHub>("/hub");`, add:
Find:
```csharp
using (var scope = app.Services.CreateScope())
{
ClaudeDoDbContext.MigrateAndConfigure(
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
}
app.MapHub<WorkerHub>("/hub");
```
Replace with:
```csharp
using (var scope = app.Services.CreateScope())
{
ClaudeDoDbContext.MigrateAndConfigure(
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
}
try
{
var seeder = app.Services.GetRequiredService<DefaultAgentSeeder>();
var seedResult = await seeder.SeedMissingAsync();
app.Logger.LogInformation(
"Default agents seeded: {Copied} copied, {Skipped} already present",
seedResult.Copied, seedResult.Skipped);
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "Default agent seeding failed");
}
app.MapHub<WorkerHub>("/hub");
```
- [ ] **Step 2: Build worker to verify compile**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): seed default agents on startup"
```
---
### Task 6: Add RestoreDefaultAgents hub method (TDD)
**Files:**
- Modify: `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
The existing `AgentSettingsHubTests` doesn't exercise `WorkerHub` directly (it tests the repository). We'll add a new test file that tests the seeder restore flow end-to-end without SignalR plumbing — constructing the seeder with temp dirs and asserting the `SeedResult` round-trip. This mirrors how the file is structured today and keeps tests simple.
- [ ] **Step 1: Add a restore test**
Add to `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`. Inside the existing `AgentSettingsHubTests` class (before the closing brace), add:
```csharp
[Fact]
public async Task RestoreDefaultAgents_CopiesMissingBundledFiles()
{
var root = Path.Combine(Path.GetTempPath(), $"claudedo_hub_restore_{Guid.NewGuid():N}");
var bundleDir = Path.Combine(root, "bundle");
var targetDir = Path.Combine(root, "target");
try
{
Directory.CreateDirectory(bundleDir);
Directory.CreateDirectory(targetDir);
await File.WriteAllTextAsync(Path.Combine(bundleDir, "code-reviewer.md"), "body");
var seeder = new ClaudeDo.Worker.Services.DefaultAgentSeeder(bundleDir, targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.Equal(0, result.Skipped);
Assert.True(File.Exists(Path.Combine(targetDir, "code-reviewer.md")));
}
finally
{
try { Directory.Delete(root, true); } catch { }
}
}
```
- [ ] **Step 2: Run the test to confirm it passes (seeder already exists)**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentSettingsHubTests.RestoreDefaultAgents_CopiesMissingBundledFiles"`
Expected: 1 test passes.
- [ ] **Step 3: Add RestoreDefaultAgents to WorkerHub**
Edit `src/ClaudeDo.Worker/Hub/WorkerHub.cs`.
Add a new DTO record near the top with the other DTOs (after the `ListConfigDto` line on line 30):
```csharp
public record SeedResultDto(int Copied, int Skipped);
```
Update the class field block. Find:
```csharp
private readonly QueueService _queue;
private readonly AgentFileService _agentService;
private readonly HubBroadcaster _broadcaster;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _wtMaintenance;
private readonly TaskResetService _resetService;
private readonly TaskMergeService _mergeService;
```
Replace with:
```csharp
private readonly QueueService _queue;
private readonly AgentFileService _agentService;
private readonly DefaultAgentSeeder _seeder;
private readonly HubBroadcaster _broadcaster;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _wtMaintenance;
private readonly TaskResetService _resetService;
private readonly TaskMergeService _mergeService;
```
Update the constructor. Find:
```csharp
public WorkerHub(
QueueService queue,
AgentFileService agentService,
HubBroadcaster broadcaster,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService wtMaintenance,
TaskResetService resetService,
TaskMergeService mergeService)
{
_queue = queue;
_agentService = agentService;
_broadcaster = broadcaster;
_dbFactory = dbFactory;
_wtMaintenance = wtMaintenance;
_resetService = resetService;
_mergeService = mergeService;
}
```
Replace with:
```csharp
public WorkerHub(
QueueService queue,
AgentFileService agentService,
DefaultAgentSeeder seeder,
HubBroadcaster broadcaster,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService wtMaintenance,
TaskResetService resetService,
TaskMergeService mergeService)
{
_queue = queue;
_agentService = agentService;
_seeder = seeder;
_broadcaster = broadcaster;
_dbFactory = dbFactory;
_wtMaintenance = wtMaintenance;
_resetService = resetService;
_mergeService = mergeService;
}
```
Then add the hub method. After the existing `RefreshAgents` method (currently line 126):
```csharp
public async Task<SeedResultDto> RestoreDefaultAgents()
{
var result = await _seeder.SeedMissingAsync();
return new SeedResultDto(result.Copied, result.Skipped);
}
```
- [ ] **Step 4: Build worker to verify compile**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds.
- [ ] **Step 5: Run all Worker tests to confirm no regressions**
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
Expected: all tests pass.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs
git commit -m "feat(worker): expose RestoreDefaultAgents hub method"
```
---
### Task 7: Add RestoreDefaultAgentsAsync to WorkerClient
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- [ ] **Step 1: Add the DTO record**
At the bottom of `src/ClaudeDo.Ui/Services/WorkerClient.cs`, alongside the other public record declarations (after `ListConfigDto`, currently line 350), add:
```csharp
public sealed record SeedResultDto(int Copied, int Skipped);
```
- [ ] **Step 2: Add the client method**
In the `WorkerClient` class, after the `RefreshAgentsAsync` method (currently line 232), add:
```csharp
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
{
try
{
return await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
}
catch
{
return null;
}
}
```
- [ ] **Step 3: Build UI to verify compile**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): add RestoreDefaultAgentsAsync to WorkerClient"
```
---
### Task 8: Add restore button to Settings modal
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
- [ ] **Step 1: Add the command to the viewmodel**
In `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`, add a new command method. Place it after the existing `ConfirmResetAll` method (currently ending line 162), before the `OpenPath` method:
```csharp
[RelayCommand]
private async Task RestoreDefaultAgents()
{
IsBusy = true;
StatusMessage = "";
try
{
var result = await _worker.RestoreDefaultAgentsAsync();
if (result is null)
StatusMessage = "Worker offline.";
else if (result.Copied == 0 && result.Skipped == 0)
StatusMessage = "No default agents bundled.";
else if (result.Copied == 0)
StatusMessage = "All default agents already present.";
else
StatusMessage = $"Restored {result.Copied} default agent(s).";
await _worker.RefreshAgentsAsync();
}
catch (Exception ex)
{
StatusMessage = $"Restore failed: {ex.Message}";
}
finally { IsBusy = false; }
}
```
- [ ] **Step 2: Add a button to the settings view**
Edit `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`. Add a new section after the `WORKTREES` StackPanel block (which ends with `</StackPanel>` around line 185) and before the `ABOUT` section (`<!-- ABOUT -->` around line 187).
Find:
```xml
</StackPanel>
<!-- ABOUT -->
```
Replace with:
```xml
</StackPanel>
<!-- AGENTS -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="AGENTS"/>
<Border Classes="section">
<StackPanel Spacing="8">
<TextBlock Text="Restore bundled default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher). Existing files are not overwritten."
FontSize="11"
TextWrapping="Wrap"
Foreground="{DynamicResource TextDimBrush}"/>
<Button Content="Restore default agents"
Command="{Binding RestoreDefaultAgentsCommand}"
IsEnabled="{Binding !IsBusy}"
HorizontalAlignment="Left"/>
</StackPanel>
</Border>
</StackPanel>
<!-- ABOUT -->
```
- [ ] **Step 3: Build UI to verify compile**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 4: Manual smoke test**
Start the worker and the UI. Open Settings (3-dots next to username). The AGENTS section should appear with a "Restore default agents" button.
1. With `~/.todo-app/agents/` empty (delete any existing `.md` files first, back them up if needed): click the button. Status should read "Restored N default agent(s)." The files should appear in the folder.
2. Click again. Status should read "All default agents already present."
3. Modify one of the restored files. Click restore. The modified file content should be preserved.
If any step fails, stop and fix before committing.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
git commit -m "feat(ui): add Restore default agents button to Settings modal"
```
---
## Verification
At the end, run the full test suite and build all projects:
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
dotnet test tests/ClaudeDo.Worker.Tests
```
Expected: all builds succeed, all tests pass.
Additionally, start the Worker once with an empty `~/.todo-app/agents/` folder and confirm the log line:
> `Default agents seeded: 6 copied, 0 already present`
Then confirm `~/.todo-app/agents/` contains all 6 markdown files.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,927 @@
# Planning Sessions — Plan C: UI 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:** Wire the planning-session feature into the UI: context-menu entries, hierarchical display of parent + children, draft styling, unfinished-session dialog, and the `WorkerClient` methods that call the hub endpoints built in Plan B.
**Architecture:** Extend `TaskRowViewModel` with hierarchy-aware flags (`IsChild`, `IsPlanningParent`, `IsExpanded`). `TasksIslandViewModel` builds a flat stream that interleaves parents and their children based on expanded state. Context-menu entries on `TaskRowView` gate on task status. Draft styling lives in the existing island styles. A modal dialog reuses the project's `TaskCompletionSource<T>` pattern.
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`), compiled bindings, SignalR client.
**Spec reference:** `docs/superpowers/specs/2026-04-23-planning-sessions-design.md` section 6.
---
## Prerequisite Gate
This plan depends on Plan A being merged to `main`. Plan B's interface contract (hub method names, return types) is locked in the spec §6.6 and Plan B task 13 — this plan can proceed in parallel with Plan B.
Before starting:
```bash
git fetch origin main
git checkout main
git pull --ff-only
ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs
```
If the file is missing, wait for Plan A:
```bash
while ! ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs >/dev/null 2>&1; do
echo "Waiting for Plan A to merge..."
sleep 60
git fetch origin main && git pull --ff-only
done
```
Then branch:
```bash
git checkout -b feat/planning-sessions-ui
```
**Parallel-with-Plan-B note:** Plan B may not yet be merged when this plan runs. The `WorkerClient` methods in Task 9 will compile against Plan B's SignalR hub method names (they're string-based SignalR invocations), so they don't have a build-time dependency. Runtime end-to-end testing requires Plan B merged; until then, mock-test what's possible and smoke-test manually once both plans land.
---
## File Structure
**Modified:**
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — add `ParentTaskId`, `IsChild`, `IsPlanningParent`, `IsExpanded`, `PlanningBadge` properties.
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` — add planning commands, expanded-state map, flat-stream rebuild logic.
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — chevron, indentation, badges, draft styling hooks.
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs` — context-menu event handlers (if code-behind is used; else inline).
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` — use the extended TaskRowView template.
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — five new hub method wrappers matching Plan B.
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml``.draft`, `.planning-parent`, `.planned-parent`, badge styles.
**Created:**
- `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` + `.axaml.cs` — modal Resume/Finalize/Discard dialog.
- `src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs` — dialog VM.
- `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` — VM-level tests.
- `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs` — VM-level tests.
---
## Task 1: Extend `TaskRowViewModel`
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
- Create: `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs`
- [ ] **Step 1: Write failing test for planning flags**
Create the test file. Adapt the existing `TaskRowViewModelTests` pattern (look at `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs` for how VMs are constructed in tests):
```csharp
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Worker.Tests.UiVm;
public sealed class TaskRowViewModelPlanningTests
{
[Fact]
public void Draft_Status_SetsIsChildFlag_WhenParentIdIsNotNull()
{
// Adapt the constructor call to your actual TaskRowViewModel signature (see TaskRowViewModelTests).
var vm = TestHelpers.MakeRow(
status: "draft",
parentTaskId: "parent-id");
Assert.True(vm.IsChild);
Assert.False(vm.IsPlanningParent);
}
[Fact]
public void Planning_Status_SetsIsPlanningParent()
{
var vm = TestHelpers.MakeRow(status: "planning", parentTaskId: null);
Assert.True(vm.IsPlanningParent);
Assert.False(vm.IsChild);
Assert.Equal("PLANNING", vm.PlanningBadge);
}
[Fact]
public void Planned_Status_ShowsPlannedBadge()
{
var vm = TestHelpers.MakeRow(status: "planned", parentTaskId: null);
Assert.True(vm.IsPlanningParent);
Assert.Equal("PLANNED", vm.PlanningBadge);
}
[Fact]
public void NonPlanningStatus_NoBadge()
{
var vm = TestHelpers.MakeRow(status: "manual", parentTaskId: null);
Assert.False(vm.IsPlanningParent);
Assert.Null(vm.PlanningBadge);
}
}
internal static class TestHelpers
{
public static TaskRowViewModel MakeRow(string status, string? parentTaskId)
{
// Implement based on actual TaskRowViewModel constructor.
// The TaskRowViewModelTests.cs file in the same folder shows the existing pattern.
throw new NotImplementedException("Adapt to your TaskRowViewModel constructor");
}
}
```
Open `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs` first to see how the VM is constructed in tests, then fill in `TestHelpers.MakeRow` accordingly.
- [ ] **Step 2: Run; verify fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRowViewModelPlanningTests"`
Expected: FAIL (properties not yet on VM).
- [ ] **Step 3: Extend the VM**
In `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` add the new properties using `[ObservableProperty]`:
```csharp
[ObservableProperty] private string? parentTaskId;
[ObservableProperty] private bool isExpanded = true;
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
public bool IsPlanningParent => string.Equals(Status, "planning", StringComparison.OrdinalIgnoreCase)
|| string.Equals(Status, "planned", StringComparison.OrdinalIgnoreCase);
public string? PlanningBadge => Status switch
{
string s when string.Equals(s, "planning", StringComparison.OrdinalIgnoreCase) => "PLANNING",
string s when string.Equals(s, "planned", StringComparison.OrdinalIgnoreCase) => "PLANNED",
_ => null,
};
public bool IsDraft => string.Equals(Status, "draft", StringComparison.OrdinalIgnoreCase);
```
Since `IsChild`, `IsPlanningParent`, `PlanningBadge`, and `IsDraft` are computed from other observables, you must raise property-changed notifications when `Status` or `ParentTaskId` changes. Use `[ObservableProperty]` partial methods:
```csharp
partial void OnStatusChanged(string value)
{
OnPropertyChanged(nameof(IsPlanningParent));
OnPropertyChanged(nameof(PlanningBadge));
OnPropertyChanged(nameof(IsDraft));
}
partial void OnParentTaskIdChanged(string? value)
{
OnPropertyChanged(nameof(IsChild));
}
```
If the existing VM already has `OnStatusChanged` (check for generator outputs), merge into it rather than duplicating.
- [ ] **Step 4: Run; verify pass**
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs
git commit -m "feat(ui): TaskRowViewModel gains planning hierarchy flags"
```
---
## Task 2: `WorkerClient` planning methods
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Create: DTOs matching Plan B return types (either inline in the client file or new file `src/ClaudeDo.Ui/Services/PlanningDtos.cs`).
- [ ] **Step 1: Add DTOs**
Create `src/ClaudeDo.Ui/Services/PlanningDtos.cs`:
```csharp
namespace ClaudeDo.Ui.Services;
public sealed record PlanningSessionFilesDto(
string SessionDirectory,
string McpConfigPath,
string SystemPromptPath,
string InitialPromptPath);
public sealed record PlanningSessionStartInfo(
string ParentTaskId,
string WorkingDir,
PlanningSessionFilesDto Files);
public sealed record PlanningSessionResumeInfo(
string ParentTaskId,
string WorkingDir,
string ClaudeSessionId,
string McpConfigPath);
```
These field names must match Plan B's `PlanningSessionStartContext` / `PlanningSessionResumeContext` exactly (case-sensitive JSON deserialization through SignalR).
- [ ] **Step 2: Add `WorkerClient` methods**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add:
```csharp
public Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> _connection.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
public Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
=> _connection.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> _connection.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
public Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
=> _connection.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
=> _connection.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
```
Replace `_connection` with whatever name the existing `WorkerClient` uses for its `HubConnection` field.
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: builds.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Services/PlanningDtos.cs src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): WorkerClient planning-session methods"
```
---
## Task 3: `TasksIslandViewModel` — planning commands + expanded state
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
- Create: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`
- [ ] **Step 1: Add commands to the VM**
In `TasksIslandViewModel.cs`, add:
```csharp
private readonly Dictionary<string, bool> _expandedState = new();
[RelayCommand]
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null || !string.Equals(row.Status, "manual", StringComparison.OrdinalIgnoreCase))
return;
try
{
await _workerClient.StartPlanningSessionAsync(row.Id);
}
catch (Exception ex)
{
await _dialogs.ShowErrorAsync("Could not start planning session", ex.Message);
}
}
[RelayCommand]
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null || !row.IsPlanningParent) return;
try
{
await _workerClient.ResumePlanningSessionAsync(row.Id);
}
catch (Exception ex)
{
await _dialogs.ShowErrorAsync("Could not resume planning session", ex.Message);
}
}
[RelayCommand]
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null) return;
var confirm = await _dialogs.ConfirmAsync(
"Discard planning session?",
"This will delete all draft tasks and reset the parent to Manual.");
if (!confirm) return;
await _workerClient.DiscardPlanningSessionAsync(row.Id);
}
[RelayCommand]
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null) return;
await _workerClient.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true);
}
[RelayCommand]
private void ToggleExpand(TaskRowViewModel? row)
{
if (row is null) return;
var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : true);
_expandedState[row.Id] = next;
row.IsExpanded = next;
RebuildFlatStreams();
}
private void RebuildFlatStreams()
{
// Existing code builds OpenItems/CompletedItems from the task list.
// Modify it so that: after emitting a parent, if IsPlanningParent && IsExpanded,
// its Draft / Manual / Queued / Running / Done children are emitted next.
// Children already know they are children (ParentTaskId != null) and are styled as such.
}
```
The existing `RebuildFlatStreams` (or equivalent) probably just groups tasks by status. You need to intersperse the hierarchy:
```csharp
// Pseudocode — fit to the existing code shape.
var topLevel = allRows.Where(r => !r.IsChild).OrderBy(r => r.SortOrder);
var flat = new List<TaskRowViewModel>();
foreach (var parent in topLevel)
{
flat.Add(parent);
if (parent.IsPlanningParent && parent.IsExpanded)
{
var children = allRows
.Where(r => r.ParentTaskId == parent.Id)
.OrderBy(r => r.SortOrder)
.ToList();
flat.AddRange(children);
}
}
// Then bucket `flat` into OpenItems/CompletedItems like today, preserving order.
```
Pass dependencies: the VM already has a `WorkerClient` or equivalent — reuse it. Add a dialog service if not already injected:
```csharp
public interface IDialogService
{
Task<bool> ConfirmAsync(string title, string message);
Task ShowErrorAsync(string title, string message);
}
```
If an analog already exists (check existing editor dialogs), use it.
- [ ] **Step 2: Write failing VM tests**
`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`:
```csharp
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Worker.Tests.UiVm;
public sealed class TasksIslandViewModelPlanningTests
{
[Fact]
public void ToggleExpand_CollapsesChildrenOfPlanningParent()
{
// Arrange: create VM with one Planning parent and two Draft children.
// Act: call ToggleExpandCommand with the parent.
// Assert: flat stream no longer contains the children.
// Adapt to how the existing TasksIslandViewModel is instantiated.
}
[Fact]
public void OpenPlanningSessionCommand_ManualTaskOnly_CanExecuteTrue()
{
// Arrange VM with a Manual row.
// Assert CanExecute for OpenPlanningSession command is true for Manual rows,
// false for Queued/Running/Done/Failed rows.
}
}
```
These are skeleton tests — implement with the same construction pattern used by the existing `TasksIslandViewModelTests` if one exists, or build a minimal VM fake with a stub `WorkerClient`.
- [ ] **Step 3: Build + test**
Run:
```bash
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TasksIslandViewModelPlanningTests"
```
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
git commit -m "feat(ui): planning commands and expand/collapse in TasksIslandViewModel"
```
---
## Task 4: `TaskRowView` — indent, chevron, badges, draft styling
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- [ ] **Step 1: Wrap the row content with a Grid that has an indent column**
Open `TaskRowView.axaml`. The existing root is likely a `Grid` or `Border`. Replace/refactor the top-level layout to:
```xml
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0"
Width="24"
IsVisible="{Binding IsChild}">
<Rectangle Width="1" Fill="{DynamicResource TextFaintBrush}" HorizontalAlignment="Right" Margin="0,4"/>
</Border>
<Grid Grid.Column="1" ColumnDefinitions="Auto,*,Auto">
<!-- Chevron for planning parents -->
<Button Grid.Column="0"
Classes="icon-btn chevron"
Width="18" Height="18"
IsVisible="{Binding IsPlanningParent}"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleExpandCommand}"
CommandParameter="{Binding}">
<PathIcon Width="10" Height="10">
<PathIcon.Data>
<MultiBinding Converter="{StaticResource ChevronDataConverter}">
<Binding Path="IsExpanded"/>
</MultiBinding>
</PathIcon.Data>
</PathIcon>
</Button>
<!-- existing title/description area -->
<StackPanel Grid.Column="1" ...>
<!-- existing title binding with added italic when IsDraft -->
<TextBlock Text="{Binding Title}"
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalicConverter}}"
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacityConverter}}" />
</StackPanel>
<!-- Badges -->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
<TextBlock Text="DRAFT"/>
</Border>
<Border Classes="badge planning" IsVisible="{Binding IsPlanningParent}">
<TextBlock Text="{Binding PlanningBadge}"/>
</Border>
</StackPanel>
</Grid>
</Grid>
```
This is a structural edit — preserve all existing bindings for status color, completion toggle, star, scheduled-for, etc. The new indentation column and badges are additive.
- [ ] **Step 2: Add the converters**
If `ChevronDataConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter` do not exist, add them to `src/ClaudeDo.Ui/Converters/` (or inline as compiled converters). Example inline:
```xml
<!-- in UserControl.Resources of TaskRowView.axaml, or in App.axaml for global -->
<Style Selector="Border.badge">
<Setter Property="CornerRadius" Value="3"/>
<Setter Property="Padding" Value="4,1"/>
<Setter Property="Background" Value="{DynamicResource BadgeBgBrush}"/>
</Style>
<Style Selector="Border.badge.draft">
<Setter Property="Background" Value="{DynamicResource DraftBadgeBrush}"/>
</Style>
<Style Selector="Border.badge.planning">
<Setter Property="Background" Value="{DynamicResource PlanningBadgeBrush}"/>
</Style>
```
If converters must be code-based, a minimal `BoolToItalicConverter`:
```csharp
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace ClaudeDo.Ui.Converters;
public sealed class BoolToItalicConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
=> value is true ? FontStyle.Italic : FontStyle.Normal;
public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
=> throw new NotSupportedException();
}
```
Register in `App.axaml` resources.
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: builds cleanly (XAML compiles).
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Converters/
git commit -m "feat(ui): TaskRowView hierarchy indentation, chevron, badges, draft italic"
```
---
## Task 5: `TaskRowView` — planning context-menu entries
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- [ ] **Step 1: Locate the existing context menu**
Open `TaskRowView.axaml`. The ContextMenu lives somewhere on the root element or as a `ContextMenu.Items`/`ContextFlyout`. Find the block that defines entries like "Edit", "Run now", etc.
- [ ] **Step 2: Insert planning entries conditionally**
Add within the existing menu (order: after "Run now" and a separator):
```xml
<MenuItem Header="Open planning Session"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).OpenPlanningSessionCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding Status, Converter={StaticResource IsManualAndNotChildConverter}, ConverterParameter={Binding IsChild}}"/>
<MenuItem Header="Resume planning Session"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ResumePlanningSessionCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding Status, Converter={StaticResource IsPlanningConverter}}"/>
<MenuItem Header="Discard planning session"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).DiscardPlanningSessionCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding Status, Converter={StaticResource IsPlanningConverter}}"/>
```
Simpler alternative without multi-condition converters: expose direct bool VM properties that combine the logic — `CanOpenPlanningSession`, `CanResumePlanningSession`, `CanDiscardPlanningSession`:
```csharp
// In TaskRowViewModel
public bool CanOpenPlanningSession =>
string.Equals(Status, "manual", StringComparison.OrdinalIgnoreCase) && !IsChild;
public bool CanResumeOrDiscardPlanning =>
string.Equals(Status, "planning", StringComparison.OrdinalIgnoreCase);
```
Add `OnPropertyChanged(nameof(CanOpenPlanningSession))` and friends in the status/parent-id partial methods from Task 1. Then the XAML simplifies to:
```xml
<MenuItem Header="Open planning Session"
Command="{Binding ...OpenPlanningSessionCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding CanOpenPlanningSession}"/>
```
Use this simpler path — cleaner.
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: builds.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
git commit -m "feat(ui): planning entries in task context menu"
```
---
## Task 6: Island styles — draft, badges
**Files:**
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
- [ ] **Step 1: Add brushes + styles**
Append within `<Styles.Resources>` or wherever brushes are defined:
```xml
<SolidColorBrush x:Key="DraftBadgeBrush" Color="#4A5568"/>
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="#D69E2E"/>
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="#3182CE"/>
```
Add styles:
```xml
<Style Selector="Border.badge">
<Setter Property="CornerRadius" Value="3"/>
<Setter Property="Padding" Value="4,1"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style Selector="Border.badge > TextBlock">
<Setter Property="FontSize" Value="9"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Foreground" Value="White"/>
</Style>
<Style Selector="Border.badge.draft">
<Setter Property="Background" Value="{DynamicResource DraftBadgeBrush}"/>
</Style>
<Style Selector="Border.badge.planning">
<Setter Property="Background" Value="{DynamicResource PlanningBadgeBrush}"/>
</Style>
<Style Selector="Border.badge.planned">
<Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/>
</Style>
```
- [ ] **Step 2: Build and manually verify**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Launch app, create a task, right-click, use Open planning Session (if Plan B merged) or simulate via DB. Verify badge + italic draft rendering visually.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml
git commit -m "feat(ui): draft and planning badge styles"
```
---
## Task 7: Unfinished-planning-session dialog
**Files:**
- Create: `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` + `.axaml.cs`
- Create: `src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs`
- [ ] **Step 1: Create the VM**
`src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs`:
```csharp
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Dialogs;
public enum UnfinishedPlanningDialogResult
{
Cancel,
Resume,
FinalizeNow,
Discard,
}
public sealed partial class UnfinishedPlanningDialogViewModel : ObservableObject
{
[ObservableProperty] private string title = "Unfinished planning session";
[ObservableProperty] private string taskTitle = "";
[ObservableProperty] private int draftCount;
public TaskCompletionSource<UnfinishedPlanningDialogResult> Result { get; } = new();
[RelayCommand] private void Resume() => Result.TrySetResult(UnfinishedPlanningDialogResult.Resume);
[RelayCommand] private void FinalizeNow() => Result.TrySetResult(UnfinishedPlanningDialogResult.FinalizeNow);
[RelayCommand] private void Discard() => Result.TrySetResult(UnfinishedPlanningDialogResult.Discard);
[RelayCommand] private void Cancel() => Result.TrySetResult(UnfinishedPlanningDialogResult.Cancel);
}
```
- [ ] **Step 2: Create the view**
`src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml`:
```xml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Dialogs"
x:Class="ClaudeDo.Ui.Views.Dialogs.UnfinishedPlanningDialog"
x:DataType="vm:UnfinishedPlanningDialogViewModel"
Width="440" Height="220"
WindowStartupLocation="CenterOwner"
CanResize="False"
Title="{Binding Title}">
<StackPanel Margin="20" Spacing="12">
<TextBlock Text="{Binding Title}" FontWeight="Bold" FontSize="15"/>
<TextBlock Text="{Binding TaskTitle}" Opacity="0.85"/>
<TextBlock>
<Run Text="{Binding DraftCount}"/>
<Run Text=" draft tasks waiting to be finalized."/>
</TextBlock>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Content="Discard" Command="{Binding DiscardCommand}"/>
<Button Content="Finalize now" Command="{Binding FinalizeNowCommand}"/>
<Button Content="Resume" Classes="accent" Command="{Binding ResumeCommand}"/>
</StackPanel>
</StackPanel>
</Window>
```
`UnfinishedPlanningDialog.axaml.cs`:
```csharp
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace ClaudeDo.Ui.Views.Dialogs;
public partial class UnfinishedPlanningDialog : Window
{
public UnfinishedPlanningDialog()
{
InitializeComponent();
}
}
```
- [ ] **Step 3: Wire into `TasksIslandViewModel`**
When the user right-clicks a `Planning` row OR when the app starts and a `Planning` row is present, show the dialog. Add a helper in the VM:
```csharp
private async Task<UnfinishedPlanningDialogResult> AskUnfinishedPlanningAsync(TaskRowViewModel row)
{
var dialogVm = new UnfinishedPlanningDialogViewModel
{
TaskTitle = row.Title,
DraftCount = await _workerClient.GetPendingDraftCountAsync(row.Id),
};
var dlg = new UnfinishedPlanningDialog { DataContext = dialogVm };
_ = dlg.ShowDialog(_ownerWindow);
return await dialogVm.Result.Task;
}
```
Replace the direct resume/discard/finalize commands (from Task 3) with calls that first pop this dialog and dispatch based on result. For example:
```csharp
[RelayCommand]
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null || !row.IsPlanningParent) return;
var choice = await AskUnfinishedPlanningAsync(row);
switch (choice)
{
case UnfinishedPlanningDialogResult.Resume:
await _workerClient.ResumePlanningSessionAsync(row.Id);
break;
case UnfinishedPlanningDialogResult.FinalizeNow:
await _workerClient.FinalizePlanningSessionAsync(row.Id);
break;
case UnfinishedPlanningDialogResult.Discard:
await _workerClient.DiscardPlanningSessionAsync(row.Id);
break;
}
}
```
- [ ] **Step 4: Build + manual run**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: builds.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml.cs src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
git commit -m "feat(ui): unfinished planning session dialog"
```
---
## Task 8: TasksIslandView — wire new templates
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
- [ ] **Step 1: No structural change required**
The hierarchy is already handled by `TasksIslandViewModel.RebuildFlatStreams` interleaving children into `OpenItems`/`CompletedItems`. The existing `ItemsControl` bindings in `TasksIslandView` automatically pick up the new rows. Indentation/chevron/badge rendering is entirely inside `TaskRowView` (Task 4).
Verify the view does not have any logic that filters out children based on `ParentTaskId IS NOT NULL` today. If it does, remove that filter — the VM is now authoritative about what's in the stream.
- [ ] **Step 2: Build + manual check**
Launch the UI, create a manual task, and manually update its status to `Planning` in the DB (or wait for Plan B). Create one child in DB. Verify indentation and chevron render.
- [ ] **Step 3: Commit (if any change was made)**
```bash
git add src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml
git commit -m "chore(ui): verify tasks view renders hierarchy via flat stream"
```
If no change — skip the commit.
---
## Task 9: Delete-with-children handling
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (existing delete command)
- [ ] **Step 1: Catch `DbUpdateException` from delete**
Find the existing delete command. Wrap the repository/hub call:
```csharp
[RelayCommand]
private async Task RemoveAsync(TaskRowViewModel? row)
{
if (row is null) return;
try
{
await _workerClient.DeleteTaskAsync(row.Id);
}
catch (HubException ex) when (ex.Message.Contains("foreign key", StringComparison.OrdinalIgnoreCase)
|| ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|| ex.Message.Contains("Restrict", StringComparison.OrdinalIgnoreCase))
{
var childrenCount = 1; // or query via a new hub method if exact count matters
var choice = await _dialogs.ConfirmAsync(
"Cannot delete",
$"This task has child tasks. Delete all including children?");
if (!choice) return;
// Recursive delete — iterate children first. For v1 MVP, instruct user to
// discard the planning session first. Simpler, safer.
await _dialogs.ShowErrorAsync(
"Cannot delete",
"Discard the planning session or delete child tasks manually first.");
}
}
```
**Simplification for v1:** do not implement "Delete all including children" yet. Show an error instructing the user to discard the planning session or delete children first. This avoids an additional hub endpoint and keeps Plan C bounded.
- [ ] **Step 2: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: builds.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
git commit -m "feat(ui): friendly error when deleting task with children"
```
---
## Task 10: Manual smoke test + final verification
**Files:** none
- [ ] **Step 1: Full test run**
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
Expected: all tests pass.
- [ ] **Step 2: Build the full app**
Run:
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
```
Expected: all succeed.
- [ ] **Step 3: Manual smoke test (requires Plan B merged)**
1. Launch the app.
2. Create a Manual task with a title and some TODO-style description.
3. Right-click → "Open planning Session".
4. Verify Windows Terminal opens with Claude CLI running.
5. In the terminal, ask Claude to create two child tasks (`mcp__claudedo__create_child_task`).
6. Watch the UI: drafts appear under the parent (italic, grey, badge DRAFT).
7. Ask Claude to `finalize`.
8. Verify drafts become Manual/Queued children, parent flips to PLANNED badge.
9. Close terminal without finalize on a new planning task; right-click the Planning task: dialog appears with Resume/Finalize/Discard.
- [ ] **Step 4: Document any UI tweaks needed in `docs/open.md`**
Add a checklist item under UI verification for planning session visuals.
- [ ] **Step 5: Final commit**
```bash
git add docs/open.md
git commit -m "docs(open): add planning-session manual verification checklist"
```
---
## Out of scope for Plan C
- Recursive delete of parent-with-children via UI (error-only in v1).
- Collapse-state persistence across app restarts (in-memory only).
- Keyboard shortcut for "Open planning Session".
- Visual differentiation for PLANNED parents beyond a badge (e.g., subtle background tint) — can be added later if visually needed.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,718 @@
# Worker Log Footer 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:** Surface important Worker lifecycle events in the UI footer as a single rotating, color-coded line that auto-hides after 30s of silence.
**Architecture:** Add `WorkerLogLevel` enum in shared `ClaudeDo.Data` project. `HubBroadcaster` gets a `WorkerLog(message, level, timestampUtc)` SignalR event. Seven emit sites in `TaskRunner`, `TaskMergeService`, `TaskResetService` (callers of `WorktreeManager`, not WorktreeManager itself — they have the task title in scope). UI side: `WorkerClient` surfaces a `WorkerLogReceived` event; footer state lives on `IslandsShellViewModel` (existing root VM for `MainWindow`, also owns connection state); `System.Timers.Timer` clears the line after 30s; a `WorkerLogLevelToBrushConverter` maps level → brush in XAML.
**Tech Stack:** .NET 8, ASP.NET Core SignalR, Avalonia 12, CommunityToolkit.Mvvm, xUnit.
**Spec:** `docs/superpowers/specs/2026-04-23-worker-log-footer-design.md`
**Deviation from spec:** Spec names `WorktreeManager.CreateAsync` / `DiscardAsync` as emit sites. In practice, `WorktreeManager` has only the task ID in scope; its callers (`TaskRunner`, `TaskResetService`) have the title. Emitting from callers avoids adding constructor dependencies to `WorktreeManager` and produces identical user-visible behavior.
**Build note:** Per project convention, `dotnet build ClaudeDo.slnx` fails on .NET 8 — always build individual csprojs.
---
### Task 1: Add `WorkerLogLevel` enum (shared contract)
**Files:**
- Create: `src/ClaudeDo.Data/Models/WorkerLogLevel.cs`
- [ ] **Step 1: Write the enum**
```csharp
namespace ClaudeDo.Data.Models;
public enum WorkerLogLevel
{
Info,
Success,
Warn,
Error,
}
```
- [ ] **Step 2: Build Data project**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
Expected: Build succeeded, 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Data/Models/WorkerLogLevel.cs
git commit -m "feat(data): add WorkerLogLevel enum"
```
---
### Task 2: Add `WorkerLog` broadcaster method + SignalR JSON enum-as-string
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` (append method)
- Modify: `src/ClaudeDo.Worker/Program.cs` (line ~23 — the `AddSignalR()` call)
- [ ] **Step 1: Add enum-as-string serialization**
Replace:
```csharp
builder.Services.AddSignalR();
```
with:
```csharp
builder.Services.AddSignalR().AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
```
- [ ] **Step 2: Add `WorkerLog` method to `HubBroadcaster`**
Add the following method inside `HubBroadcaster` class (after the existing `RunCreated` method):
```csharp
public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) =>
_hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc);
```
Add to the using block at top of file (if not already present):
```csharp
using ClaudeDo.Data.Models;
```
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded, 0 errors.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): add WorkerLog SignalR event"
```
---
### Task 3: Emit `WorkerLog` from `TaskRunner`
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
Four emit sites in this file:
1. **Created worktree** — right after `_wtManager.CreateAsync` succeeds (around line 69).
2. **Started Claude** — just before invoking Claude process.
3. **Committed changes** — after auto-commit (before the `WorktreeUpdated` broadcast around line 318).
4. **Finished** — at both success (line 330) and failure paths, mirroring the existing `TaskFinished` call.
- [ ] **Step 1: Add using for `WorkerLogLevel`**
Ensure `TaskRunner.cs` has at the top:
```csharp
using ClaudeDo.Data.Models;
```
- [ ] **Step 2: Emit "Created worktree"**
After the line `wtCtx = await _wtManager.CreateAsync(task, list, ct);` (around line 69), add:
```csharp
await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
```
(Place inside the same `if` branch that called `CreateAsync`, after the assignment.)
- [ ] **Step 3: Emit "Started Claude"**
Locate the point just before `ClaudeProcess` is invoked (search for where `ClaudeProcess` or `RunProcessAsync` is called). Just before the invocation, add:
```csharp
await _broadcaster.WorkerLog($"Started Claude for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
```
- [ ] **Step 4: Emit "Committed changes"**
Locate the auto-commit code path (around line 318, just before `await _broadcaster.WorktreeUpdated(task.Id);`). Add immediately before that call:
```csharp
await _broadcaster.WorkerLog($"Committed changes in \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
```
- [ ] **Step 5: Emit "Finished (done)"**
Find the success finish path (around line 330, where `TaskFinished` is broadcast with status `"done"`). Add immediately before that call:
```csharp
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
```
- [ ] **Step 6: Emit "Finished (failed)"**
Find the failure path (search for `TaskFinished` with status `"failed"`). Add immediately before:
```csharp
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
```
- [ ] **Step 7: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded, 0 errors.
- [ ] **Step 8: Run existing Worker tests (no regressions)**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
Expected: All tests pass.
- [ ] **Step 9: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
git commit -m "feat(worker): emit WorkerLog events from TaskRunner"
```
---
### Task 4: Emit `WorkerLog` from `TaskMergeService` and `TaskResetService`
**Files:**
- Modify: `src/ClaudeDo.Worker/Services/TaskMergeService.cs`
- Modify: `src/ClaudeDo.Worker/Services/TaskResetService.cs`
Both services already have `HubBroadcaster` injected (`_broadcaster`). Both already load the task entity (needed for title).
- [ ] **Step 1: Add using in both files**
Add to the top of each file (if not already present):
```csharp
using ClaudeDo.Data.Models;
```
- [ ] **Step 2: Emit "Merged" in `TaskMergeService.MergeAsync`**
Locate the existing log line around line 137:
```csharp
_logger.LogInformation(
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
...);
```
Immediately after it (before `return new MergeResult(...)` on line 140), add:
```csharp
await _broadcaster.WorkerLog($"Merged \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);
```
Use whatever variable names `task` and `targetBranch` are in scope — adjust to match the actual local names at that site.
- [ ] **Step 3: Emit "Discarded" in `TaskResetService.ResetAsync`**
Locate the call `await _wtManager.DiscardAsync(wt, list.WorkingDir, ct);` (line 53). Immediately after it, add:
```csharp
await _broadcaster.WorkerLog($"Discarded worktree for \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
```
- [ ] **Step 4: Emit "Reset" in `TaskResetService.ResetAsync`**
Locate the existing line `_logger.LogInformation("Reset task {TaskId} to Manual ...` (line 66). Immediately after it, add:
```csharp
await _broadcaster.WorkerLog($"Reset \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
```
- [ ] **Step 5: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded, 0 errors.
- [ ] **Step 6: Run existing Worker tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
Expected: All tests pass.
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Worker/Services/TaskMergeService.cs src/ClaudeDo.Worker/Services/TaskResetService.cs
git commit -m "feat(worker): emit WorkerLog for merge, discard, reset"
```
---
### Task 5: Add `WorkerLogEntry` record + `WorkerLogReceived` event on `WorkerClient`
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- [ ] **Step 1: Add using for `WorkerLogLevel`**
Add at the top of `WorkerClient.cs`:
```csharp
using ClaudeDo.Data.Models;
```
- [ ] **Step 2: Declare the `WorkerLogEntry` record**
Add at the top of the file (above or below the `WorkerClient` class, same namespace):
```csharp
public sealed record WorkerLogEntry(string Message, WorkerLogLevel Level, DateTime TimestampUtc);
```
- [ ] **Step 3: Add the event field**
Alongside the other `public event Action<...>?` declarations (around lines 42-48), add:
```csharp
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
```
- [ ] **Step 4: Register the SignalR handler**
Alongside the other `_hub.On<...>` registrations (around lines 80-117), add:
```csharp
_hub.On<string, WorkerLogLevel, DateTime>("WorkerLog", (message, level, timestampUtc) =>
{
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc));
});
```
- [ ] **Step 5: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded, 0 errors.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): subscribe to WorkerLog SignalR event"
```
---
### Task 6: Create `ClaudeDo.Ui.Tests` project and add `WorkerLogLevelToBrushConverter`
**Files:**
- Create: `src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs`
- Modify: `src/ClaudeDo.Ui/App.axaml` (register converter as resource)
- Create: `tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
- Create: `tests/ClaudeDo.Ui.Tests/WorkerLogLevelToBrushConverterTests.cs`
- [ ] **Step 1: Write the converter**
Create `src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs`:
```csharp
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Data.Converters;
using Avalonia.Media;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Ui.Converters;
public sealed class WorkerLogLevelToBrushConverter : IValueConverter
{
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#4CAF50"));
private static readonly IBrush WarnBrush = new SolidColorBrush(Color.Parse("#FFA726"));
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#EF5350"));
private static readonly IBrush InfoFallback = new SolidColorBrush(Color.Parse("#888888"));
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not WorkerLogLevel level)
return AvaloniaProperty.UnsetValue;
return level switch
{
WorkerLogLevel.Success => SuccessBrush,
WorkerLogLevel.Warn => WarnBrush,
WorkerLogLevel.Error => ErrorBrush,
WorkerLogLevel.Info => ResolveInfoBrush(),
_ => AvaloniaProperty.UnsetValue,
};
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
private static IBrush ResolveInfoBrush()
{
if (Application.Current is { } app &&
app.Resources.TryGetResource("TextDimBrush", app.ActualThemeVariant, out var res) &&
res is IBrush brush)
{
return brush;
}
return InfoFallback;
}
}
```
- [ ] **Step 2: Register converter in `App.axaml`**
Open `src/ClaudeDo.Ui/App.axaml`. Inside the `<Application.Resources>` section (add one if missing), add alongside any existing converter entries:
```xml
<converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
```
Ensure the `xmlns:converters="using:ClaudeDo.Ui.Converters"` namespace is declared at the root `<Application>` element. If other converters (e.g. `StatusColorConverter`) are already resources in `App.axaml` follow the same pattern; if they're declared per-view, declare this converter at the top of `MainWindow.axaml` in Task 8 instead.
- [ ] **Step 3: Create UI test project**
Create `tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.0" />
<PackageReference Include="Avalonia.Headless" Version="12.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ClaudeDo.Ui\ClaudeDo.Ui.csproj" />
</ItemGroup>
</Project>
```
If the existing `tests/ClaudeDo.Worker.Tests/*.csproj` uses different `Microsoft.NET.Test.Sdk` / xUnit versions, match those versions exactly to avoid analyzer mismatches.
- [ ] **Step 4: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/WorkerLogLevelToBrushConverterTests.cs`:
```csharp
using System.Globalization;
using Avalonia;
using Avalonia.Media;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Converters;
using Xunit;
namespace ClaudeDo.Ui.Tests;
public class WorkerLogLevelToBrushConverterTests
{
[Theory]
[InlineData(WorkerLogLevel.Success, "#FF4CAF50")]
[InlineData(WorkerLogLevel.Warn, "#FFFFA726")]
[InlineData(WorkerLogLevel.Error, "#FFEF5350")]
public void Convert_maps_level_to_expected_brush_color(WorkerLogLevel level, string expectedArgb)
{
var converter = new WorkerLogLevelToBrushConverter();
var result = converter.Convert(level, typeof(IBrush), null, CultureInfo.InvariantCulture);
var solid = Assert.IsType<SolidColorBrush>(result);
Assert.Equal(expectedArgb.ToLowerInvariant(), $"#{solid.Color.ToUInt32():X8}".ToLowerInvariant());
}
[Fact]
public void Convert_info_returns_a_brush_fallback_when_no_app()
{
var converter = new WorkerLogLevelToBrushConverter();
var result = converter.Convert(WorkerLogLevel.Info, typeof(IBrush), null, CultureInfo.InvariantCulture);
Assert.IsAssignableFrom<IBrush>(result);
}
[Fact]
public void Convert_unknown_value_returns_unset()
{
var converter = new WorkerLogLevelToBrushConverter();
var result = converter.Convert("not a level", typeof(IBrush), null, CultureInfo.InvariantCulture);
Assert.Equal(AvaloniaProperty.UnsetValue, result);
}
}
```
- [ ] **Step 5: Run the tests**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
Expected: All 5 tests pass.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs src/ClaudeDo.Ui/App.axaml tests/ClaudeDo.Ui.Tests/
git commit -m "feat(ui): add WorkerLogLevelToBrushConverter with tests"
```
---
### Task 7: Add footer state + 30s auto-clear timer to `IslandsShellViewModel`
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- Create: `tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs`
Timer uses `System.Timers.Timer` (not `DispatcherTimer`) so unit tests don't need an Avalonia dispatcher. The elapsed callback marshals to the UI thread via `Dispatcher.UIThread.Post` when the dispatcher is available; in tests the VM logic under test sets properties directly so no marshalling is needed.
- [ ] **Step 1: Write the failing tests**
Create `tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs`:
```csharp
using System;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using Xunit;
namespace ClaudeDo.Ui.Tests;
public class IslandsShellViewModelWorkerLogTests
{
private static IslandsShellViewModel NewVm() =>
// The real constructor requires island VMs + WorkerClient. These tests
// only exercise the WorkerLog handling, so we use a test-only constructor
// that bypasses the sub-VMs. Add `internal IslandsShellViewModel()` for tests.
IslandsShellViewModel.CreateForTests();
[Fact]
public void Receiving_event_sets_text_level_and_visible()
{
var vm = NewVm();
var at = new DateTime(2026, 4, 23, 14, 32, 0, DateTimeKind.Utc);
vm.OnWorkerLogReceived(new WorkerLogEntry("Created worktree for \"X\"", WorkerLogLevel.Info, at));
Assert.True(vm.IsWorkerLogVisible);
Assert.Equal(WorkerLogLevel.Info, vm.WorkerLogLevel);
Assert.Contains("Created worktree for \"X\"", vm.WorkerLogText);
}
[Fact]
public void Second_event_replaces_first()
{
var vm = NewVm();
vm.OnWorkerLogReceived(new WorkerLogEntry("first", WorkerLogLevel.Info, DateTime.UtcNow));
vm.OnWorkerLogReceived(new WorkerLogEntry("second", WorkerLogLevel.Success, DateTime.UtcNow));
Assert.Contains("second", vm.WorkerLogText);
Assert.Equal(WorkerLogLevel.Success, vm.WorkerLogLevel);
}
[Fact]
public void ClearWorkerLog_hides_line()
{
var vm = NewVm();
vm.OnWorkerLogReceived(new WorkerLogEntry("msg", WorkerLogLevel.Info, DateTime.UtcNow));
vm.ClearWorkerLog();
Assert.False(vm.IsWorkerLogVisible);
Assert.Null(vm.WorkerLogText);
}
[Fact]
public void Text_is_formatted_as_HHmm_dot_message_local_time()
{
var vm = NewVm();
var utc = new DateTime(2026, 4, 23, 12, 0, 0, DateTimeKind.Utc);
var expectedLocalHhmm = utc.ToLocalTime().ToString("HH:mm");
vm.OnWorkerLogReceived(new WorkerLogEntry("hello", WorkerLogLevel.Info, utc));
Assert.StartsWith(expectedLocalHhmm + " · ", vm.WorkerLogText);
}
}
```
- [ ] **Step 2: Run tests to confirm they fail to compile**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
Expected: Build errors — `CreateForTests`, `OnWorkerLogReceived`, `ClearWorkerLog`, `IsWorkerLogVisible`, `WorkerLogText`, `WorkerLogLevel` do not yet exist.
- [ ] **Step 3: Implement on `IslandsShellViewModel`**
Open `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`. Add `using`s if missing:
```csharp
using System.Timers;
using Avalonia.Threading;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
```
Inside the class, add:
```csharp
[ObservableProperty] private string? workerLogText;
[ObservableProperty] private WorkerLogLevel workerLogLevel;
[ObservableProperty] private bool isWorkerLogVisible;
private readonly Timer _workerLogTimer = new(TimeSpan.FromSeconds(30).TotalMilliseconds)
{
AutoReset = false,
};
internal static IslandsShellViewModel CreateForTests() =>
(IslandsShellViewModel)System.Runtime.Serialization.FormatterServices
.GetUninitializedObject(typeof(IslandsShellViewModel));
```
(If `FormatterServices` is unavailable under `net8.0`, instead add a parameterless `internal IslandsShellViewModel() {}` constructor guarded for tests only.)
In the existing real constructor, wire up subscription (after the line `Worker.PropertyChanged += ...` block, around line 63-70):
```csharp
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
_workerLogTimer.Elapsed += (_, _) =>
{
if (Dispatcher.UIThread.CheckAccess()) ClearWorkerLog();
else Dispatcher.UIThread.Post(ClearWorkerLog);
};
```
Add the methods the tests call:
```csharp
public void OnWorkerLogReceived(WorkerLogEntry entry)
{
var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm");
WorkerLogText = $"{hhmm} · {entry.Message}";
WorkerLogLevel = entry.Level;
IsWorkerLogVisible = true;
_workerLogTimer.Stop();
_workerLogTimer.Start();
}
public void ClearWorkerLog()
{
IsWorkerLogVisible = false;
WorkerLogText = null;
}
```
- [ ] **Step 4: Run tests**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
Expected: All tests pass (5 converter + 4 VM = 9 tests).
- [ ] **Step 5: Build the UI project as a sanity check**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs
git commit -m "feat(ui): add worker log state and 30s timer to shell VM"
```
---
### Task 8: Update `MainWindow.axaml` footer — dock log line right
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml` (lines 104-135 — the footer `Border`)
- [ ] **Step 1: Add the converter resource to the window (if not already in App.axaml)**
If Task 6 declared the converter in `App.axaml`, skip this step. Otherwise, add a `<Window.Resources>` block near the top of `MainWindow.axaml`:
```xml
<Window.Resources>
<converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
</Window.Resources>
```
Ensure `xmlns:converters="using:ClaudeDo.Ui.Converters"` is declared on the root `<Window>`.
- [ ] **Step 2: Replace the footer body**
Replace the existing footer `<Border Grid.Row="2" ...>` inner contents (the `<StackPanel>` at lines 109-134) with:
```xml
<DockPanel LastChildFill="True" Margin="14,0">
<!-- Left: connection pill -->
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="7"
VerticalAlignment="Center">
<Ellipse Width="7" Height="7" Fill="#4CAF50"
IsVisible="{Binding Worker.IsConnected}"/>
<Ellipse Width="7" Height="7" Fill="#FFA726"
IsVisible="{Binding Worker.IsReconnecting}"/>
<Ellipse Width="7" Height="7" Fill="#EF5350"
IsVisible="{Binding IsOffline}"/>
<TextBlock Text="{Binding ConnectionText, Converter={StaticResource UpperCase}}"
FontFamily="{DynamicResource MonoFont}"
FontSize="10"
LetterSpacing="1.4"
Foreground="{DynamicResource TextDimBrush}"
VerticalAlignment="Center"/>
</StackPanel>
<!-- Right: worker log line -->
<TextBlock DockPanel.Dock="Right"
Text="{Binding WorkerLogText}"
IsVisible="{Binding IsWorkerLogVisible}"
Foreground="{Binding WorkerLogLevel, Converter={StaticResource WorkerLogLevelToBrush}}"
FontFamily="{DynamicResource MonoFont}"
FontSize="10"
LetterSpacing="1.4"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"/>
<!-- Spacer (fills remaining space between pill and log) -->
<Panel/>
</DockPanel>
```
- [ ] **Step 3: Build the UI**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded, 0 errors. No XAML compilation errors.
- [ ] **Step 4: Build the full app (entry point)**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded.
- [ ] **Step 5: Manual smoke test**
Start the Worker and the App (two separate processes per CLAUDE.md).
Exercise each event and confirm the footer line appears with the expected color and copy:
1. Start a task → expect `HH:MM · Created worktree for "<title>"` (dim/info).
2. Observe while Claude runs → expect `HH:MM · Started Claude for "<title>"` (dim/info).
3. Task commits → expect `HH:MM · Committed changes in "<title>"` (dim/info).
4. Task finishes successfully → expect `HH:MM · Finished "<title>" (done)` (green).
5. Trigger a failing task → expect `HH:MM · Finished "<title>" (failed)` (red).
6. Reset a failed task → expect `HH:MM · Discarded worktree for "<title>"` (amber) followed by `HH:MM · Reset "<title>"` (amber).
7. Merge a completed task → expect `HH:MM · Merged "<title>" into <branch>` (green).
8. Wait 30s with no new events → footer log line disappears (connection pill remains).
9. Trigger a burst of 3 events in quick succession → only the most recent is shown; timer resets on each.
10. Long task title (≥60 chars) → line is ellipsized, connection pill on the left remains fully visible.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/Views/MainWindow.axaml
git commit -m "feat(ui): show worker log line in footer"
```
---
## Self-Review
- **Spec coverage:**
- Enum `WorkerLogLevel` in `ClaudeDo.Data` — Task 1 ✓
- SignalR enum-as-string — Task 2 ✓
- `HubBroadcaster.WorkerLog` — Task 2 ✓
- 7 emit sites with correct level mapping — Tasks 3, 4 ✓
- `WorkerClient.WorkerLogReceived` event + `WorkerLogEntry` record — Task 5 ✓
- `WorkerLogLevelToBrushConverter` with unit tests — Task 6 ✓
- Footer VM state + 30s timer + tests — Task 7 ✓
- Footer XAML (DockPanel, connection left, log right, level-based color, ellipsis) — Task 8 ✓
- Out-of-scope items (history drawer, filtering, persistence) — correctly omitted ✓
- **Placeholder scan:** No "TBD" / "handle edge cases" / "similar to Task N". All code is inline.
- **Type consistency:** `WorkerLogEntry(Message, Level, TimestampUtc)` — same signature used in Task 5 (declaration), Task 7 (consumer tests + VM). `WorkerLog(message, level, timestampUtc)` — same signature in Task 2 (broadcaster) and Tasks 3-4 (callers). `OnWorkerLogReceived` / `ClearWorkerLog` / `IsWorkerLogVisible` / `WorkerLogText` / `WorkerLogLevel` — consistent between Task 7 test and Task 7 implementation.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,799 @@
# Planning UX Polish + Sequential Subtask Queue — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add sequential execution of planning subtasks (new `Waiting` status, context-menu trigger, worker-side chain advancement) plus three small UX changes (auto-collapse done planning parents in the task list, collapsible Description in the Details pane, narrower island GridSplitters).
**Architecture:** Foundation first — add the new `Waiting` enum value and its surface in the UI (chip, virtual-queued filter, row plumbing). Then ship the three UI polish items independently. Finally build the worker-side chain coordinator behind TDD and wire up the SignalR method + context-menu entry.
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm, EF Core (Sqlite), SignalR, xUnit.
**Spec:** `docs/superpowers/specs/2026-04-24-planning-ux-and-sequential-subtasks-design.md`
---
## Task 1: Add `Waiting` status to the enum
**Files:**
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs` (TaskStatus enum)
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` (chip class switch)
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (virtual-queued match predicate)
- [ ] **Step 1: Add `Waiting` to the enum**
Append `Waiting` as the last value (keeps existing numeric slots stable for any int-serialized rows).
`src/ClaudeDo.Data/Models/TaskEntity.cs`:
```csharp
public enum TaskStatus
{
Manual,
Queued,
Running,
Done,
Failed,
Planning,
Planned,
Draft,
Waiting,
}
```
- [ ] **Step 2: Extend `StatusChipClass` switch in TaskRowViewModel**
`src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — update the switch:
```csharp
public string StatusChipClass => Status switch
{
TaskStatus.Running => "running",
TaskStatus.Failed => "error",
TaskStatus.Done => "review",
TaskStatus.Queued => "queued",
TaskStatus.Waiting => "waiting",
_ => "idle",
};
```
- [ ] **Step 3: Add `IsWaiting` and include it in virtual-queued matching**
In the same `TaskRowViewModel.cs`, add alongside `IsQueued`:
```csharp
public bool IsWaiting => Status == TaskStatus.Waiting;
```
In `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`, find the `TaskMatchesList` static method and update the `virtual:queued` branch so tasks in `Waiting` also match. Locate the existing match for `ListKind.Virtual when list.Id == "virtual:queued"` and change it to match `t.Status == TaskStatus.Queued || t.Status == TaskStatus.Waiting`. If the existing line reads `t.Status == TaskStatus.Queued` exactly, replace it with `t.Status == TaskStatus.Queued || t.Status == TaskStatus.Waiting`.
- [ ] **Step 4: Build**
Run:
```bash
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: both build with 0 errors. Existing warnings OK.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Models/TaskEntity.cs \
src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs \
src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
git commit -m "feat(data): add Waiting task status and include it in virtual:queued"
```
---
## Task 2: Narrower island GridSplitters
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml` (lines 158 and 170)
- [ ] **Step 1: Halve the splitter width**
Both `GridSplitter` elements currently use `Width="5"`. Change both to `Width="3"`. Leave all other attributes untouched.
- [ ] **Step 2: Build**
```bash
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Views/MainWindow.axaml
git commit -m "style(ui): narrow island GridSplitters from 5 to 3"
```
---
## Task 3: Collapsible Description section in Details pane
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
- [ ] **Step 1: Add observable flag + toggle command**
In `DetailsIslandViewModel.cs`, add beside the existing editable fields:
```csharp
[ObservableProperty] private bool _isDescriptionExpanded = true;
[RelayCommand]
private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded;
```
- [ ] **Step 2: Reset flag when a new task is loaded**
Find the method that handles a new `Task` being bound (the existing `OnTaskChanged` / `Bind` path — it's the spot that already sets `EditableTitle`, `EditableDescription`, etc.). At the start of the load path where fields get reset, add:
```csharp
IsDescriptionExpanded = true;
```
(If the reset is scattered, put it next to the `EditableDescription = ""` assignment.)
- [ ] **Step 3: Wrap the description TextBox in a collapsible section**
In `DetailsIslandView.axaml`, locate the description TextBox. Wrap it so it looks like:
```xml
<StackPanel Spacing="4">
<Button Classes="flat"
Command="{Binding ToggleDescriptionExpandedCommand}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Padding="0">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Width="10" Height="10"
Data="{StaticResource Icon.ChevronDown}"
IsVisible="{Binding IsDescriptionExpanded}"/>
<PathIcon Width="10" Height="10"
Data="{StaticResource Icon.ChevronRight}"
IsVisible="{Binding !IsDescriptionExpanded}"/>
<TextBlock Classes="eyebrow" Text="DESCRIPTION"/>
</StackPanel>
</Button>
<!-- existing description TextBox goes here unchanged, but add: -->
<TextBox ...existing attributes...
IsVisible="{Binding IsDescriptionExpanded}"/>
</StackPanel>
```
If the existing `Icon.ChevronDown` / `Icon.ChevronRight` static resources don't exist, inspect `App.axaml` (or wherever `StaticResource Icon.*` icons live) and pick the closest existing chevron pair. If only one direction exists, use a simple `▾` / `▸` TextBlock substitute:
```xml
<TextBlock Text="▾" IsVisible="{Binding IsDescriptionExpanded}"/>
<TextBlock Text="▸" IsVisible="{Binding !IsDescriptionExpanded}"/>
```
- [ ] **Step 4: Build**
```bash
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: 0 errors.
- [ ] **Step 5: Manual verify**
Launch the app (`dotnet run --project src/ClaudeDo.App`), open a task with a description, click the chevron. Verify the body collapses/expands; verify opening a different task restores the expanded default.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs \
src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml
git commit -m "feat(ui): collapsible description section in details pane"
```
---
## Task 4: Auto-collapse done planning parents in task list
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- [ ] **Step 1: Add expansion state + "all children done" flag to `TaskRowViewModel`**
In `TaskRowViewModel.cs`, add below the existing observable properties:
```csharp
[ObservableProperty] private bool _areChildrenExpanded = true;
[ObservableProperty] private bool _allChildrenDone;
partial void OnAllChildrenDoneChanged(bool value)
{
// Default children to collapsed once the planning parent is fully done.
if (value) AreChildrenExpanded = false;
}
[RelayCommand]
private void ToggleChildrenExpanded() => AreChildrenExpanded = !AreChildrenExpanded;
```
- [ ] **Step 2: Compute `AllChildrenDone` during Regroup in `TasksIslandViewModel`**
In `TasksIslandViewModel.cs`, locate the `Regroup()` method (the one that clears and repopulates `OverdueItems`/`OpenItems`/`CompletedItems`). Before it distributes rows, build a lookup of children by parent id:
```csharp
var childrenByParent = Items
.Where(r => r.IsChild && r.ParentTaskId is not null)
.GroupBy(r => r.ParentTaskId!)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild))
{
if (childrenByParent.TryGetValue(parent.Id, out var kids) && kids.Count > 0)
parent.AllChildrenDone = kids.All(c => c.Status == TaskStatus.Done);
else
parent.AllChildrenDone = false;
}
```
Then inside the existing distribution loop, skip child rows whose parent row has `AreChildrenExpanded == false`:
```csharp
foreach (var row in Items)
{
if (row.IsChild && row.ParentTaskId is not null)
{
var parentRow = Items.FirstOrDefault(p => p.Id == row.ParentTaskId);
if (parentRow is not null && !parentRow.AreChildrenExpanded) continue;
}
// ... existing distribution into Overdue/Open/Completed ...
}
```
If `Regroup()` currently uses LINQ expressions instead of a loop, split them out into explicit foreach so the skip is clear. Keep the overdue/completed logic intact — children of a collapsed parent are excluded from every bucket.
- [ ] **Step 3: Re-run Regroup when a row's expansion flag toggles**
In `TasksIslandViewModel.cs`, in the constructor (after `Items` is created), subscribe to changes so toggling one row triggers a regroup:
```csharp
Items.CollectionChanged += (_, e) =>
{
if (e.NewItems is not null)
foreach (TaskRowViewModel r in e.NewItems)
r.PropertyChanged += OnItemPropertyChanged;
if (e.OldItems is not null)
foreach (TaskRowViewModel r in e.OldItems)
r.PropertyChanged -= OnItemPropertyChanged;
};
```
Add the handler:
```csharp
private void OnItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(TaskRowViewModel.AreChildrenExpanded))
Regroup();
}
```
- [ ] **Step 4: Add chevron toggle button to the planning-parent row**
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, inside the main task card where the title/eyebrow row lives (co-located with `PlanningBadge`), add a chevron button visible only when `IsPlanningParent && HasPlanningChildren`:
```xml
<Button Classes="flat"
Command="{Binding ToggleChildrenExpandedCommand}"
IsVisible="{Binding HasPlanningChildren}"
Padding="0" Margin="0,0,6,0"
VerticalAlignment="Center">
<TextBlock FontSize="10"
Text="▾"
IsVisible="{Binding AreChildrenExpanded}"/>
<TextBlock FontSize="10"
Text="▸"
IsVisible="{Binding !AreChildrenExpanded}"/>
</Button>
```
Place it immediately before the title TextBlock in the parent-row layout. Leave child rows untouched.
- [ ] **Step 5: Build**
```bash
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: 0 errors.
- [ ] **Step 6: Manual verify**
Create a planning parent with ≥2 children. Mark both children `Done` (manually via DB if needed, or via a full planning run). Reload the list — the children should be hidden by default. Click the chevron on the parent — children appear. Click again — collapse.
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs \
src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs \
src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml
git commit -m "feat(ui): auto-collapse done planning parents in task list"
```
---
## Task 5: PlanningChainCoordinator — worker-side chain advancement (TDD)
**Files:**
- Create: `src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs`
- [ ] **Step 1: Write the first failing test — queueing sets first child Queued, rest Waiting**
Create `tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs`:
```csharp
using System.Threading.Tasks;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Planning;
using Microsoft.EntityFrameworkCore;
using Xunit;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Planning;
public class PlanningChainCoordinatorTests
{
private static DbContextOptions<ClaudeDoDbContext> InMemoryOptions() =>
new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite("DataSource=:memory:;Cache=Shared")
.Options;
private static async Task<(ClaudeDoDbContext ctx, TaskRepository repo)> NewDbAsync()
{
var ctx = new ClaudeDoDbContext(InMemoryOptions());
await ctx.Database.OpenConnectionAsync();
await ctx.Database.EnsureCreatedAsync();
return (ctx, new TaskRepository(ctx));
}
private static async Task SeedPlanningFamily(TaskRepository repo, string parentId, int childCount)
{
await repo.AddAsync(new TaskEntity
{
Id = parentId, ListId = "L1", Title = "Parent",
CreatedAt = System.DateTime.UtcNow, Status = TaskStatus.Planned,
});
for (int i = 0; i < childCount; i++)
{
await repo.AddAsync(new TaskEntity
{
Id = $"{parentId}-c{i}", ListId = "L1", Title = $"Child {i}",
CreatedAt = System.DateTime.UtcNow, Status = TaskStatus.Manual,
ParentTaskId = parentId, SortOrder = i,
});
}
}
[Fact]
public async Task QueueSubtasksSequentially_SetsFirstQueued_RestWaiting()
{
var (ctx, repo) = await NewDbAsync();
await using var _ = ctx;
await SeedPlanningFamily(repo, "P", 3);
var coord = new PlanningChainCoordinator(repo);
await coord.QueueSubtasksSequentiallyAsync("P", default);
var kids = await ctx.Tasks.Where(t => t.ParentTaskId == "P").OrderBy(t => t.SortOrder).ToListAsync();
Assert.Equal(TaskStatus.Queued, kids[0].Status);
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
}
}
```
- [ ] **Step 2: Run the test — expect failure (class doesn't exist)**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
```
Expected: compile error "PlanningChainCoordinator not found".
- [ ] **Step 3: Create the coordinator with the minimum to pass**
`src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs`:
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
namespace ClaudeDo.Worker.Planning;
public sealed class PlanningChainCoordinator
{
private readonly TaskRepository _tasks;
public PlanningChainCoordinator(TaskRepository tasks) => _tasks = tasks;
public async Task QueueSubtasksSequentiallyAsync(string parentTaskId, CancellationToken ct)
{
var parent = await _tasks.GetByIdAsync(parentTaskId, ct)
?? throw new InvalidOperationException($"Task {parentTaskId} not found.");
var children = (await _tasks.GetChildrenAsync(parentTaskId, ct))
.OrderBy(t => t.SortOrder)
.ToList();
if (children.Count == 0)
throw new InvalidOperationException("Parent has no subtasks.");
var bad = children.FirstOrDefault(c => c.Status is not (TaskStatus.Manual or TaskStatus.Planned));
if (bad is not null)
throw new InvalidOperationException($"Child {bad.Id} is in status {bad.Status}; expected Manual or Planned.");
for (int i = 0; i < children.Count; i++)
{
children[i].Status = i == 0 ? TaskStatus.Queued : TaskStatus.Waiting;
await _tasks.UpdateAsync(children[i], ct);
}
}
}
```
If `TaskRepository.GetChildrenAsync` does not yet exist, add it:
```csharp
// in src/ClaudeDo.Data/Repositories/TaskRepository.cs
public Task<List<TaskEntity>> GetChildrenAsync(string parentTaskId, CancellationToken ct = default) =>
_ctx.Tasks.Where(t => t.ParentTaskId == parentTaskId).ToListAsync(ct);
```
(If the repo uses `AsNoTracking()` elsewhere for reads, match that pattern. For this method we want tracked entities so `UpdateAsync` works without extra attach.)
- [ ] **Step 4: Run the test — expect pass**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
```
Expected: 1 passed.
- [ ] **Step 5: Add failing test — on child Done, next Waiting sibling flips to Queued**
Append to `PlanningChainCoordinatorTests.cs`:
```csharp
[Fact]
public async Task OnChildDone_FlipsNextWaitingToQueued()
{
var (ctx, repo) = await NewDbAsync();
await using var _ = ctx;
await SeedPlanningFamily(repo, "P", 3);
var coord = new PlanningChainCoordinator(repo);
await coord.QueueSubtasksSequentiallyAsync("P", default);
// Simulate first child finishing Done.
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
first.Status = TaskStatus.Done;
await ctx.SaveChangesAsync();
var advanced = await coord.OnChildFinishedAsync("P-c0", TaskStatus.Done, default);
Assert.Equal("P-c1", advanced);
var kids = await ctx.Tasks.Where(t => t.ParentTaskId == "P").OrderBy(t => t.SortOrder).ToListAsync();
Assert.Equal(TaskStatus.Done, kids[0].Status);
Assert.Equal(TaskStatus.Queued, kids[1].Status);
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
}
```
- [ ] **Step 6: Run — expect failure**
Expected: compile error "OnChildFinishedAsync does not exist".
- [ ] **Step 7: Implement `OnChildFinishedAsync`**
In `PlanningChainCoordinator.cs`:
```csharp
/// <summary>
/// Call after a child task transitions to a terminal status.
/// Returns the id of the newly-queued sibling (if any), else null.
/// </summary>
public async Task<string?> OnChildFinishedAsync(string childTaskId, TaskStatus finalStatus, CancellationToken ct)
{
if (finalStatus != TaskStatus.Done) return null;
var child = await _tasks.GetByIdAsync(childTaskId, ct);
if (child?.ParentTaskId is null) return null;
var siblings = (await _tasks.GetChildrenAsync(child.ParentTaskId, ct))
.OrderBy(t => t.SortOrder)
.ToList();
var next = siblings
.Where(s => s.SortOrder > child.SortOrder && s.Status == TaskStatus.Waiting)
.FirstOrDefault();
if (next is null) return null;
next.Status = TaskStatus.Queued;
await _tasks.UpdateAsync(next, ct);
return next.Id;
}
```
- [ ] **Step 8: Run — expect pass**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
```
Expected: 2 passed.
- [ ] **Step 9: Add failing test — on Failed, chain stops**
```csharp
[Fact]
public async Task OnChildFailed_DoesNotAdvanceChain()
{
var (ctx, repo) = await NewDbAsync();
await using var _ = ctx;
await SeedPlanningFamily(repo, "P", 3);
var coord = new PlanningChainCoordinator(repo);
await coord.QueueSubtasksSequentiallyAsync("P", default);
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
first.Status = TaskStatus.Failed;
await ctx.SaveChangesAsync();
var advanced = await coord.OnChildFinishedAsync("P-c0", TaskStatus.Failed, default);
Assert.Null(advanced);
var kids = await ctx.Tasks.Where(t => t.ParentTaskId == "P").OrderBy(t => t.SortOrder).ToListAsync();
Assert.Equal(TaskStatus.Failed, kids[0].Status);
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
}
```
- [ ] **Step 10: Run — expect pass (existing guard handles it)**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
```
Expected: 3 passed.
- [ ] **Step 11: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs \
src/ClaudeDo.Data/Repositories/TaskRepository.cs \
tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs
git commit -m "feat(worker): add PlanningChainCoordinator with sequential subtask advancement"
```
---
## Task 6: Hook chain advancement into TaskRunner finish path
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
- Modify: `src/ClaudeDo.Worker/Program.cs` (DI registration)
- [ ] **Step 1: Register `PlanningChainCoordinator` in DI**
Locate `src/ClaudeDo.Worker/Program.cs` where other services are registered (look for `services.AddSingleton<PlanningSessionManager>` or similar). Add:
```csharp
services.AddScoped<PlanningChainCoordinator>();
```
Use `AddScoped` if `TaskRepository` is scoped (check how it's registered — match its lifetime). If `TaskRepository` is constructed ad-hoc inside the worker, add a constructor overload on `PlanningChainCoordinator` that takes `IDbContextFactory<ClaudeDoDbContext>` and builds its own `TaskRepository` per call, then register as Singleton. Mirror the pattern used by `PlanningSessionManager`.
- [ ] **Step 2: Inject coordinator into `TaskRunner`**
In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, add `PlanningChainCoordinator` to the constructor parameter list and store it in a readonly field (match the style used for `_broadcaster`).
If `TaskRunner` is not a good fit for direct injection (e.g., it's used in contexts without DI), instead inject `IServiceProvider` / `IDbContextFactory<ClaudeDoDbContext>` and new-up a coordinator inside the finish handler. Pick whichever matches existing `TaskRunner` patterns.
- [ ] **Step 3: Call coordinator after Done/Failed emission**
Immediately after each `await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);` on line ~338 and the two failed emissions on lines ~355 and ~372, add:
```csharp
if (task.ParentTaskId is not null)
{
var advancedId = await _chainCoordinator.OnChildFinishedAsync(
task.Id,
/* Done or Failed based on path */,
CancellationToken.None);
if (advancedId is not null)
await _broadcaster.TaskUpdated(advancedId);
}
```
Use `TaskStatus.Done` in the done-path call site and `TaskStatus.Failed` in the failed-path call sites. For the failed paths that use `justFailed` rather than `task`, read `justFailed?.ParentTaskId` and `justFailed?.Id` to stay consistent with the surrounding code.
After this call the existing queue-pickup loop will see the newly-Queued sibling and dispatch it on its next tick.
- [ ] **Step 4: Build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: 0 errors.
- [ ] **Step 5: Run full test suite**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
```
Expected: all pre-existing tests + 3 new ones pass.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs \
src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): advance planning subtask chain on child finish"
```
---
## Task 7: Hub method + client + context menu entry
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- Modify: `src/ClaudeDo.Ui/Services/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs`
- [ ] **Step 1: Add hub method**
In `src/ClaudeDo.Worker/Hub/WorkerHub.cs`, add (match the style of other planning methods):
```csharp
public async Task QueuePlanningSubtasks(string parentTaskId)
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var repo = new TaskRepository(ctx);
var coord = new PlanningChainCoordinator(repo);
await coord.QueueSubtasksSequentiallyAsync(parentTaskId, CancellationToken.None);
// Broadcast updates for the parent and all its children so the UI refreshes.
var children = await ctx.Tasks
.Where(t => t.ParentTaskId == parentTaskId)
.Select(t => t.Id)
.ToListAsync();
await _broadcaster.TaskUpdated(parentTaskId);
foreach (var id in children)
await _broadcaster.TaskUpdated(id);
// Make sure the queue picks up the now-Queued first child immediately.
_queueSignal.Wake();
}
```
If the existing hub constructs `PlanningSessionManager` via DI directly, inject `PlanningChainCoordinator` the same way and call `_chainCoordinator.QueueSubtasksSequentiallyAsync(...)` instead of newing one up. If the hub exposes a queue-wakeup via a different name than `_queueSignal`, use that (search the file for `WakeQueue` or `.Wake()`).
- [ ] **Step 2: Add method to `IWorkerClient`**
In `src/ClaudeDo.Ui/Services/IWorkerClient.cs`, add next to the other planning methods:
```csharp
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
```
- [ ] **Step 3: Implement in `WorkerClient`**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add (match the pattern of `StartPlanningSessionAsync` etc.):
```csharp
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) =>
_connection.InvokeAsync("QueuePlanningSubtasks", parentTaskId, ct);
```
- [ ] **Step 4: Add `CanQueueSubtasksSequentially` + `HasPlanningChildren` observable to `TaskRowViewModel`**
Confirm `HasPlanningChildren` exists (it's referenced in the spec). If not, add it as `[ObservableProperty] bool _hasPlanningChildren;` and ensure `TasksIslandViewModel.Regroup()` already sets it (there should be a parent-side "has children" pass similar to the `AllChildrenDone` one added in Task 4 — if not, set it there).
Then add:
```csharp
public bool CanQueueSubtasksSequentially =>
IsPlanningParent && HasPlanningChildren && !IsChild;
```
Add `OnPropertyChanged(nameof(CanQueueSubtasksSequentially))` inside `OnStatusChanged` and `OnHasPlanningChildrenChanged` so the flag refreshes when status or children change.
- [ ] **Step 5: Add context-menu entry**
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, inside the existing `<ContextMenu>`, directly after the "Discard planning session" item:
```xml
<Separator IsVisible="{Binding CanQueueSubtasksSequentially}"/>
<MenuItem Header="Queue subtasks sequentially"
IsVisible="{Binding CanQueueSubtasksSequentially}"
Click="OnQueueSubtasksSequentiallyClick"/>
```
- [ ] **Step 6: Add click handler in code-behind**
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs`, add (match the other `On*Click` handlers — they pull the `TaskRowViewModel` from `DataContext` and call the shell / worker):
```csharp
private async void OnQueueSubtasksSequentiallyClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (DataContext is not TaskRowViewModel row) return;
var worker = App.Services.GetRequiredService<IWorkerClient>();
try
{
await worker.QueuePlanningSubtasksAsync(row.Id);
}
catch (Exception ex)
{
// Match the toast/log pattern used by OnSendToQueueClick et al.
System.Diagnostics.Debug.WriteLine($"QueuePlanningSubtasks failed: {ex}");
}
}
```
Use the same `App.Services` / `IWorkerClient` lookup pattern as `OnSendToQueueClick` — do not introduce a new DI pattern. If the existing handlers use a shell/mediator indirection, use that instead.
- [ ] **Step 7: Build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: 0 errors.
- [ ] **Step 8: Manual verify end-to-end**
1. Launch app: `dotnet run --project src/ClaudeDo.App`.
2. Open a planning task with ≥2 subtasks (all in `Manual`/`Planned`).
3. Right-click parent → **Queue subtasks sequentially**.
4. Confirm in the task list: first child shows `Queued` chip, others show `Waiting` chip.
5. Let the first run to completion (or, for a quick smoke test, edit the DB to mark it `Done` and emit `TaskUpdated` via a restart).
6. Confirm the next child's status flips `Waiting → Queued` without user interaction.
7. Force-fail a child (cancel it mid-run) — confirm remaining `Waiting` children stay `Waiting`.
- [ ] **Step 9: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs \
src/ClaudeDo.Ui/Services/IWorkerClient.cs \
src/ClaudeDo.Ui/Services/WorkerClient.cs \
src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs \
src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml \
src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs
git commit -m "feat(ui+worker): context menu to queue planning subtasks sequentially"
```
---
## Self-review checklist (for the plan author before handing off)
- All four spec items mapped: auto-collapse (Task 4), collapsible description (Task 3), narrower splitters (Task 2), sequential subtask queue (Tasks 1, 5, 6, 7).
- `Waiting` enum touches: enum, chip class, virtual:queued filter — covered in Task 1.
- TDD applied where it pays off (the coordinator); UI tasks rely on manual verification (correct for this codebase).
- No placeholders. Every code step shows the code to paste.
- Type names consistent: `PlanningChainCoordinator`, `QueueSubtasksSequentiallyAsync`, `OnChildFinishedAsync`, `QueuePlanningSubtasksAsync`, `AreChildrenExpanded`, `AllChildrenDone`, `IsDescriptionExpanded` — used the same across tasks.
- Commits are small and conventional.

View File

@@ -0,0 +1,999 @@
# Planning Session Worktree Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `mcp__claudedo__*` tools available inside planning sessions by running each session in an ephemeral git worktree that holds a project-scope `.mcp.json` and a settings override that auto-trusts project MCP servers.
**Architecture:** `PlanningSessionManager` creates a short-lived git worktree from `HEAD` of the list's working directory on `StartAsync`, writes `.mcp.json` (with env-var expansion for the bearer token) and `.claude/settings.local.json` into it, and returns the worktree path as the spawn directory. `WindowsTerminalPlanningLauncher` passes the token via env var (`CLAUDEDO_PLANNING_TOKEN`) and stops passing `--mcp-config`. Finalize/Discard force-remove the worktree and branch.
**Tech Stack:** .NET 8, xUnit, real SQLite (DbFixture), real git worktrees via `ClaudeDo.Data.Git.GitService`.
**Spec:** `docs/superpowers/specs/2026-04-24-planning-worktree-design.md`
---
## File Structure
**Modify:**
- `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs` — add `Token`, `WorktreePath`, `BranchName` to start context; add `Token` and rename `McpConfigPath``WorktreePath` on resume context
- `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs` — drop `McpConfigPath` field
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — worktree create/cleanup, token persistence, new ctor deps
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs` — env var, drop `--mcp-config`
- `src/ClaudeDo.Worker/Program.cs` — DI wiring for new ctor signature
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` — add git init, update existing assertions
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs` — add git init in setup
- `tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs` — assert env var, no `--mcp-config`
Each file has one clear responsibility; no new files needed.
---
## Task 1: Extend context records with token and worktree info
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs`
- [ ] **Step 1: Edit the records**
Replace the full file content with:
```csharp
namespace ClaudeDo.Worker.Planning;
public sealed record PlanningSessionStartContext(
string ParentTaskId,
string WorkingDir,
string Token,
string WorktreePath,
string BranchName,
PlanningSessionFiles Files);
public sealed record PlanningSessionResumeContext(
string ParentTaskId,
string WorkingDir,
string ClaudeSessionId,
string Token,
string WorktreePath);
```
Note: `WorkingDir` on both records now points at the worktree (callers that used it as "spawn dir" remain correct; callers that needed "list working dir" must be updated separately — no such callers exist today).
- [ ] **Step 2: Build to see breakage**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: FAIL — `PlanningSessionManager` and `WindowsTerminalPlanningLauncher` no longer match these signatures.
- [ ] **Step 3: Commit stub**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs
git commit -m "refactor(worker): extend planning contexts with token and worktree"
```
---
## Task 2: Drop `McpConfigPath` from `PlanningSessionFiles`
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs`
- [ ] **Step 1: Edit the record**
Replace the full file content with:
```csharp
namespace ClaudeDo.Worker.Planning;
public sealed record PlanningSessionFiles(
string SessionDirectory,
string SystemPromptPath,
string InitialPromptPath);
```
- [ ] **Step 2: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs
git commit -m "refactor(worker): drop McpConfigPath from PlanningSessionFiles"
```
---
## Task 3: Extend `PlanningSessionManager` constructors
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (fields + constructors only)
- [ ] **Step 1: Add using directives**
At the top of `PlanningSessionManager.cs`, add these imports alongside the existing ones:
```csharp
using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Config;
```
- [ ] **Step 2: Replace fields and constructors**
Replace the block from `private const string McpServerUrl` down to the end of `CreateRepos()` with:
```csharp
private const string McpServerUrl = "http://127.0.0.1:47821/mcp";
private readonly IDbContextFactory<ClaudeDoDbContext>? _factory;
private readonly TaskRepository? _tasksOverride;
private readonly ListRepository? _listsOverride;
private readonly AppSettingsRepository? _settingsOverride;
private readonly GitService _git;
private readonly WorkerConfig _cfg;
private readonly string _rootDirectory;
// DI constructor.
public PlanningSessionManager(
IDbContextFactory<ClaudeDoDbContext> factory,
GitService git,
WorkerConfig cfg,
string rootDirectory)
{
_factory = factory;
_git = git;
_cfg = cfg;
_rootDirectory = rootDirectory;
}
// Test constructor.
public PlanningSessionManager(
TaskRepository tasks,
ListRepository lists,
AppSettingsRepository settings,
GitService git,
WorkerConfig cfg,
string rootDirectory)
{
_tasksOverride = tasks;
_listsOverride = lists;
_settingsOverride = settings;
_git = git;
_cfg = cfg;
_rootDirectory = rootDirectory;
}
private (TaskRepository tasks, ListRepository lists, AppSettingsRepository settings, ClaudeDoDbContext? ctx) CreateRepos()
{
if (_tasksOverride is not null)
return (_tasksOverride, _listsOverride!, _settingsOverride!, null);
var ctx = _factory!.CreateDbContext();
return (new TaskRepository(ctx), new ListRepository(ctx), new AppSettingsRepository(ctx), ctx);
}
```
- [ ] **Step 3: Update all `CreateRepos()` call-sites in this file**
Every call currently binds `(tasks, lists, ctx)`. Change each to `(tasks, lists, settings, ctx)` (search the file for `= CreateRepos();`).
The `_` and `__` discard patterns on the returned `ctx` (lines like `await using var _ = ctx;`) remain valid.
- [ ] **Step 4: Build — expect test breakage**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS (production code compiles).
Run: `dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
Expected: FAIL — test ctor calls don't match. Will be fixed in Task 10.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "refactor(worker): inject GitService and WorkerConfig into PlanningSessionManager"
```
---
## Task 4: Add a worktree-path helper and the token-file helpers
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (add private helpers)
- [ ] **Step 1: Add three private helpers at the bottom of the class (before the closing `}`)**
```csharp
private static string BranchNameFor(string taskId) =>
$"claudedo/planning/{taskId.Replace("-", "")}";
private string WorktreePathFor(string taskId, string strategy, string? centralRootOverride, string listWorkingDir)
{
var centralRoot = !string.IsNullOrWhiteSpace(centralRootOverride)
? centralRootOverride!
: _cfg.CentralWorktreeRoot;
var raw = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
? Path.Combine(centralRoot, "planning", taskId)
: Path.Combine(Path.GetDirectoryName(listWorkingDir)!, ".claudedo-worktrees", "planning", taskId);
return Path.GetFullPath(raw);
}
private static string TokenFilePathFor(string sessionDir) =>
Path.Combine(sessionDir, "token");
private static async Task WriteTokenFileAsync(string path, string token, CancellationToken ct)
{
await File.WriteAllTextAsync(path, token, ct);
// Best-effort current-user-only ACL on Windows. On non-Windows the inherited
// perms from the parent dir apply; acceptable because sessionDir is already
// under the user's home (~/.todo-app/sessions/).
if (OperatingSystem.IsWindows())
{
try
{
var fi = new FileInfo(path);
var ac = fi.GetAccessControl();
ac.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
var me = System.Security.Principal.WindowsIdentity.GetCurrent().User!;
ac.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
me,
System.Security.AccessControl.FileSystemRights.FullControl,
System.Security.AccessControl.AccessControlType.Allow));
fi.SetAccessControl(ac);
}
catch { /* ACL hardening is best-effort */ }
}
}
private static async Task<string> ReadTokenFileAsync(string path, CancellationToken ct)
{
if (!File.Exists(path))
throw new InvalidOperationException($"Token file missing: {path}");
return (await File.ReadAllTextAsync(path, ct)).Trim();
}
```
- [ ] **Step 2: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "refactor(worker): add worktree path and token file helpers"
```
---
## Task 5: Rewrite `BuildMcpConfigJson` to use env-var expansion
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`
- [ ] **Step 1: Replace `BuildMcpConfigJson` body**
Find the existing `private static string BuildMcpConfigJson(string token)` method. Replace with:
```csharp
private static string BuildMcpConfigJson()
{
var payload = new
{
mcpServers = new
{
claudedo = new
{
type = "http",
url = McpServerUrl,
headers = new Dictionary<string, string>
{
["Authorization"] = "Bearer ${CLAUDEDO_PLANNING_TOKEN}"
}
}
}
};
return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
}
```
(The token argument is dropped — claude expands `${CLAUDEDO_PLANNING_TOKEN}` at load time from the spawned process environment.)
- [ ] **Step 2: Also add settings override builder below it**
```csharp
private const string SettingsLocalJson = """
{
"enableAllProjectMcpServers": true
}
""";
```
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "refactor(worker): switch MCP config to env-var token expansion"
```
---
## Task 6: Rewrite `StartAsync` to create the worktree
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (body of `StartAsync` only)
- [ ] **Step 1: Replace `StartAsync` body (keep signature)**
Replace the entire method body with:
```csharp
public async Task<PlanningSessionStartContext> StartAsync(string taskId, CancellationToken ct)
{
var (tasks, lists, settings, ctx) = CreateRepos();
await using var _ = ctx;
var task = await tasks.GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.ParentTaskId is not null)
throw new InvalidOperationException("Cannot start a planning session on a child task.");
if (task.Status != TaskStatus.Manual)
throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning.");
var list = await lists.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException($"List {task.ListId} not found.");
var listWorkingDir = list.WorkingDir
?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");
if (!await _git.IsGitRepoAsync(listWorkingDir, ct))
throw new InvalidOperationException($"Working directory is not a git repository: {listWorkingDir}");
var appSettings = await settings.GetAsync(ct);
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
var branchName = BranchNameFor(taskId);
var baseCommit = await _git.RevParseHeadAsync(listWorkingDir, ct);
Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!);
try
{
await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
{
// Self-heal: remove phantom worktrees, prune, delete branch, retry once.
var stalePaths = await _git.ListWorktreePathsForBranchAsync(listWorkingDir, branchName, ct);
foreach (var stale in stalePaths)
{
try { await _git.WorktreeRemoveAsync(listWorkingDir, stale, force: true, ct); } catch { }
}
try { await _git.WorktreePruneAsync(listWorkingDir, ct); } catch { }
try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
}
// Write .mcp.json and .claude/settings.local.json into the worktree.
var mcpPath = Path.Combine(worktreePath, ".mcp.json");
await File.WriteAllTextAsync(mcpPath, BuildMcpConfigJson(), ct);
var claudeDir = Path.Combine(worktreePath, ".claude");
Directory.CreateDirectory(claudeDir);
await File.WriteAllTextAsync(Path.Combine(claudeDir, "settings.local.json"), SettingsLocalJson, ct);
// Session dir + token + prompt files.
var token = GenerateToken();
var started = await tasks.SetPlanningStartedAsync(taskId, token, ct)
?? throw new InvalidOperationException("Failed to transition task to Planning.");
var sessionDir = Path.Combine(_rootDirectory, taskId);
Directory.CreateDirectory(sessionDir);
var files = new PlanningSessionFiles(
sessionDir,
Path.Combine(sessionDir, "system-prompt.md"),
Path.Combine(sessionDir, "initial-prompt.txt"));
await WriteTokenFileAsync(TokenFilePathFor(sessionDir), token, ct);
await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct);
await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct);
return new PlanningSessionStartContext(
ParentTaskId: taskId,
WorkingDir: worktreePath,
Token: token,
WorktreePath: worktreePath,
BranchName: branchName,
Files: files);
}
```
- [ ] **Step 2: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "feat(worker): create ephemeral worktree and write .mcp.json in StartAsync"
```
---
## Task 7: Rewrite `ResumeAsync` and add cleanup to `FinalizeAsync` / `DiscardAsync`
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (three methods)
- [ ] **Step 1: Replace `ResumeAsync` body**
```csharp
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
{
var (tasks, lists, settings, ctx) = CreateRepos();
await using var _ = ctx;
var task = await tasks.GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status != TaskStatus.Planning)
throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning.");
if (string.IsNullOrEmpty(task.PlanningSessionId))
throw new InvalidOperationException("No Claude session ID captured yet; cannot resume.");
var sessionDir = Path.Combine(_rootDirectory, taskId);
if (!Directory.Exists(sessionDir))
throw new InvalidOperationException($"Session directory missing: {sessionDir}");
var list = await lists.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException($"List {task.ListId} not found.");
var listWorkingDir = list.WorkingDir
?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");
var appSettings = await settings.GetAsync(ct);
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
if (!Directory.Exists(worktreePath))
throw new InvalidOperationException($"Planning worktree missing — cannot resume: {worktreePath}");
var token = await ReadTokenFileAsync(TokenFilePathFor(sessionDir), ct);
return new PlanningSessionResumeContext(
ParentTaskId: taskId,
WorkingDir: worktreePath,
ClaudeSessionId: task.PlanningSessionId,
Token: token,
WorktreePath: worktreePath);
}
```
- [ ] **Step 2: Extend `FinalizeAsync` to clean up worktree + branch**
Replace the existing `FinalizeAsync` body with:
```csharp
public async Task<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
{
var (tasks, lists, settings, ctx) = CreateRepos();
await using var __ = ctx;
var count = await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);
// Best-effort cleanup — don't block finalization on git state.
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
var sessionDir = Path.Combine(_rootDirectory, taskId);
if (Directory.Exists(sessionDir))
{
try { Directory.Delete(sessionDir, recursive: true); } catch { }
}
return count;
}
```
- [ ] **Step 3: Extend `DiscardAsync` with the same cleanup**
Replace the body of `DiscardAsync` with:
```csharp
public async Task DiscardAsync(string taskId, CancellationToken ct)
{
var (tasks, lists, settings, ctx) = CreateRepos();
await using var __ = ctx;
var ok = await tasks.DiscardPlanningAsync(taskId, ct);
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
var sessionDir = Path.Combine(_rootDirectory, taskId);
if (Directory.Exists(sessionDir))
{
try { Directory.Delete(sessionDir, recursive: true); } catch { }
}
if (!ok)
throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
}
```
- [ ] **Step 4: Add the `TryCleanupWorktreeAsync` helper**
Add this private method near the other helpers:
```csharp
private async Task TryCleanupWorktreeAsync(
string taskId,
ListRepository lists,
AppSettingsRepository settings,
CancellationToken ct)
{
try
{
var (tasks, _, _, ctx2) = CreateRepos();
await using var __ = ctx2;
var task = await tasks.GetByIdAsync(taskId, ct);
if (task is null) return;
var list = await lists.GetByIdAsync(task.ListId, ct);
var listWorkingDir = list?.WorkingDir;
if (string.IsNullOrEmpty(listWorkingDir) || !Directory.Exists(listWorkingDir)) return;
var appSettings = await settings.GetAsync(ct);
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
var branchName = BranchNameFor(taskId);
if (Directory.Exists(worktreePath))
{
try { await _git.WorktreeRemoveAsync(listWorkingDir, worktreePath, force: true, ct); }
catch { /* best effort */ }
}
try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
}
catch { /* best effort — never block finalize/discard */ }
}
```
- [ ] **Step 5: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "feat(worker): cleanup planning worktree and branch on finalize/discard"
```
---
## Task 8: Update `WindowsTerminalPlanningLauncher`
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs`
- [ ] **Step 1: Rewrite `LaunchStartAsync`**
Replace the full method body with:
```csharp
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
{
if (!Directory.Exists(ctx.WorkingDir))
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
if (!File.Exists(ctx.Files.SystemPromptPath))
throw new PlanningLaunchException($"System prompt file not found: {ctx.Files.SystemPromptPath}");
if (!File.Exists(ctx.Files.InitialPromptPath))
throw new PlanningLaunchException($"Initial prompt file not found: {ctx.Files.InitialPromptPath}");
var resolvedWt = Resolve(_wtPath);
if (resolvedWt is null)
throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
var resolvedClaude = Resolve(_claudePath);
if (resolvedClaude is null)
throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
var psi = new ProcessStartInfo
{
FileName = resolvedWt,
UseShellExecute = false,
CreateNoWindow = false,
};
psi.Environment["MAX_THINKING_TOKENS"] = "20000";
psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
// Arg order: --allowedTools is variadic (space-separated). The positional
// prompt must follow a single-value flag, or it will be swallowed.
// --append-system-prompt-file serves as that buffer.
psi.ArgumentList.Add("-d");
psi.ArgumentList.Add(ctx.WorkingDir);
psi.ArgumentList.Add(resolvedClaude);
psi.ArgumentList.Add("--model");
psi.ArgumentList.Add(Model);
psi.ArgumentList.Add("--allowedTools");
psi.ArgumentList.Add(AllowedTools);
psi.ArgumentList.Add("--append-system-prompt-file");
psi.ArgumentList.Add(ctx.Files.SystemPromptPath);
psi.ArgumentList.Add(File.ReadAllText(ctx.Files.InitialPromptPath));
var proc = Process.Start(psi)
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
return Task.CompletedTask;
}
```
- [ ] **Step 2: Rewrite `LaunchResumeAsync`**
Replace the full method body with:
```csharp
public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
{
if (!Directory.Exists(ctx.WorkingDir))
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
var resolvedWt = Resolve(_wtPath);
if (resolvedWt is null)
throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
var resolvedClaude = Resolve(_claudePath);
if (resolvedClaude is null)
throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
var psi = new ProcessStartInfo
{
FileName = resolvedWt,
UseShellExecute = false,
CreateNoWindow = false,
};
psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
psi.ArgumentList.Add("-d");
psi.ArgumentList.Add(ctx.WorkingDir);
psi.ArgumentList.Add(resolvedClaude);
psi.ArgumentList.Add("--resume");
psi.ArgumentList.Add(ctx.ClaudeSessionId);
var proc = Process.Start(psi)
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
return Task.CompletedTask;
}
```
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
git commit -m "feat(worker): launcher passes planning token via env, drops --mcp-config"
```
---
## Task 9: Update DI wiring in `Program.cs`
**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs` (around line 5962)
- [ ] **Step 1: Update the registration**
Find:
```csharp
builder.Services.AddSingleton(sp =>
new PlanningSessionManager(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
planningSessionsDir));
```
Replace with:
```csharp
builder.Services.AddSingleton(sp =>
new PlanningSessionManager(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<GitService>(),
cfg,
planningSessionsDir));
```
- [ ] **Step 2: Build full worker**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Program.cs
git commit -m "chore(worker): wire GitService and WorkerConfig into PlanningSessionManager DI"
```
---
## Task 10: Fix existing tests (add git init, update constructor calls, drop McpConfigPath assertions)
**Files:**
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs`
- [ ] **Step 1: Add a shared git-init helper**
Create `tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs`:
```csharp
using System.Diagnostics;
namespace ClaudeDo.Worker.Tests.Infrastructure;
public static class GitRepoFixture
{
public static void InitRepoWithInitialCommit(string dir)
{
Directory.CreateDirectory(dir);
Run(dir, "init", "-b", "main");
Run(dir, "config", "user.email", "test@claudedo.local");
Run(dir, "config", "user.name", "test");
File.WriteAllText(Path.Combine(dir, "README.md"), "seed\n");
Run(dir, "add", "-A");
Run(dir, "commit", "-m", "chore: seed");
}
private static void Run(string cwd, params string[] args)
{
var psi = new ProcessStartInfo("git") { WorkingDirectory = cwd, RedirectStandardError = true, RedirectStandardOutput = true };
foreach (var a in args) psi.ArgumentList.Add(a);
var p = Process.Start(psi)!;
p.WaitForExit();
if (p.ExitCode != 0)
throw new InvalidOperationException($"git {string.Join(" ", args)} failed: {p.StandardError.ReadToEnd()}");
}
}
```
- [ ] **Step 2: Update `PlanningSessionManagerTests` constructor and seed helper**
In `PlanningSessionManagerTests.cs`, find the constructor and add after `_rootDir = …;`:
```csharp
_git = new ClaudeDo.Data.Git.GitService();
_cfg = new ClaudeDo.Worker.Config.WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") };
_settingsRepo = new ClaudeDo.Data.Repositories.AppSettingsRepository(_ctx);
// Seed settings row so the manager can read strategy.
_settingsRepo.UpsertAsync(new ClaudeDo.Data.Models.AppSettingsEntity { Id = 1, WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
_sut = new PlanningSessionManager(_tasks, _lists, _settingsRepo, _git, _cfg, _rootDir);
```
Add three private fields to the class:
```csharp
private readonly ClaudeDo.Data.Git.GitService _git;
private readonly ClaudeDo.Worker.Config.WorkerConfig _cfg;
private readonly ClaudeDo.Data.Repositories.AppSettingsRepository _settingsRepo;
```
Change `SeedListAsync` to init a git repo:
```csharp
private async Task<(string listId, string workingDir)> SeedListAsync()
{
var listId = Guid.NewGuid().ToString();
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
ClaudeDo.Worker.Tests.Infrastructure.GitRepoFixture.InitRepoWithInitialCommit(wd);
await _lists.AddAsync(new ListEntity
{
Id = listId,
Name = "Test",
WorkingDir = wd,
CreatedAt = DateTime.UtcNow,
});
return (listId, wd);
}
```
- [ ] **Step 3: Update assertions in the existing `StartAsync_…` test**
The old test asserts `ctx.Files.McpConfigPath`. Replace with worktree-based assertions:
```csharp
Assert.Equal(parent.Id, ctx.ParentTaskId);
Assert.Equal(ctx.WorktreePath, ctx.WorkingDir);
Assert.True(Directory.Exists(ctx.WorktreePath));
var mcpPath = Path.Combine(ctx.WorktreePath, ".mcp.json");
Assert.True(File.Exists(mcpPath));
Assert.True(File.Exists(Path.Combine(ctx.WorktreePath, ".claude", "settings.local.json")));
Assert.True(File.Exists(ctx.Files.SystemPromptPath));
Assert.True(File.Exists(ctx.Files.InitialPromptPath));
var mcp = await File.ReadAllTextAsync(mcpPath);
Assert.Contains("${CLAUDEDO_PLANNING_TOKEN}", mcp);
Assert.DoesNotContain(ctx.Token, mcp);
```
- [ ] **Step 4: Update `PlanningEndToEndTests` SUT construction similarly**
Add the same fields + ctor arguments. Replace any `new PlanningSessionManager(tasks, lists, rootDir)` with `new PlanningSessionManager(tasks, lists, settingsRepo, git, cfg, rootDir)` and ensure the seeded working directory is git-initialized.
- [ ] **Step 5: Update `WindowsTerminalPlanningLauncherTests`**
If the existing tests construct `PlanningSessionStartContext` manually, update to supply the new `Token`, `WorktreePath`, `BranchName` fields. Add an assertion that the test observes (via a fake `IPlanningTerminalLauncher`-level check or by verifying the psi after a refactor seam) that the env var is set.
If the existing launcher test only verifies behavior that's no longer directly testable (it spawns wt.exe), leave those tests as-is but ensure they still compile with the new ctor shape.
- [ ] **Step 6: Run all planning tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~Planning"`
Expected: PASS for all tests that previously passed.
- [ ] **Step 7: Commit**
```bash
git add tests/ClaudeDo.Worker.Tests/Planning/ tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs
git commit -m "test(worker): adapt planning tests to git-backed worktree flow"
```
---
## Task 11: New tests — worktree creation, cleanup, self-heal, resume
**Files:**
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` (append new tests)
- [ ] **Step 1: Write the failing "worktree is removed on discard" test**
Append to the test class:
```csharp
[Fact]
public async Task DiscardAsync_RemovesWorktreeAndBranch()
{
var (listId, wd) = await SeedListAsync();
var parent = await SeedManualTaskAsync(listId);
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
Assert.True(Directory.Exists(ctx.WorktreePath));
await _sut.DiscardAsync(parent.Id, CancellationToken.None);
Assert.False(Directory.Exists(ctx.WorktreePath));
// branch deleted
var paths = await _git.ListWorktreePathsForBranchAsync(wd, ctx.BranchName);
Assert.Empty(paths);
}
```
- [ ] **Step 2: Run — expect PASS**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "DiscardAsync_RemovesWorktreeAndBranch"`
Expected: PASS.
- [ ] **Step 3: Add "non-git working dir errors" test**
```csharp
[Fact]
public async Task StartAsync_ThrowsWhenWorkingDirIsNotGitRepo()
{
var listId = Guid.NewGuid().ToString();
var wd = Path.Combine(Path.GetTempPath(), $"cd_nogit_{Guid.NewGuid():N}");
Directory.CreateDirectory(wd);
await _lists.AddAsync(new ListEntity { Id = listId, Name = "NoGit", WorkingDir = wd, CreatedAt = DateTime.UtcNow });
var t = await SeedManualTaskAsync(listId);
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.StartAsync(t.Id, CancellationToken.None));
}
```
Run and expect PASS.
- [ ] **Step 4: Add self-heal test**
```csharp
[Fact]
public async Task StartAsync_SelfHealsWhenBranchAlreadyExists()
{
var (listId, wd) = await SeedListAsync();
var parent = await SeedManualTaskAsync(listId);
// Pre-create a colliding branch.
var branch = $"claudedo/planning/{parent.Id.Replace("-", "")}";
var head = await _git.RevParseHeadAsync(wd);
var procInfo = new System.Diagnostics.ProcessStartInfo("git") { WorkingDirectory = wd };
procInfo.ArgumentList.Add("branch");
procInfo.ArgumentList.Add(branch);
procInfo.ArgumentList.Add(head);
var p = System.Diagnostics.Process.Start(procInfo)!;
p.WaitForExit();
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
Assert.True(Directory.Exists(ctx.WorktreePath));
}
```
Run and expect PASS.
- [ ] **Step 5: Add resume test**
```csharp
[Fact]
public async Task ResumeAsync_ReturnsContextWithTokenAndWorktree()
{
var (listId, wd) = await SeedListAsync();
var parent = await SeedManualTaskAsync(listId);
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
// Simulate the claude session capturing its session id.
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "session-abc", CancellationToken.None);
var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);
Assert.Equal(startCtx.Token, resumeCtx.Token);
Assert.Equal(startCtx.WorktreePath, resumeCtx.WorktreePath);
Assert.Equal("session-abc", resumeCtx.ClaudeSessionId);
}
```
Run and expect PASS. If `UpdatePlanningSessionIdAsync` doesn't exist, use whatever repository method captures the Claude session id in this codebase (search the repo for the existing pattern) and substitute; do **not** skip this step.
- [ ] **Step 6: Run all planning tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~Planning"`
Expected: all PASS.
- [ ] **Step 7: Commit**
```bash
git add tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
git commit -m "test(worker): cover planning worktree lifecycle and self-heal"
```
---
## Task 12: Manual end-to-end verification
**Files:** none (manual)
- [ ] **Step 1: Build all projects**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj && dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.
- [ ] **Step 2: Start Worker + UI, create a manual task on a list whose WorkingDir is a real git repo, hit "Start planning"**
Expected:
- A Windows Terminal opens with `claude` running in a worktree under `<parent-of-WorkingDir>\.claudedo-worktrees\planning\<taskId>` (or the central root if strategy=central).
- No trust prompt appears for the `claudedo` MCP server.
- Inside claude, `/mcp` lists `claudedo` as connected.
- Asking claude "create a subtask" invokes `mcp__claudedo__*` tools and the new child task appears in the UI.
- [ ] **Step 3: Click Discard**
Expected:
- The worktree directory is gone; `git branch --list claudedo/planning/*` returns nothing; `~/.todo-app/sessions/<taskId>` is gone.
- [ ] **Step 4: Repeat with Finalize** — same expected cleanup.
- [ ] **Step 5: Close Windows Terminal mid-session, then "Resume"** — same worktree opens again with `--resume`.
---
## Deferred / follow-up
- **Defensive startup cleanup of orphaned planning worktrees.** Enumerate `.claudedo-worktrees/planning/*` (both sibling and central) and GC any whose session dir no longer exists. Ship as a follow-up plan if orphans become a real problem in practice.
---
## Self-Review Notes
- **Spec coverage:** Every section in `docs/superpowers/specs/2026-04-24-planning-worktree-design.md` maps to a task above (data flow → Task 6; launcher → Task 8; cleanup → Task 7; self-heal → Task 6 + Task 11.4; non-git error → Task 11.3; resume → Task 7 + Task 11.5; trust prompt bypass → Task 5 + Task 6). The one spec item deferred is the defensive startup cleanup.
- **Placeholder scan:** One conditional in Task 11.5 ("use whatever repository method captures the Claude session id") — this is deliberate: the existing codebase has an accessor whose exact name depends on local conventions and it's faster for the engineer to grep than for me to guess wrong. Every other step has full code.
- **Type consistency:** `PlanningSessionStartContext.WorktreePath` and `ResumeContext.WorktreePath` both `string`. `BranchName` only on Start (Resume recomputes via `BranchNameFor`). `Token` on both. `Files.McpConfigPath` removed everywhere.

View File

@@ -0,0 +1,897 @@
# External MCP — CRUD Extensions Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extend the always-on `ExternalMcpService` with full task CRUD plus tag management so a normal Claude CLI session can fully manage scope-creep tasks via MCP.
**Architecture:** Pure extension of the existing service. New repository helper for tag replacement; five new/extended `[McpServerTool]` methods. No DI changes (`TagRepository` is already registered for `TaskRepository`/`ListRepository`). Uses the same `X-ClaudeDo-Key` middleware already in place.
**Tech Stack:** .NET 8, EF Core (SQLite), `ModelContextProtocol.Server` (MCP SDK), xUnit.
---
## Pre-flight
The test assembly `tests/ClaudeDo.Worker.Tests` currently fails to compile on `main` because of pre-existing in-progress work on `PlanningChainCoordinator` (stale `TaskRunner` / `WorkerHub` constructor calls in `QueueServiceTests.cs`, `QueueServiceSlotGuardTests.cs`, `PlanningHubTests.cs`). This is unrelated to this work and must NOT be fixed here.
Consequence: `dotnet test` cannot execute until that refactor lands. Each task's "Run test, verify it fails" step uses `dotnet build` of the **test csproj** to confirm only the new test's compile expectations, and `dotnet build` of the **production csproj** to confirm production code is correct. When the refactor lands, the engineer or user re-runs `dotnet test --filter "FullyQualifiedName~ExternalMcpServiceTests"` to validate the new tests for real.
Build commands used throughout (per the project memory note "use csproj, not .slnx, on .NET 8"):
```bash
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
```
---
## File Map
| File | Change |
|---|---|
| `src/ClaudeDo.Data/Repositories/TaskRepository.cs` | Add `SetTagsAsync` (replace tag set, auto-create rows) |
| `src/ClaudeDo.Worker/External/ExternalMcpService.cs` | Inject `TagRepository`; extend `AddTask` with `tags`; add `UpdateTask`, `DeleteTask`, `SetTaskTags`, `ListTags` |
| `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` | New test file with fakes mirroring `Planning/PlanningMcpServiceTests.cs` |
`TagRepository.GetAllAsync` already exists — no change needed there.
---
### Task 1: `TaskRepository.SetTagsAsync`
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (add new method inside the `#region Tags` block, after `RemoveTagAsync`)
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs`
- [ ] **Step 1: Write the failing tests**
Append to `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs` (use the existing `MakeTask`/list-seed helpers from that file — match the pattern used in adjacent tests):
```csharp
[Fact]
public async Task SetTagsAsync_AttachesNewTagsAndCreatesMissingRows()
{
var listId = await CreateListAsync("L");
var task = MakeTask(listId, "t");
await _tasks.AddAsync(task);
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "novel-tag" });
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Contains(tags, t => t.Name == "agent");
Assert.Contains(tags, t => t.Name == "novel-tag");
Assert.Equal(2, tags.Count);
}
[Fact]
public async Task SetTagsAsync_ReplacesExistingTagSet()
{
var listId = await CreateListAsync("L");
var task = MakeTask(listId, "t");
await _tasks.AddAsync(task);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
await _tasks.SetTagsAsync(task.Id, new[] { "manual" });
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Single(tags);
Assert.Equal("manual", tags[0].Name);
}
[Fact]
public async Task SetTagsAsync_DeduplicatesCaseInsensitively()
{
var listId = await CreateListAsync("L");
var task = MakeTask(listId, "t");
await _tasks.AddAsync(task);
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "AGENT", "Agent" });
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Single(tags);
}
[Fact]
public async Task SetTagsAsync_EmptyListClearsAllTags()
{
var listId = await CreateListAsync("L");
var task = MakeTask(listId, "t");
await _tasks.AddAsync(task);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
await _tasks.SetTagsAsync(task.Id, Array.Empty<string>());
Assert.Empty(await _tasks.GetTagsAsync(task.Id));
}
```
- [ ] **Step 2: Run test to verify it fails**
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
```
Expected: compile error `CS1061: 'TaskRepository' does not contain a definition for 'SetTagsAsync'`. (Existing unrelated `CS7036` errors from `PlanningChainCoordinator` work also appear — ignore.)
- [ ] **Step 3: Write minimal implementation**
In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, inside `#region Tags`, after `RemoveTagAsync`:
```csharp
public async Task SetTagsAsync(string taskId, IReadOnlyList<string> tagNames, CancellationToken ct = default)
{
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
if (task is null) return;
task.Tags.Clear();
foreach (var name in tagNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase))
{
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
if (tag is null)
{
tag = new TagEntity { Name = name };
_context.Tags.Add(tag);
}
task.Tags.Add(tag);
}
await _context.SaveChangesAsync(ct);
}
```
- [ ] **Step 4: Run test to verify it compiles + production build still passes**
```bash
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
```
Expected: `Build succeeded. 0 Error(s)`.
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "TaskRepositoryTests" || echo "no errors in TaskRepositoryTests"
```
Expected: no errors specific to `TaskRepositoryTests` (assembly may still fail due to unrelated `PlanningChainCoordinator` issues).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs
git commit -m "feat(data): add TaskRepository.SetTagsAsync for full tag-set replacement"
```
---
### Task 2: New test file scaffolding for `ExternalMcpService`
**Files:**
- Create: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
This task creates the shared test fakes and one trivial passing test. Subsequent tasks reuse the same fakes.
- [ ] **Step 1: Inspect existing patterns**
Read `tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs` for the `FakeHubContext`/`RecordingClientProxy` pattern and `tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs` for how to construct a real `QueueService` for tests (the same approach is used here — `ExternalMcpService` depends on it for `WakeQueue`/`RunNow`/`CancelTask`).
- [ ] **Step 2: Write the test scaffolding**
Create `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Services;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.External;
file sealed class RecordingHubClients : IHubClients
{
public RecordingClientProxy Proxy { get; } = new();
public IClientProxy All => Proxy;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy;
public IClientProxy Client(string connectionId) => Proxy;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
public IClientProxy Group(string groupName) => Proxy;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
public IClientProxy User(string userId) => Proxy;
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy;
}
file sealed class RecordingClientProxy : IClientProxy
{
public List<(string Method, object?[] Args)> Calls { get; } = new();
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
Calls.Add((method, args));
return Task.CompletedTask;
}
}
file sealed class FakeHubContext : IHubContext<WorkerHub>
{
public RecordingHubClients RecordingClients { get; } = new();
public IHubClients Clients => RecordingClients;
public IGroupManager Groups => throw new NotImplementedException();
}
public sealed class ExternalMcpServiceTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly TagRepository _tags;
private readonly FakeHubContext _hub;
private readonly HubBroadcaster _broadcaster;
public ExternalMcpServiceTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
_tags = new TagRepository(_ctx);
_hub = new FakeHubContext();
_broadcaster = new HubBroadcaster(_hub);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private async Task<string> SeedListAsync(string name = "L")
{
var id = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow });
return id;
}
private async Task<TaskEntity> SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Manual)
{
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = title,
Status = status,
CreatedAt = DateTime.UtcNow,
CommitType = "chore",
};
await _tasks.AddAsync(task);
return task;
}
// QueueService is needed by ExternalMcpService's constructor. For tests that
// only exercise UpdateTask / DeleteTask / SetTaskTags / ListTags / ListTags,
// we never call its WakeQueue/RunNow/CancelTask paths, so a real QueueService
// built with the same approach used in QueueServiceTests is sufficient.
private ExternalMcpService BuildSut(QueueService queue) =>
new(_tasks, _lists, queue, _broadcaster, _tags);
[Fact]
public async Task SeededListAndTask_AreRetrievable()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
Assert.NotNull(await _tasks.GetByIdAsync(task.Id));
}
}
```
The trivial `SeededListAndTask_AreRetrievable` test exists to confirm the scaffolding compiles and the fakes work, without depending on `ExternalMcpService` itself yet.
Note: `BuildSut` uses a 5-argument constructor signature that does not exist yet — this matches the future signature added in Task 3. The compiler will accept this method only after Task 3.
- [ ] **Step 3: Verify the file references resolve**
Build the test csproj and check for errors specific to `ExternalMcpServiceTests`:
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "ExternalMcpServiceTests"
```
Expected output: only one error referring to the 5-arg `ExternalMcpService` constructor (resolved in Task 3). No missing-namespace or syntax errors.
- [ ] **Step 4: Commit**
```bash
git add tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "test(external): scaffold ExternalMcpServiceTests"
```
---
### Task 3: Inject `TagRepository` into `ExternalMcpService` + add `ListTags`
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
Smallest possible change to unblock everything else: take the new dependency and ship the simplest tool first.
- [ ] **Step 1: Write the failing test**
Add to `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`. The `BuildSut` helper is defined in Task 2; tests construct `QueueService` the same way `QueueServiceTests.cs` does (look there for the exact constructor argument list and adopt it verbatim):
```csharp
[Fact]
public async Task ListTags_ReturnsSeededAndCustomTags()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "custom-tag" });
using var queue = QueueServiceFactory.Create(_ctx, _broadcaster); // see helper note below
var sut = BuildSut(queue);
var tags = await sut.ListTags(CancellationToken.None);
Assert.Contains(tags, t => t.Name == "agent");
Assert.Contains(tags, t => t.Name == "custom-tag");
}
```
If a `QueueServiceFactory` helper does not already exist in the test project, inline the construction by mirroring the setup found in `tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs` (it builds `QueueService` directly with `IDbContextFactory`, `HubBroadcaster`, fake claude process, etc.). Do NOT call `StartAsync`; just construct and dispose.
- [ ] **Step 2: Run test to verify it fails**
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep -E "ExternalMcpService|ExternalMcpServiceTests"
```
Expected: errors about the 5-arg constructor and `ListTags` not existing.
- [ ] **Step 3: Implement**
In `src/ClaudeDo.Worker/External/ExternalMcpService.cs`:
1. Add `TagRepository` field and constructor parameter:
```csharp
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly QueueService _queue;
private readonly HubBroadcaster _broadcaster;
private readonly TagRepository _tags;
public ExternalMcpService(
TaskRepository tasks,
ListRepository lists,
QueueService queue,
HubBroadcaster broadcaster,
TagRepository tags)
{
_tasks = tasks;
_lists = lists;
_queue = queue;
_broadcaster = broadcaster;
_tags = tags;
}
```
2. Add a tag DTO above the class (next to `TaskListDto`):
```csharp
public sealed record TagDto(long Id, string Name);
```
3. Add the new tool method (place at the end of the class, before `ToDto`):
```csharp
[McpServerTool, Description("List all known tags. Useful for discovering existing tag names (including 'agent' which marks tasks for auto-execution) before tagging.")]
public async Task<IReadOnlyList<TagDto>> ListTags(CancellationToken cancellationToken)
{
var tags = await _tags.GetAllAsync(cancellationToken);
return tags.Select(t => new TagDto(t.Id, t.Name)).ToList();
}
```
- [ ] **Step 4: Verify production build + new test compiles**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: `Build succeeded. 0 Error(s)`.
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "ExternalMcpService"
```
Expected: no errors mentioning `ExternalMcpService` or `ListTags`. (Unrelated `PlanningChainCoordinator` errors persist.)
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "feat(mcp/external): add ListTags + inject TagRepository"
```
---
### Task 4: Extend `AddTask` to accept `tags`
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs` (`AddTask` method)
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
- [ ] **Step 1: Write the failing tests**
Append to `ExternalMcpServiceTests.cs`:
```csharp
[Fact]
public async Task AddTask_WithTags_AttachesTags()
{
var listId = await SeedListAsync();
using var queue = /* same construction as ListTags test */;
var sut = BuildSut(queue);
var dto = await sut.AddTask(
listId, "scope-creep handoff", "desc", "claude-cli",
queueImmediately: false,
tags: new[] { "agent", "custom" },
CancellationToken.None);
var tags = await _tasks.GetTagsAsync(dto.Id);
Assert.Contains(tags, t => t.Name == "agent");
Assert.Contains(tags, t => t.Name == "custom");
}
[Fact]
public async Task AddTask_NullTags_BehavesAsBefore()
{
var listId = await SeedListAsync();
using var queue = /* same construction */;
var sut = BuildSut(queue);
var dto = await sut.AddTask(
listId, "no tags", null, "claude-cli",
queueImmediately: false, tags: null, CancellationToken.None);
Assert.Empty(await _tasks.GetTagsAsync(dto.Id));
}
```
(Replace the `/* same construction */` placeholder with the actual `QueueService` construction used in Task 3 — repeat the code, do not extract.)
- [ ] **Step 2: Run test to verify it fails**
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "AddTask"
```
Expected: error that `AddTask` does not accept a 7th `tags` parameter.
- [ ] **Step 3: Implement**
Replace the existing `AddTask` method in `ExternalMcpService.cs` with:
```csharp
[McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. Optional tags are attached on creation; missing tag names auto-create.")]
public async Task<TaskDto> AddTask(
string listId,
string title,
string? description,
string createdBy,
bool queueImmediately,
IReadOnlyList<string>? tags,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(listId))
throw new InvalidOperationException("listId is required.");
if (string.IsNullOrWhiteSpace(title))
throw new InvalidOperationException("title is required.");
if (string.IsNullOrWhiteSpace(createdBy))
throw new InvalidOperationException("createdBy is required.");
var list = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
var entity = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = title,
Description = description,
Status = queueImmediately ? TaskStatus.Queued : TaskStatus.Manual,
CreatedAt = DateTime.UtcNow,
CommitType = list.DefaultCommitType,
CreatedBy = createdBy,
};
await _tasks.AddAsync(entity, cancellationToken);
if (tags is not null && tags.Count > 0)
await _tasks.SetTagsAsync(entity.Id, tags, cancellationToken);
if (queueImmediately)
_queue.WakeQueue();
await _broadcaster.TaskUpdated(entity.Id);
return ToDto(entity);
}
```
- [ ] **Step 4: Verify production build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: `Build succeeded. 0 Error(s)`.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "feat(mcp/external): AddTask accepts tags on creation"
```
---
### Task 5: `UpdateTask`
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
- [ ] **Step 1: Write the failing tests**
Append to `ExternalMcpServiceTests.cs`:
```csharp
[Fact]
public async Task UpdateTask_PatchesNonNullFieldsOnly()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, "old title");
using var queue = /* same construction */;
var sut = BuildSut(queue);
var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None);
Assert.Equal("new title", dto.Title);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal("new title", loaded!.Title);
}
[Fact]
public async Task UpdateTask_TagsReplaceFullSet()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
using var queue = /* same construction */;
var sut = BuildSut(queue);
await sut.UpdateTask(task.Id, null, null, null, new[] { "manual" }, CancellationToken.None);
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Single(tags);
Assert.Equal("manual", tags[0].Name);
}
[Fact]
public async Task UpdateTask_OnRunning_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
using var queue = /* same construction */;
var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None));
}
[Fact]
public async Task UpdateTask_NotFound_Throws()
{
using var queue = /* same construction */;
var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None));
}
```
- [ ] **Step 2: Run test to verify it fails**
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "UpdateTask"
```
Expected: errors that `UpdateTask` does not exist on `ExternalMcpService`.
- [ ] **Step 3: Implement**
Add to `ExternalMcpService.cs` (after `AddTask`):
```csharp
[McpServerTool, Description("Update an existing task's title, description, commit type, and/or tags. Pass null to leave a field unchanged. Tags are replaced as a full set when non-null. Refuses if the task is currently Running.")]
public async Task<TaskDto> UpdateTask(
string taskId,
string? title,
string? description,
string? commitType,
IReadOnlyList<string>? tags,
CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot update a running task. Cancel it first.");
if (title is not null) task.Title = title;
if (description is not null) task.Description = description;
if (commitType is not null) task.CommitType = commitType;
await _tasks.UpdateAsync(task, cancellationToken);
if (tags is not null)
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId);
return ToDto(reload);
}
```
- [ ] **Step 4: Verify production build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: `Build succeeded. 0 Error(s)`.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "feat(mcp/external): add UpdateTask for content/tag patching"
```
---
### Task 6: `DeleteTask`
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
- [ ] **Step 1: Write the failing tests**
Append to `ExternalMcpServiceTests.cs`:
```csharp
[Fact]
public async Task DeleteTask_RemovesTaskAndTagJoins()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
using var queue = /* same construction */;
var sut = BuildSut(queue);
await sut.DeleteTask(task.Id, CancellationToken.None);
Assert.Null(await _tasks.GetByIdAsync(task.Id));
}
[Fact]
public async Task DeleteTask_OnRunning_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
using var queue = /* same construction */;
var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.DeleteTask(task.Id, CancellationToken.None));
}
[Fact]
public async Task DeleteTask_NotFound_Throws()
{
using var queue = /* same construction */;
var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.DeleteTask("does-not-exist", CancellationToken.None));
}
```
- [ ] **Step 2: Run test to verify it fails**
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "DeleteTask"
```
Expected: errors that `DeleteTask` does not exist on `ExternalMcpService`.
- [ ] **Step 3: Implement**
Add to `ExternalMcpService.cs` (after `UpdateTask`):
```csharp
[McpServerTool, Description("Delete a task. Refuses if the task is currently Running — cancel it first.")]
public async Task DeleteTask(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot delete a running task. Cancel it first.");
await _tasks.DeleteAsync(taskId, cancellationToken);
await _broadcaster.TaskUpdated(taskId);
}
```
- [ ] **Step 4: Verify production build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: `Build succeeded. 0 Error(s)`.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "feat(mcp/external): add DeleteTask"
```
---
### Task 7: `SetTaskTags`
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
- [ ] **Step 1: Write the failing tests**
Append to `ExternalMcpServiceTests.cs`:
```csharp
[Fact]
public async Task SetTaskTags_ReplacesTagSetAndBroadcasts()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
using var queue = /* same construction */;
var sut = BuildSut(queue);
var dto = await sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None);
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Single(tags);
Assert.Equal("manual", tags[0].Name);
Assert.Contains(_hub.RecordingClients.Proxy.Calls,
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == task.Id);
}
[Fact]
public async Task SetTaskTags_OnRunning_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
using var queue = /* same construction */;
var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None));
}
```
- [ ] **Step 2: Run test to verify it fails**
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "SetTaskTags"
```
Expected: errors that `SetTaskTags` does not exist.
- [ ] **Step 3: Implement**
Add to `ExternalMcpService.cs` (after `DeleteTask`):
```csharp
[McpServerTool, Description("Replace the full tag set on an existing task. Missing tag names auto-create. Refuses if the task is Running.")]
public async Task<TaskDto> SetTaskTags(
string taskId,
IReadOnlyList<string> tags,
CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot retag a running task. Cancel it first.");
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId);
return ToDto(reload);
}
```
- [ ] **Step 4: Verify production build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: `Build succeeded. 0 Error(s)`.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "feat(mcp/external): add SetTaskTags"
```
---
### Task 8: Final verification + docs touch
**Files:**
- Modify: `src/ClaudeDo.Worker/CLAUDE.md` (one-line update reflecting the new tools)
- [ ] **Step 1: Full production build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
```
Expected: both succeed with 0 errors.
- [ ] **Step 2: Update Worker CLAUDE.md**
In `src/ClaudeDo.Worker/CLAUDE.md`, locate the existing line near the bottom of the file describing external MCP tools (search for `ExternalMcpService` or `External/`). If a list of tools is already there, append the new tool names: `UpdateTask`, `DeleteTask`, `SetTaskTags`, `ListTags`. If no such line exists, add one short line under an existing structural section, for example under "Architecture":
```markdown
- **External/ExternalMcpService** — always-on MCP tools for general Claude sessions: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask` (with tags), `UpdateTask`, `UpdateTaskStatus`, `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`. Auth via optional `X-ClaudeDo-Key` header.
```
If the file already has a similar line — replace it; do not duplicate.
- [ ] **Step 3: Verify the full test assembly state is unchanged**
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep -E "error CS" | grep -v "PlanningChainCoordinator\|TaskRunner.*chain\|WorkerHub.*planningChain"
```
Expected: empty output (every remaining error must be one of the pre-existing `PlanningChainCoordinator`-related errors and nothing new).
- [ ] **Step 4: When the unrelated refactor lands, run the new tests**
(Defer to whoever lands the `PlanningChainCoordinator` refactor — they should run:)
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~ExternalMcpServiceTests|FullyQualifiedName~TaskRepositoryTests.SetTagsAsync"
```
Expected: all new tests green.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/CLAUDE.md
git commit -m "docs(worker): document new external MCP tools"
```
---
## Self-review
**Spec coverage:**
- `AddTask` extension with tags → Task 4 ✓
- `UpdateTask` → Task 5 ✓
- `DeleteTask` → Task 6 ✓
- `SetTaskTags` → Task 7 ✓
- `ListTags` → Task 3 ✓
- `TaskRepository.SetTagsAsync` → Task 1 ✓
- Auth (no change) → out of scope, called out in pre-flight ✓
- Tests for each tool → Tasks 1, 3-7 ✓
- Docs touch → Task 8 ✓
**Placeholder scan:** The phrase `/* same construction */` in tasks 47 is intentional — the engineer fills it in by mirroring the `QueueService` construction in Task 3 (which itself mirrors `QueueServiceTests.cs`). All other placeholders eliminated. No "TBD".
**Type consistency:**
- `IReadOnlyList<string>` for tag inputs everywhere ✓
- `TaskDto` returned by `AddTask`, `UpdateTask`, `SetTaskTags`
- `TagDto(long Id, string Name)` consistent across `ListTags`
- Constructor signature `(TaskRepository, ListRepository, QueueService, HubBroadcaster, TagRepository)` consistent between Task 3 implementation and Task 2 scaffold's `BuildSut` call ✓
- Method `TaskRepository.SetTagsAsync(string, IReadOnlyList<string>, CancellationToken)` consistent with all callers ✓
No issues found.

View File

@@ -0,0 +1,225 @@
# Session Prompts — Worker State & Queue Consolidation Slices 26
Paste-ready prompts for each remaining slice. Run **one slice per session** so the diff stays reviewable and tests stay green between commits. Spec lives at `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` — reference it when the prompt asks.
**Common ground rules** (carry across all slices):
- Direct on `main`, one commit per slice, conventional commit messages.
- Build green (`dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` + Data + Ui) before commit.
- Pre-existing test errors (TaskRunner/WorkerHub constructor drift in 4 test files) are **not** in scope to fix — they exist on `main` already. New compile errors my changes introduce ARE in scope.
- No drive-by refactors outside the slice's stated scope.
- New files must follow existing naming/folder conventions; legacy enum values stay until Slice 6.
- After each slice, update `~/.claude/projects/C--Private-ClaudeDo/memory/` if I learn something durable about the codebase.
---
## Slice 2 — `TaskStateService` (centralized state machine)
**Prompt to paste into a fresh session:**
> Slice 2 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (sections 2 and 8). Slice 1 already landed (commit 7b737e6) — `TaskStatus` has `Idle`/`Cancelled`, `PlanningPhase` enum exists, `BlockedByTaskId` field exists. Legacy enum values still around.
>
> **Goal:** introduce `Worker/State/ITaskStateService` + `TaskStateService` as the single component that mutates `Status`, `PlanningPhase`, `BlockedByTaskId`. Migrate every existing caller. Mark repo `Mark*Async` helpers `internal`.
>
> **Public surface (verbatim from spec):**
> ```csharp
> Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct);
> Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct);
> Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
> Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
> Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct);
> Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
> Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct);
> Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct);
> Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
> Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct);
> Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct);
> ```
>
> **Allowed transition table:** see spec §2. Reject invalid transitions with `TransitionResult(false, "<reason>")` — no exceptions. Each transition is one atomic `ExecuteUpdate` with `WHERE Status = <expected>` for TOCTOU-freedom.
>
> **Side effects after successful DB write** (do these inside the service so callers don't need to remember):
> - On any `→ Queued`: call `_queue.WakeQueue()` directly for now (Slice 3 will replace with `IQueueWaker`). Inject `QueueService` lazily via `Func<QueueService>` to break the DI cycle if needed.
> - On any successful transition: `_broadcaster.TaskUpdated(taskId)`.
> - On `Done`/`Failed`/`Cancelled` for a child task: invoke `_chain.OnChildFinishedAsync(taskId, finalStatus, ct)`. If it returns a next-task-id, call `UnblockAsync` on it. Then run `_repo.TryCompleteParentAsync(parentId, ct)`.
>
> **Important:** `BlockOnAsync` and `UnblockAsync` should write `BlockedByTaskId` directly. `EnqueueAsync` for a Planning child should keep `BlockedByTaskId` null when it's the head of the chain. The chain coordinator will compose these calls in Slice 4 — for now just expose the API.
>
> **Caller migration (mechanical — preserve current behavior):**
> - `TaskRunner.HandleSuccess` → replace `taskRepo.MarkDoneAsync` + `TryCompleteParentAsync` + `_chain.OnChildFinishedAsync` block with a single `_state.CompleteAsync(taskId, finishedAt, result, CancellationToken.None)`.
> - `TaskRunner.HandleFailure` → `_state.FailAsync(taskId, finishedAt, errorMarkdown, CancellationToken.None)`.
> - `TaskRunner.MarkFailed` (early-fail path) → same.
> - `TaskRunner.RunAsync` start of run → `_state.StartRunningAsync(taskId, startedAt, ct)`.
> - `StaleTaskRecovery.StartAsync` → `_state.RecoverStaleRunningAsync("worker restart", ct)`.
> - `TaskResetService.ResetAsync` → `_state.ResetToIdleAsync(taskId, ct)` for the status flip; service keeps owning worktree cleanup.
> - `PlanningSessionManager.StartAsync` (the `SetPlanningStartedAsync` call) → `_state.StartPlanningAsync(parentId, ct)`. The manager still owns token/session-dir setup; only the status flip moves.
> - `PlanningChainCoordinator.OnChildFinishedAsync` (the `next.Status = TaskStatus.Queued` write) → keep its existing logic but use `_state.UnblockAsync(next.Id, ct)` for the actual write. The Slice 4 rewrite finishes the rest.
> - `ExternalMcpService.UpdateTaskStatus` (status flip in the Queued case) → `_state.EnqueueAsync(taskId, ct)`. The Manual case stays as-is until Slice 6 since `Manual` is still a valid legacy value.
>
> **Repo helpers to mark `internal`:** `MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`, `FlipAllRunningToFailedAsync`. Verify nothing outside `ClaudeDo.Worker.State` calls them after migration. (`Worker.Tests` may need `InternalsVisibleTo` — add it if so.)
>
> **DI wiring:** register `TaskStateService` as Singleton in `Program.cs` for both the main app and the external-MCP app. The service holds no per-request state.
>
> **Tests:** new file `tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs`. At minimum:
> - Happy path for each transition (verify DB state + side-effect mocks invoked).
> - Reject path for each invalid transition (verify result + DB unchanged).
> - Concurrency: two parallel `StartRunningAsync` for the same `Queued` task → exactly one returns `Ok=true`.
> - Mock or fake the broadcaster, queue, and chain-coordinator dependencies. Use real SQLite for the DB (existing test pattern).
>
> Build all projects, run the worker test project (the 4 pre-existing constructor-drift errors are out of scope — but my changes shouldn't add new errors), commit as `refactor(worker/state): introduce TaskStateService and route mutations through it`.
---
## Slice 3 — `IQueueWaker` + `IQueuePicker`
**Prompt to paste into a fresh session:**
> Slice 3 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 3). Slices 1 and 2 already landed.
>
> **Goal:** extract queue-wake and queue-pick from `QueueService` and `TaskRepository` into dedicated single-responsibility components. Make wakes automatic.
>
> **New components in `Worker/Queue/`:**
> - `IQueueWaker` (interface, `void Wake()`). Backed by `QueueWaker` singleton holding the existing `SemaphoreSlim`. Inject into `TaskStateService` (replaces the direct `QueueService` ref from Slice 2) and into `QueueService` itself.
> - `IQueuePicker` with `Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct)`. Implementation `QueuePicker` moves the raw SQL out of `TaskRepository.GetNextQueuedAgentTaskAsync` and **adds a `blocked_by_task_id IS NULL` filter to the WHERE clause**. Order stays `sort_order ASC, created_at ASC` (verify the existing query — add ORDER BY if missing). Atomic `UPDATE … RETURNING` flips `Queued → Running` and writes `started_at`.
>
> **Caller updates:**
> - `TaskStateService` swaps its `Func<QueueService>` for `IQueueWaker`. The `→ Queued` side-effect now calls `_waker.Wake()`.
> - `QueueService.ExecuteAsync` calls `_picker.ClaimNextAsync` instead of `_taskRepo.GetNextQueuedAgentTaskAsync`. The slot-claim, broadcaster, and `WakeQueue()` after slot release stay where they are.
> - `WorkerHub.WakeQueue()` and `ExternalMcpService.WakeQueue` calls in app code → remove the explicit invocations. The state-service triggers waking automatically. **Keep** the SignalR/MCP endpoint that exposes `WakeQueue()` for diagnostics/manual use — that one delegates to `_waker.Wake()`.
> - `TaskRepository.GetNextQueuedAgentTaskAsync` becomes a thin shim that forwards to `IQueuePicker` for any remaining tests, OR delete it and update tests to use the picker. Prefer delete if tests are easy to migrate.
>
> **Tests:** new `tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs`:
> - Skipped: `BlockedByTaskId` set; missing agent tag; `scheduled_for > now`; status not Queued.
> - Picked: correct order (`sort_order, created_at`).
> - Atomic claim: two parallel pickers → exactly one row returned non-null, the other null.
>
> Update existing `TaskRepositoryTests.GetNextQueuedAgentTaskAsync_*` tests if they exercised the removed method.
>
> Build, test, commit as `refactor(worker/queue): split queue waker and picker, auto-wake on enqueue`.
---
## Slice 4 — Planning flow consolidation (kills the original bug)
**Prompt to paste into a fresh session:**
> Slice 4 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 4). Slices 13 already landed. **This slice eliminates the original "queue never picks up planning tasks" bug structurally.**
>
> **Goal:** one path through planning. Delete the dual-flow problem.
>
> **Changes:**
> - **Delete** `TaskRepository.FinalizePlanningAsync` entirely. Also delete its tests in `TaskRepositoryPlanningTests.cs`.
> - **Rewrite** `PlanningSessionManager.FinalizeAsync(taskId, queueAgentTasks, ct)`:
> 1. `_state.FinalizePlanningAsync(parentId, ct)` (sets parent `PlanningPhase=Finalized`, `Status=Idle`).
> 2. If `queueAgentTasks` is true, call the new `_chainCoordinator.SetupChainAsync(parentId, ct)`.
> 3. Existing worktree-cleanup + session-dir-deletion remains.
> 4. Return the count of children that ended up in the chain.
> - **Rename** `PlanningChainCoordinator.QueueSubtasksSequentiallyAsync` → `SetupChainAsync`. Make it `internal`. New behavior:
> - Eligibility check: children must be in `Status=Idle` (was `Manual` or `Planned` legacy values — keep tolerating those for one slice via OR).
> - Auto-attach `agent` tag to all children (already in WIP — keep that behavior).
> - For first child: `_state.EnqueueAsync(child[0].Id, ct)` (no BlockedBy, head of chain).
> - For rest: `_state.EnqueueAsync(child[i].Id, ct)` followed immediately by `_state.BlockOnAsync(child[i].Id, child[i-1].Id, ct)`. (Or: add a single `EnqueueBlockedAsync` helper to TaskStateService if call-site clutter bothers you.)
> - **Update** `PlanningChainCoordinator.OnChildFinishedAsync`: replace status-via-LINQ logic with: query for the next child where `BlockedByTaskId == childTaskId`, call `_state.UnblockAsync` on it. Drop the `Waiting` lookup entirely.
> - Audit `Status == TaskStatus.Waiting` in UI/tests — replace with `Status == Queued && BlockedByTaskId != null`. (UI changes confirmed against `TaskRowViewModel`, `TasksIslandViewModel` from Slice 1's WIP.)
>
> **Regression test:** new `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs` (or extend existing) — `Active` parent + 3 drafts → call `FinalizeAsync(queueAgentTasks: true)` → assert within 200 ms the first child has `Status=Running` (queue picker claimed it) without anyone calling `WakeQueue()` manually. This was the bug the user originally reported.
>
> **Update** `PlanningMcpService.EditableStatuses` — replace `Waiting` with `Queued` (since blocked tasks are now `Queued + BlockedByTaskId`). Verify the MCP tool still gates on `parent.PlanningPhase == Active` (legacy: `parent.Status == Planning`).
>
> Build, test, commit as `feat(planning): consolidate finalize+chain via TaskStateService, fix queue pickup`.
---
## Slice 5 — `OverrideSlotService` + folder reorg
**Prompt to paste into a fresh session:**
> Slice 5 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 5). Slices 14 already landed.
>
> **Goal:** split the override slot out of QueueService and reorganize `Worker/Services/` into domain folders.
>
> **`OverrideSlotService` (new in `Worker/Queue/`):**
> - Owns the `_overrideSlot` field, `RunNow(taskId)`, `ContinueTask(taskId, followUpPrompt)`, and the override-slot piece of `CancelTask`.
> - Status mutations go through `TaskStateService.StartRunningAsync` (non-atomic claim is fine; serialized by slot lock).
> - `QueueService.CancelTask` delegates to `OverrideSlotService.TryCancel` first, falls back to its own queue slot.
> - WorkerHub's `RunNow`/`ContinueTask`/`CancelTask` SignalR endpoints route to the new service via `OverrideSlotService` when applicable; keep the signatures stable.
>
> **Folder reorg** (use `git mv`, don't copy/delete):
> ```
> Worker/State/ ← ITaskStateService.cs, TaskStateService.cs, TransitionResult.cs (already exist; no move needed if already there)
> Worker/Queue/ ← IQueueWaker.cs, QueueWaker.cs, IQueuePicker.cs, QueuePicker.cs, QueueService.cs, OverrideSlotService.cs, QueueSlotState.cs
> Worker/Lifecycle/ ← StaleTaskRecovery.cs, TaskResetService.cs, TaskMergeService.cs
> Worker/Worktrees/ ← WorktreeMaintenanceService.cs
> Worker/Agents/ ← AgentFileService.cs, DefaultAgentSeeder.cs
> Worker/Runner/ ← unchanged
> Worker/Planning/ ← unchanged
> Worker/External/ ← unchanged
> Worker/Hub/ ← unchanged
> ```
>
> Update namespaces to match folders (existing convention: namespace == folder path under `ClaudeDo.Worker`). Delete the old `Worker/Services/` folder once empty.
>
> Update DI registrations in `Program.cs` (both apps) — most calls just need `using` updates. `OverrideSlotService` is a new singleton.
>
> Update test `using` statements to follow.
>
> Build, test, commit as `refactor(worker): extract OverrideSlotService and reorganize Worker/Services into domain folders`.
---
## Slice 6 — Cleanup, legacy retirement, docs
**Prompt to paste into a fresh session:**
> Slice 6 (final) of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 6 + slice plan). Slices 15 already landed.
>
> **Goal:** retire legacy enum values, backfill DB rows, update docs.
>
> **EF migration `RetireLegacyTaskStatus`:**
> ```sql
> UPDATE tasks SET status='idle' WHERE status IN ('manual', 'draft');
> UPDATE tasks SET status='idle', planning_phase='active' WHERE status='planning';
> UPDATE tasks SET status='idle', planning_phase='finalized' WHERE status='planned';
>
> -- Waiting → Queued + blocked_by from sort_order:
> WITH ordered AS (
> SELECT id,
> LAG(id) OVER (PARTITION BY parent_task_id ORDER BY sort_order, created_at) AS prev_id
> FROM tasks WHERE status='waiting'
> )
> UPDATE tasks
> SET status='queued',
> blocked_by_task_id=(SELECT prev_id FROM ordered WHERE ordered.id=tasks.id)
> WHERE id IN (SELECT id FROM ordered);
> ```
> Use `migrationBuilder.Sql(...)` for these. Down() is best-effort: `Cancelled` → `Failed`, `(idle, finalized)` → `planned`, `(idle, active)` → `planning`, `queued + blocked_by_task_id != null` → `waiting`. Document lossiness in a comment.
>
> **Code changes:**
> - Remove legacy values from `TaskStatus` enum: `Manual, Planning, Planned, Draft, Waiting`.
> - Strip the legacy branches from `TaskEntityConfiguration.StatusToString`/`StatusFromString`.
> - Default for `TaskEntity.Status` is `TaskStatus.Idle` (already correct after Slice 1's revert).
> - Audit + remap every remaining caller — they should already use new values from Slices 24, but search for any leftover `TaskStatus.Manual` etc. in:
> - tests (~10 files seed status — flip to `Idle`/`Queued`/etc.)
> - UI (`TaskRowViewModel.IsPlanningParent`, `IsDraft`, `CanOpenPlanningSession`, status maps — replace with `PlanningPhase` checks where appropriate)
> - any leftover guards in MCP/services
> - Mark `Mark*Async` repo helpers as `internal` if not already (Slice 2 should have done this — verify).
>
> **Docs to update:**
> - `src/ClaudeDo.Worker/CLAUDE.md` — new folder structure, new state-service flow, new wake mechanics, removal of legacy values.
> - `src/ClaudeDo.Data/CLAUDE.md` — TaskEntity new fields (`PlanningPhase`, `BlockedByTaskId`), retired legacy enum values, new tag-attach behavior.
> - `docs/plan.md` — update status flow section.
> - `docs/open.md` — close the "queue doesn't pick up planning tasks" item if it's tracked there; add any follow-ups discovered along the way.
> - Memory: update `~/.claude/projects/C--Private-ClaudeDo/memory/` with a new entry summarizing the new architecture (state-service + queue split + planning chain via blocked-by).
>
> **Sanity tests** — full test run. The 4 pre-existing constructor-drift errors should still be the only failures. If new ones surfaced from missed legacy-value remappings, fix them before commit.
>
> Build, full test run, commit as `refactor(data): retire legacy TaskStatus values and backfill existing rows`.
---
## After Slice 6
- All 6 slices on `main`.
- The original bug ("queue doesn't pick up planning tasks") is structurally impossible.
- Worker has clear domain folders, single state-mutator, single queue-picker.
- Spec doc + this prompt file can be deleted or moved to `docs/superpowers/done/`.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,834 @@
# Repo Import List Helper Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a helper that scans parent folders for git repos and bulk-creates lists (with `WorkingDir` pre-filled) for the repos the user ticks.
**Architecture:** A pure `RepoScanner` finds git repos under a parent folder. A `RepoImportModalViewModel` loads existing lists' working dirs, merges scanned candidates into a checklist (marking already-added repos), and creates `ListEntity` rows for ticked-new repos via `ListRepository`. `RepoImportModalView` hosts the checklist and a folder picker. Two entry points open the modal: a Help-menu item (handled by `IslandsShellViewModel`) and a folder button in the Lists island (handled by `ListsIslandViewModel`). Each entry point reloads the Lists island after the modal closes.
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm source generators, EF Core (SQLite), xUnit.
---
## File Structure
**Create:**
- `src/ClaudeDo.Ui/Services/RepoScanner.cs` — pure filesystem scan; `RepoCandidate` record + `RepoScanner.Scan`.
- `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs` — one checklist row.
- `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs` — modal VM (load, merge, create) + static `BuildCandidates`.
- `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml` (+ `.axaml.cs`) — modal window + folder picker.
- `tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs` — scanner unit tests.
- `tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs` — merge/dedupe/already-added unit tests.
**Modify:**
- `src/ClaudeDo.App/Program.cs` — register `RepoImportModalViewModel` (transient) + a `Func<RepoImportModalViewModel>`; pass the Func into `IslandsShellViewModel`.
- `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs``ShowRepoImportModal` Func + `OpenRepoImportCommand`.
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml` — folder button beside `+ New list`.
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs` — wire `ShowRepoImportModal`.
- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs``ShowRepoImportModal` Func + `OpenRepoImportCommand`; inject `Func<RepoImportModalViewModel>`.
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` — Help-menu item `Add repos as lists…`.
- `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` — wire `ShowRepoImportModal`.
- `src/ClaudeDo.Ui/CLAUDE.md` — document the new modal + entry points.
---
## Task 1: RepoScanner
**Files:**
- Create: `src/ClaudeDo.Ui/Services/RepoScanner.cs`
- Test: `tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs`
- [ ] **Step 1: Write the failing tests**
Create `tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs`:
```csharp
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.Tests;
public sealed class RepoScannerTests : IDisposable
{
private readonly string _root =
Path.Combine(Path.GetTempPath(), "repo-scan-" + Guid.NewGuid().ToString("N"));
public RepoScannerTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch { }
}
private string MakeDir(string name)
{
var p = Path.Combine(_root, name);
Directory.CreateDirectory(p);
return p;
}
[Fact]
public void Scan_ReturnsSubfoldersWithGitDirectory()
{
var repo = MakeDir("repo-a");
Directory.CreateDirectory(Path.Combine(repo, ".git"));
var result = RepoScanner.Scan(_root);
Assert.Single(result);
Assert.Equal("repo-a", result[0].Name);
Assert.Equal(repo, result[0].FullPath);
}
[Fact]
public void Scan_TreatsDotGitFileAsRepo()
{
var repo = MakeDir("worktree-repo");
File.WriteAllText(Path.Combine(repo, ".git"), "gitdir: ../somewhere");
var result = RepoScanner.Scan(_root);
Assert.Single(result);
Assert.Equal("worktree-repo", result[0].Name);
}
[Fact]
public void Scan_IgnoresPlainFolders()
{
MakeDir("not-a-repo");
var result = RepoScanner.Scan(_root);
Assert.Empty(result);
}
[Fact]
public void Scan_IsNotRecursive()
{
var nested = MakeDir(Path.Combine("outer", "inner"));
Directory.CreateDirectory(Path.Combine(nested, ".git"));
// outer itself has no .git
var result = RepoScanner.Scan(_root);
Assert.Empty(result);
}
[Fact]
public void Scan_ReturnsEmptyForMissingFolder()
{
var result = RepoScanner.Scan(Path.Combine(_root, "does-not-exist"));
Assert.Empty(result);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoScannerTests`
Expected: FAIL — `RepoScanner` / `RepoCandidate` do not exist (compile error).
- [ ] **Step 3: Implement RepoScanner**
Create `src/ClaudeDo.Ui/Services/RepoScanner.cs`:
```csharp
namespace ClaudeDo.Ui.Services;
public sealed record RepoCandidate(string Name, string FullPath);
public static class RepoScanner
{
public static IReadOnlyList<RepoCandidate> Scan(string parentFolder)
{
if (string.IsNullOrWhiteSpace(parentFolder) || !Directory.Exists(parentFolder))
return Array.Empty<RepoCandidate>();
var result = new List<RepoCandidate>();
IEnumerable<string> subdirs;
try { subdirs = Directory.EnumerateDirectories(parentFolder); }
catch { return Array.Empty<RepoCandidate>(); }
foreach (var dir in subdirs)
{
var gitPath = Path.Combine(dir, ".git");
if (Directory.Exists(gitPath) || File.Exists(gitPath))
result.Add(new RepoCandidate(Path.GetFileName(dir), dir));
}
return result;
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoScannerTests`
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Services/RepoScanner.cs tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs
git commit -m "feat(ui): add RepoScanner for git repo discovery"
```
---
## Task 2: RepoImportItemViewModel
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs`
No dedicated test (trivial display VM; covered indirectly by Task 3).
- [ ] **Step 1: Implement the item VM**
Create `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs`:
```csharp
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class RepoImportItemViewModel : ViewModelBase
{
public string Name { get; init; } = "";
public string FullPath { get; init; } = "";
// True when a list already points at this path. Such rows are shown ticked + disabled.
public bool AlreadyAdded { get; init; }
public bool CanToggle => !AlreadyAdded;
[ObservableProperty] private bool _isChecked;
}
```
- [ ] **Step 2: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs
git commit -m "feat(ui): add RepoImportItemViewModel"
```
---
## Task 3: RepoImportModalViewModel
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs`
The pure `BuildCandidates` static method is the tested seam (dedupe + already-added marking). `LoadAsync`/`CreateAsync` touch the DB and are verified manually.
- [ ] **Step 1: Write the failing tests**
Create `tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs`:
```csharp
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Tests;
public sealed class RepoImportCandidatesTests
{
[Fact]
public void BuildCandidates_NewRepo_IsCheckedAndNotAlreadyAdded()
{
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
var current = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
Assert.Single(items);
Assert.True(items[0].IsChecked);
Assert.False(items[0].AlreadyAdded);
Assert.Equal("repo-a", items[0].Name);
}
[Fact]
public void BuildCandidates_ExistingWorkingDir_IsMarkedAlreadyAdded()
{
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
var current = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var existing = new HashSet<string>(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase);
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
Assert.Single(items);
Assert.True(items[0].AlreadyAdded);
Assert.True(items[0].IsChecked); // already-added rows render ticked
}
[Fact]
public void BuildCandidates_SkipsPathsAlreadyShown()
{
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
var current = new HashSet<string>(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase);
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
Assert.Empty(items);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoImportCandidatesTests`
Expected: FAIL — `RepoImportModalViewModel` does not exist (compile error).
- [ ] **Step 3: Implement the modal VM**
Create `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs`:
```csharp
using System.Collections.ObjectModel;
using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class RepoImportModalViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly HashSet<string> _existingDirs = new(StringComparer.OrdinalIgnoreCase);
public ObservableCollection<RepoImportItemViewModel> Repos { get; } = new();
public Action? CloseAction { get; set; }
public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded);
public bool CanCreate => CreateCount > 0;
public string CreateButtonText => $"Create {CreateCount} list(s)";
public RepoImportModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task LoadAsync(CancellationToken ct = default)
{
Repos.Clear();
_existingDirs.Clear();
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx);
foreach (var l in await lists.GetAllAsync(ct))
{
if (!string.IsNullOrWhiteSpace(l.WorkingDir))
_existingDirs.Add(l.WorkingDir!);
}
NotifyCreateState();
}
public void AddFolders(IEnumerable<string> folders)
{
var current = new HashSet<string>(
Repos.Select(r => r.FullPath), StringComparer.OrdinalIgnoreCase);
foreach (var folder in folders)
{
var found = RepoScanner.Scan(folder);
foreach (var item in BuildCandidates(found, current, _existingDirs))
{
item.PropertyChanged += OnItemChanged;
Repos.Add(item);
current.Add(item.FullPath);
}
}
NotifyCreateState();
}
public static List<RepoImportItemViewModel> BuildCandidates(
IEnumerable<RepoCandidate> found,
IReadOnlySet<string> currentPaths,
IReadOnlySet<string> existingDirs)
{
var items = new List<RepoImportItemViewModel>();
foreach (var c in found)
{
if (currentPaths.Contains(c.FullPath)) continue;
items.Add(new RepoImportItemViewModel
{
Name = c.Name,
FullPath = c.FullPath,
AlreadyAdded = existingDirs.Contains(c.FullPath),
IsChecked = true,
});
}
return items;
}
private void OnItemChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(RepoImportItemViewModel.IsChecked))
NotifyCreateState();
}
private void NotifyCreateState()
{
OnPropertyChanged(nameof(CreateCount));
OnPropertyChanged(nameof(CanCreate));
OnPropertyChanged(nameof(CreateButtonText));
}
[RelayCommand]
private async Task CreateAsync()
{
var toCreate = Repos.Where(r => r.IsChecked && !r.AlreadyAdded).ToList();
if (toCreate.Count > 0)
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
foreach (var r in toCreate)
{
await lists.AddAsync(new ListEntity
{
Id = Guid.NewGuid().ToString("N"),
Name = r.Name,
WorkingDir = r.FullPath,
DefaultCommitType = CommitTypeRegistry.DefaultType,
CreatedAt = DateTime.UtcNow,
});
}
}
CloseAction?.Invoke();
}
[RelayCommand]
private void Cancel() => CloseAction?.Invoke();
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoImportCandidatesTests`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs
git commit -m "feat(ui): add RepoImportModalViewModel with candidate merge logic"
```
---
## Task 4: RepoImportModalView
**Files:**
- Create: `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml`
- Create: `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs`
Modeled on `AboutModalView.axaml` (header/body/footer) and `ListSettingsModalView.axaml.cs` (folder picker).
- [ ] **Step 1: Create the view XAML**
Create `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml`:
```xml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.RepoImportModalView"
x:DataType="vm:RepoImportModalViewModel"
Title="Add repos as lists"
Width="560" Height="480"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
</Window.KeyBindings>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1">
<Grid RowDefinitions="36,Auto,*,52">
<!-- Header -->
<Border Grid.Row="0" Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="ADD REPOS AS LISTS" FontFamily="{DynamicResource MonoFont}" FontSize="11"
LetterSpacing="1.4" Foreground="{DynamicResource TextBrush}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn" Content="✕" FontSize="12"
Command="{Binding CancelCommand}" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Add folder row -->
<Border Grid.Row="1" Padding="16,12,16,4">
<Button Content="Add folder…" Click="AddFolderClicked" HorizontalAlignment="Left"/>
</Border>
<!-- Repo checklist -->
<ScrollViewer Grid.Row="2" Padding="16,4,16,8">
<ItemsControl ItemsSource="{Binding Repos}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:RepoImportItemViewModel">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,4">
<CheckBox Grid.Column="0"
IsChecked="{Binding IsChecked, Mode=TwoWay}"
IsEnabled="{Binding CanToggle}"
VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Margin="6,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource TextBrush}" FontSize="13"/>
<TextBlock Text="{Binding FullPath}" Foreground="{DynamicResource TextFaintBrush}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="(already added)"
Foreground="{DynamicResource TextFaintBrush}" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding AlreadyAdded}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Footer -->
<Border Grid.Row="3" Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right"
VerticalAlignment="Center" Margin="16,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="{Binding CreateButtonText}" Command="{Binding CreateCommand}"
IsEnabled="{Binding CanCreate}" MinWidth="120" Classes="accent"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Window>
```
- [ ] **Step 2: Create the code-behind with folder picker**
Create `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs`:
```csharp
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class RepoImportModalView : Window
{
public RepoImportModalView()
{
InitializeComponent();
}
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
private async void AddFolderClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not RepoImportModalViewModel vm) return;
var top = TopLevel.GetTopLevel(this);
if (top is null) return;
var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Choose folders containing repos",
AllowMultiple = true,
});
if (folders.Count == 0) return;
vm.AddFolders(folders.Select(f => f.Path.LocalPath));
}
}
```
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded. (`TitleBar_PointerPressed` is unused for now but kept for parity with other modals; if the build warns as error, leave it — other modals keep the same handler.)
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs
git commit -m "feat(ui): add RepoImportModalView"
```
---
## Task 5: DI registration
**Files:**
- Modify: `src/ClaudeDo.App/Program.cs:106` (after `ListSettingsModalViewModel` registration)
- [ ] **Step 1: Register the modal VM and its factory**
In `src/ClaudeDo.App/Program.cs`, after the line `sc.AddTransient<ListSettingsModalViewModel>();` add:
```csharp
sc.AddTransient<RepoImportModalViewModel>();
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
```
(`RepoImportModalViewModel` is in namespace `ClaudeDo.Ui.ViewModels.Modals`, already imported in `Program.cs` via the existing modal VM usings — verify the using is present; if not, add `using ClaudeDo.Ui.ViewModels.Modals;`.)
- [ ] **Step 2: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.App/Program.cs
git commit -m "chore(di): register RepoImportModalViewModel"
```
---
## Task 6: Lists island entry point
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs`
- [ ] **Step 1: Add Func + command to the VM**
In `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`, next to the existing `ShowListSettingsModal` property (around line 30), add:
```csharp
public Func<RepoImportModalViewModel, System.Threading.Tasks.Task>? ShowRepoImportModal { get; set; }
```
Then add a command (place it near `CreateListAsync`, e.g. after the `OpenWorktreesOverviewAsync` command around line 71):
```csharp
[RelayCommand]
private async System.Threading.Tasks.Task OpenRepoImportAsync()
{
if (ShowRepoImportModal is null || _services is null) return;
var vm = _services.GetRequiredService<RepoImportModalViewModel>();
await vm.LoadAsync();
await ShowRepoImportModal(vm);
await LoadAsync();
}
```
(`RepoImportModalViewModel` is in `ClaudeDo.Ui.ViewModels.Modals`, already imported at the top of this file.)
- [ ] **Step 2: Add the folder button in XAML**
In `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`, replace the existing `+ New list` button block (lines 171-183) with a row that holds both the new-list button and a folder-scan button:
```xml
<!-- New list + import row -->
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="new-list-btn"
Command="{Binding CreateListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Data="{StaticResource Icon.Plus}"
Width="13" Height="13"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="New list" FontSize="12"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Grid.Column="1" Classes="icon-btn" Margin="6,0,0,0"
Command="{Binding OpenRepoImportCommand}"
ToolTip.Tip="Add repos as lists">
<PathIcon Data="{StaticResource Icon.Folder}"
Width="14" Height="14"
Foreground="{DynamicResource TextMuteBrush}"/>
</Button>
</Grid>
```
- [ ] **Step 3: Wire the Func in the code-behind**
In `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs`, inside the `DataContextChanged` handler (after the `vm.ShowWorktreesOverviewModal = ...` assignment, before the closing brace of the `if` block around line 66), add:
```csharp
vm.ShowRepoImportModal = async modal =>
{
var window = new RepoImportModalView { DataContext = modal };
modal.CloseAction = () => window.Close();
var top = TopLevel.GetTopLevel(this) as Window;
if (top is null) window.Show();
else await window.ShowDialog(top);
};
```
(`RepoImportModalView` is in `ClaudeDo.Ui.Views.Modals`, already imported in this file.)
- [ ] **Step 4: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs
git commit -m "feat(ui): add repo import button to Lists island"
```
---
## Task 7: Help-menu entry point
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- Modify: `src/ClaudeDo.App/Program.cs` (pass the Func into the shell VM)
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
- [ ] **Step 1: Add Func, factory field, and command to the shell VM**
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`:
(a) Near the `ShowAboutModal` property (line 44), add:
```csharp
public Func<RepoImportModalViewModel, Task>? ShowRepoImportModal { get; set; }
```
(b) Add a backing field for the factory next to `_worktreesOverviewVmFactory` (declared as a private readonly field elsewhere in the class). Add:
```csharp
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
```
(c) Add a parameter to the public constructor (line 162-171) — append after `mergeVmFactory`:
```csharp
Func<MergeModalViewModel> mergeVmFactory,
Func<RepoImportModalViewModel> repoImportVmFactory)
```
and in the constructor body assign it (next to `_mergeVmFactory = mergeVmFactory;`):
```csharp
_repoImportVmFactory = repoImportVmFactory;
```
(d) Add the command near `OpenAbout` (line 256):
```csharp
[RelayCommand]
private async Task OpenRepoImport()
{
if (ShowRepoImportModal is null || _repoImportVmFactory is null) return;
var vm = _repoImportVmFactory();
await vm.LoadAsync();
await ShowRepoImportModal(vm);
if (Lists is not null) await Lists.LoadAsync();
}
```
(`RepoImportModalViewModel` is in `ClaudeDo.Ui.ViewModels.Modals`, already imported in this file.)
- [ ] **Step 2: Pass the Func into the shell VM in DI**
`IslandsShellViewModel` is registered with `sc.AddSingleton<IslandsShellViewModel>();` (Program.cs:123), which resolves constructor params from the container. Since Task 5 registered `Func<RepoImportModalViewModel>`, no change to the registration call is required — the new constructor parameter resolves automatically. Verify by building in Step 5.
- [ ] **Step 3: Add the Help-menu item**
In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, inside the Help `MenuItem` (after the `About…` item at line 74), add:
```xml
<MenuItem Header="Add repos as lists…" Command="{Binding OpenRepoImportCommand}"/>
```
- [ ] **Step 4: Wire the Func in MainWindow code-behind**
In `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`, inside `OnDataContextChanged` (after the `vm.ShowWorktreesOverviewModal = ...` block, before the closing brace of the `if` at line 65), add:
```csharp
vm.ShowRepoImportModal = async (modal) =>
{
var dlg = new RepoImportModalView { DataContext = modal };
modal.CloseAction = () => dlg.Close();
await dlg.ShowDialog(this);
};
```
(`RepoImportModalView` is in `ClaudeDo.Ui.Views.Modals`, already imported in this file.)
- [ ] **Step 5: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded (this also builds the Ui project).
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs src/ClaudeDo.Ui/Views/MainWindow.axaml src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
git commit -m "feat(ui): add 'Add repos as lists' Help-menu entry point"
```
---
## Task 8: Manual verification + docs
**Files:**
- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
- [ ] **Step 1: Run the full Ui test suite**
Run: `dotnet test tests/ClaudeDo.Ui.Tests`
Expected: PASS (all tests, including the new `RepoScannerTests` and `RepoImportCandidatesTests`).
- [ ] **Step 2: Manual smoke test**
Launch the app (`dotnet run --project src/ClaudeDo.App/ClaudeDo.App.csproj`). Verify:
- Lists island shows a folder button next to `+ New list`; clicking it opens the modal.
- Help menu shows `Add repos as lists…`; clicking it opens the same modal.
- `Add folder…` → pick a parent folder containing git repos → repos appear as ticked rows; non-repo subfolders are absent.
- A repo that already has a list appears ticked, disabled, with `(already added)`.
- The confirm button reads `Create N list(s)` and is disabled when N is 0.
- Confirming creates the lists; they appear in the Lists island immediately after the modal closes.
Note: if you cannot run the GUI in this environment, state that explicitly rather than claiming the UI works.
- [ ] **Step 3: Update CLAUDE.md**
In `src/ClaudeDo.Ui/CLAUDE.md`, under the `## Views` section, add a bullet:
```markdown
- **RepoImportModalView** — bulk-creates lists from git repos discovered under chosen parent folders. Opened via the folder button beside "New list" in the Lists island, or the "Add repos as lists…" Help-menu item. Repos already wired to a list show as disabled/"(already added)".
```
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/CLAUDE.md
git commit -m "docs(ui): document RepoImportModalView"
```
---
## Self-Review Notes
- **Spec coverage:** Entry points (Help menu — Task 7; Lists island button — Task 6); `RepoScanner` non-recursive `.git` dir/file detection (Task 1); `RepoImportModalViewModel` load existing dirs + merge + create (Task 3); already-added disabled rows + `(already added)` label (Tasks 2/3/4); combined multi-folder checklist with path dedupe (Task 3 `AddFolders`); defaults Name/WorkingDir/DefaultCommitType (Task 3 `CreateAsync`); reload Lists island after close (Tasks 6/7); DI registration (Task 5); tests for scanner + merge logic (Tasks 1/3). All spec sections map to a task.
- **Type consistency:** `RepoCandidate(Name, FullPath)`, `RepoScanner.Scan`, `RepoImportItemViewModel{Name,FullPath,AlreadyAdded,CanToggle,IsChecked}`, `RepoImportModalViewModel{Repos,CreateCount,CanCreate,CreateButtonText,LoadAsync,AddFolders,BuildCandidates,CreateCommand,CancelCommand,ShowRepoImportModal,CloseAction}` used consistently across tasks.
- **YAGNI:** No recursive scan, no inline rename, no per-list model/prompt/agent during import — all explicitly out of scope.

View File

@@ -0,0 +1,655 @@
# Worker Per-User Autostart Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the worker's Windows service with a per-user logon Scheduled Task so the worker runs as the logged-in user (Claude auth works), windowless, with file logging and auto-restart.
**Architecture:** Worker becomes a windowless (`WinExe`) process with Serilog file logging and a single-instance mutex. The installer registers a hidden logon Scheduled Task (via `schtasks /Create /XML`), migrates away the old `ClaudeDoWorker` service, and manages the worker as a process. The app launches/restarts the worker as a process and ensures it's running.
**Tech Stack:** .NET 8, ASP.NET Core (worker), WPF (installer), Avalonia (app), Serilog, Windows Task Scheduler (`schtasks`), `sc.exe`.
**Build note:** `.slnx` fails on .NET 8 — always build individual `.csproj` files.
---
## File Structure
**Worker**
- Modify `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — WinExe, Serilog packages, drop Hosting.WindowsServices.
- Modify `src/ClaudeDo.Worker/Program.cs` — mutex, Serilog, remove `UseWindowsService`.
**Installer**
- Create `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs` — pure XML builder.
- Create `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs` — migrate service + register task.
- Rename/rewrite `StopServiceStep.cs``StopWorkerStep.cs`, `StartServiceStep.cs``StartWorkerStep.cs`.
- Delete `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs`.
- Modify `Pages/ServicePage/ServicePageViewModel.cs` + `ServicePageView.xaml` — drop account radios.
- Modify `Core/InstallContext.cs` — drop `ServiceAccount`.
- Modify `Pages/InstallPage/InstallPageViewModel.cs` — pipeline wiring.
- Modify `App.xaml.cs` — DI registration.
- Modify `Core/UninstallRunner.cs` — task delete + process kill.
- Modify `Views/SettingsViewModel.cs` — use renamed steps.
**App**
- Create `src/ClaudeDo.Ui/Services/WorkerLocator.cs` — resolve worker exe path.
- Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — process restart + ensure-running.
- Modify `src/ClaudeDo.App/Program.cs` — register `WorkerLocator`, pass to shell VM if needed.
**Tests**
- Create `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs`.
- Create `tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs`.
---
## Task 1: Worker → WinExe + Serilog packages
**Files:** Modify `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
- [ ] **Step 1:** In the main `<PropertyGroup>` add `<OutputType>WinExe</OutputType>`. Remove the `Microsoft.Extensions.Hosting.WindowsServices` PackageReference. Add:
```xml
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
```
- [ ] **Step 2:** Build: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — Expected: succeeds (packages restore).
---
## Task 2: Worker single-instance mutex + Serilog + drop UseWindowsService
**Files:** Modify `src/ClaudeDo.Worker/Program.cs`
- [ ] **Step 1:** At the very top of the file (before `var cfg = WorkerConfig.Load();`), add the single-instance guard:
```csharp
using System.Threading;
// Single-instance per user session. Multiple launch paths exist (logon task,
// app ensure-running, Restart button); a second instance exits cleanly instead
// of fighting over the SignalR port.
var mutex = new Mutex(true, @"Local\ClaudeDoWorker", out var createdNew);
if (!createdNew)
return; // another instance already owns the port; exit 0
```
- [ ] **Step 2:** Remove the `builder.Host.UseWindowsService(...)` line (lines ~21-23 incl. the comment).
- [ ] **Step 3:** After `var builder = WebApplication.CreateBuilder(args);`, add Serilog file logging:
```csharp
using Serilog;
var logRoot = ClaudeDo.Data.Paths.Expand(cfg.LogRoot);
Directory.CreateDirectory(logRoot);
builder.Host.UseSerilog((ctx, lc) => lc
.MinimumLevel.Information()
.WriteTo.File(
System.IO.Path.Combine(logRoot, "worker-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
shared: true));
```
(If `cfg.LogRoot` is already absolute/expanded, `Paths.Expand` is a safe no-op. Verify `WorkerConfig` exposes `LogRoot`; if the property differs, use the actual name.)
- [ ] **Step 4:** At the very end of the file, after the run block, add `GC.KeepAlive(mutex);` to ensure the mutex isn't collected.
- [ ] **Step 5:** Build: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — Expected: succeeds.
- [ ] **Step 6:** Run worker tests: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` — Expected: all pass (set `CLAUDEDO_SKIP_CLI_PREFLIGHT=1` if needed; existing tests already handle this).
---
## Task 3: Scheduled-task XML builder (pure, TDD)
**Files:** Create `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs`, Test `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs`
- [ ] **Step 1: Write the failing test:**
```csharp
using ClaudeDo.Installer.Core;
using Xunit;
namespace ClaudeDo.Installer.Tests;
public class ScheduledTaskXmlTests
{
[Fact]
public void Build_EmbedsUserExeAndLogonTrigger()
{
var xml = ScheduledTaskXml.Build(
userId: "MACHINE\\mika",
workerExePath: @"C:\Program Files\ClaudeDo\worker\ClaudeDo.Worker.exe",
restartIntervalMinutes: 1);
Assert.Contains("<LogonTrigger>", xml);
Assert.Contains("<UserId>MACHINE\\mika</UserId>", xml);
Assert.Contains("<LogonType>InteractiveToken</LogonType>", xml);
Assert.Contains("<Hidden>true</Hidden>", xml);
Assert.Contains("<RunLevel>LeastPrivilege</RunLevel>", xml);
Assert.Contains(@"C:\Program Files\ClaudeDo\worker\ClaudeDo.Worker.exe", xml);
Assert.Contains("<Interval>PT1M</Interval>", xml);
}
[Fact]
public void Build_ClampsRestartIntervalToOneMinuteMinimum()
{
var xml = ScheduledTaskXml.Build("M\\u", @"C:\w.exe", restartIntervalMinutes: 0);
Assert.Contains("<Interval>PT1M</Interval>", xml);
}
}
```
- [ ] **Step 2: Run it, verify fail:** `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj --filter ScheduledTaskXmlTests` — Expected: FAIL (type missing).
- [ ] **Step 3: Implement:**
```csharp
using System.Security;
namespace ClaudeDo.Installer.Core;
/// <summary>Builds a Task Scheduler definition XML for the per-user worker autostart.
/// Pure function so it can be unit-tested without admin rights.</summary>
public static class ScheduledTaskXml
{
public static string Build(string userId, string workerExePath, int restartIntervalMinutes)
{
var minutes = restartIntervalMinutes < 1 ? 1 : restartIntervalMinutes;
var user = SecurityElement.Escape(userId);
var cmd = SecurityElement.Escape(workerExePath);
return $"""
<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>ClaudeDo background worker (per-user).</Description>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
<UserId>{user}</UserId>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<UserId>{user}</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<Hidden>true</Hidden>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<RestartOnFailure>
<Interval>PT{minutes}M</Interval>
<Count>3</Count>
</RestartOnFailure>
</Settings>
<Actions Context="Author">
<Exec>
<Command>{cmd}</Command>
</Exec>
</Actions>
</Task>
""";
}
}
```
- [ ] **Step 4: Run, verify pass:** same filter — Expected: PASS.
---
## Task 4: RegisterAutostartStep (migrate service + register task)
**Files:** Create `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs`
- [ ] **Step 1: Implement** (no unit test — shells out to `sc`/`schtasks`; logic kept thin):
```csharp
using System.IO;
using System.Security.Principal;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class RegisterAutostartStep : IInstallStep
{
public const string TaskName = "ClaudeDoWorker";
private const string LegacyServiceName = "ClaudeDoWorker";
public string Name => "Register Autostart";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
if (!File.Exists(workerExe))
return StepResult.Fail($"Worker executable not found: {workerExe}");
// 1) Migrate away the legacy Windows service if present.
progress.Report("Checking for legacy worker service...");
var (queryExit, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
if (queryExit == 0)
{
progress.Report("Removing legacy worker service...");
await ProcessRunner.RunAsync("sc.exe", $"stop {LegacyServiceName}", null, progress, ct);
await ProcessRunner.RunAsync("sc.exe", $"delete {LegacyServiceName}", null, progress, ct);
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (q, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
if (q != 0) break;
await Task.Delay(1000, ct);
}
}
// 2) Register (or replace) the per-user logon task.
var userId = WindowsIdentity.GetCurrent().Name;
var minutes = Math.Max(1, ctx.RestartDelayMs / 60000);
var xml = ScheduledTaskXml.Build(userId, workerExe, minutes);
var xmlPath = Path.Combine(Path.GetTempPath(), $"ClaudeDoWorker-{Guid.NewGuid():N}.xml");
await File.WriteAllTextAsync(xmlPath, xml, new System.Text.UnicodeEncoding(false, true), ct);
try
{
progress.Report("Registering logon task...");
var (exit, output) = await ProcessRunner.RunAsync(
"schtasks.exe", $"/Create /TN \"{TaskName}\" /XML \"{xmlPath}\" /F", null, progress, ct);
if (exit != 0)
return StepResult.Fail($"schtasks /Create failed (exit {exit}): {output}");
}
finally
{
try { File.Delete(xmlPath); } catch { /* best effort */ }
}
return StepResult.Ok();
}
}
```
- [ ] **Step 2: Build:** `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds (after Task 5/6 it compiles fully; if `RestartDelayMs` exists on `InstallContext` already, this compiles now).
---
## Task 5: StopWorkerStep + StartWorkerStep (replace service steps)
**Files:** Create `src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`, `src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`. Delete `StopServiceStep.cs`, `StartServiceStep.cs`, `RegisterServiceStep.cs`.
- [ ] **Step 1: Create `StopWorkerStep.cs`:**
```csharp
using System.Diagnostics;
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StopWorkerStep : IInstallStep
{
public const string TaskName = "ClaudeDoWorker";
public const string ProcessName = "ClaudeDo.Worker";
public string Name => "Stop Worker";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report("Stopping worker task (if running)...");
await ProcessRunner.RunAsync("schtasks.exe", $"/End /TN \"{TaskName}\"", null, progress, ct);
progress.Report("Stopping worker process (if running)...");
var installDir = ctx.InstallDirectory;
foreach (var p in Process.GetProcessesByName(ProcessName))
{
try
{
var path = p.MainModule?.FileName;
if (path is not null && !IsUnder(path, installDir)) continue;
p.Kill(entireProcessTree: true);
p.WaitForExit(10000);
}
catch { /* process may have exited or be inaccessible */ }
finally { p.Dispose(); }
}
await Task.CompletedTask;
return StepResult.Ok();
}
private static bool IsUnder(string filePath, string dir)
{
try
{
if (string.IsNullOrWhiteSpace(dir)) return true; // can't scope — be permissive
var full = Path.GetFullPath(filePath);
var root = Path.GetFullPath(dir).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
return full.StartsWith(root, StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
}
```
- [ ] **Step 2: Create `StartWorkerStep.cs`:**
```csharp
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StartWorkerStep : IInstallStep
{
public const string TaskName = "ClaudeDoWorker";
public string Name => "Start Worker";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report("Starting worker...");
var (exit, output) = await ProcessRunner.RunAsync("schtasks.exe", $"/Run /TN \"{TaskName}\"", null, progress, ct);
if (exit != 0)
return StepResult.Fail($"schtasks /Run failed (exit {exit}): {output}");
return StepResult.Ok();
}
}
```
- [ ] **Step 3:** Delete `src/ClaudeDo.Installer/Steps/StopServiceStep.cs`, `StartServiceStep.cs`, `RegisterServiceStep.cs`.
- [ ] **Step 4:** Grep for remaining references: `StopServiceStep`, `StartServiceStep`, `RegisterServiceStep` across `src/` — fix each (Tasks 6-9 cover them).
---
## Task 6: InstallContext + ServicePage cleanup
**Files:** Modify `src/ClaudeDo.Installer/Core/InstallContext.cs`, `Pages/ServicePage/ServicePageViewModel.cs`, `Pages/ServicePage/ServicePageView.xaml`
- [ ] **Step 1:** In `InstallContext.cs` remove the `ServiceAccount` property (keep `AutoStart`, `RestartDelayMs`, `SignalRPort`, `ClaudeBin`, etc.).
- [ ] **Step 2:** In `ServicePageViewModel.cs` remove `IsLocalSystem`/`IsCurrentUser` `[ObservableProperty]` fields and the `_context.ServiceAccount = ...` line in `ApplyAsync`. Keep port/claudeBin/autostart/restartDelay.
- [ ] **Step 3:** In `ServicePageView.xaml` remove the radio buttons / account-selection UI bound to those properties. Leave the rest.
- [ ] **Step 4:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds after Tasks 7-9.
---
## Task 7: Pipeline wiring + DI
**Files:** Modify `Pages/InstallPage/InstallPageViewModel.cs`, `App.xaml.cs`
- [ ] **Step 1:** In `InstallPageViewModel.LoadAsync`, update the **Update** display steps to:
```csharp
Steps.Add(new StepViewModel("Stop Worker"));
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Register Autostart"));
Steps.Add(new StepViewModel("Start Worker"));
Steps.Add(new StepViewModel("Write Install Manifest"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
```
And the **Fresh** display steps to:
```csharp
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Write Configuration"));
Steps.Add(new StepViewModel("Initialize Database"));
Steps.Add(new StepViewModel("Register Autostart"));
Steps.Add(new StepViewModel("Create Shortcuts"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
Steps.Add(new StepViewModel("Write Install Manifest"));
Steps.Add(new StepViewModel("Start Worker"));
```
- [ ] **Step 2:** In `RunInstallAsync`, set the Update execution list to:
```csharp
steps = new IInstallStep[]
{
_serviceProvider.GetRequiredService<StopWorkerStep>(),
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
_serviceProvider.GetRequiredService<RegisterAutostartStep>(),
_serviceProvider.GetRequiredService<StartWorkerStep>(),
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
_serviceProvider.GetRequiredService<WriteUninstallRegistryStep>(),
};
```
- [ ] **Step 3:** In `App.xaml.cs` `BuildServices`, replace the service-step registrations. Fresh-install `IInstallStep` order must be: Download, WriteConfig, InitDatabase, **RegisterAutostart**, CreateShortcuts, WriteUninstallRegistry, WriteInstallManifest, **StartWorker**. Register:
```csharp
sc.AddSingleton<DownloadAndExtractStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
sc.AddSingleton<RegisterAutostartStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterAutostartStep>());
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
sc.AddSingleton<WriteUninstallRegistryStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteUninstallRegistryStep>());
sc.AddSingleton<WriteInstallManifestStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
sc.AddSingleton<StartWorkerStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartWorkerStep>());
// Not part of the default fresh IEnumerable<IInstallStep> — pulled individually.
sc.AddSingleton<StopWorkerStep>();
```
Remove old `StopServiceStep`/`StartServiceStep`/`RegisterServiceStep` registrations.
- [ ] **Step 4:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds after Tasks 8-9.
---
## Task 8: SettingsViewModel + UninstallRunner
**Files:** Modify `Views/SettingsViewModel.cs`, `Core/UninstallRunner.cs`
- [ ] **Step 1:** In `SettingsViewModel.cs`, change ctor params/fields `StopServiceStep`/`StartServiceStep``StopWorkerStep`/`StartWorkerStep` (rename type usages only; the Save/Repair logic stays). Update the `Repair` step array to `{ _stopWorker, _downloadStep, _startWorker }`.
- [ ] **Step 2:** In `UninstallRunner.cs`:
- Constructor param `StopServiceStep``StopWorkerStep` (field too).
- Replace `sc.exe delete ClaudeDoWorker` with task removal + legacy service cleanup:
```csharp
// 3) Unregister autostart task + remove any legacy service.
progress.Report("Removing autostart task...");
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{StopWorkerStep.TaskName}\" /F", null, progress, ct);
await ProcessRunner.RunAsync("sc.exe", "delete ClaudeDoWorker", null, progress, ct); // legacy, best-effort
```
- The existing `_stopService.ExecuteAsync` call becomes `_stopWorker.ExecuteAsync` (kills the worker process before deleting files).
- [ ] **Step 3:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: **succeeds, 0 errors**.
- [ ] **Step 4:** Run installer tests: `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj` — Expected: all pass (incl. new `ScheduledTaskXmlTests`).
---
## Task 9: App WorkerLocator (TDD)
**Files:** Create `src/ClaudeDo.Ui/Services/WorkerLocator.cs`, Test `tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs`
- [ ] **Step 1: Write failing test:**
```csharp
using ClaudeDo.Ui.Services;
using Xunit;
namespace ClaudeDo.Ui.Tests.Services;
public class WorkerLocatorTests
{
[Fact]
public void FindByWalkingUp_FindsWorkerExeBesideInstallJson()
{
var root = Path.Combine(Path.GetTempPath(), "claudedo_wl_" + Guid.NewGuid().ToString("N"));
var appDir = Path.Combine(root, "app");
var workerDir = Path.Combine(root, "worker");
Directory.CreateDirectory(appDir);
Directory.CreateDirectory(workerDir);
File.WriteAllText(Path.Combine(root, "install.json"), "{}");
var exe = Path.Combine(workerDir, "ClaudeDo.Worker.exe");
File.WriteAllText(exe, "");
try
{
var found = new WorkerLocator().FindByWalkingUp(appDir);
Assert.Equal(exe, found);
}
finally { Directory.Delete(root, recursive: true); }
}
[Fact]
public void FindByWalkingUp_ReturnsNullWhenNoManifest()
{
var dir = Path.Combine(Path.GetTempPath(), "claudedo_wl_none_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try { Assert.Null(new WorkerLocator().FindByWalkingUp(dir)); }
finally { Directory.Delete(dir, recursive: true); }
}
}
```
- [ ] **Step 2: Run, verify fail:** `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter WorkerLocatorTests` — Expected: FAIL.
- [ ] **Step 3: Implement** (mirror `InstallerLocator`):
```csharp
namespace ClaudeDo.Ui.Services;
public sealed class WorkerLocator
{
private const string InstallJson = "install.json";
private const string WorkerExe = "ClaudeDo.Worker.exe";
private const string WorkerSubdir = "worker";
public string? Find()
=> FindByWalkingUp(AppContext.BaseDirectory)
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
public string? FindByWalkingUp(string startDir)
{
var dir = new DirectoryInfo(startDir);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, InstallJson)))
{
var candidate = Path.Combine(dir.FullName, WorkerSubdir, WorkerExe);
return File.Exists(candidate) ? candidate : null;
}
dir = dir.Parent;
}
return null;
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
public string? FindByRegistry()
{
if (!OperatingSystem.IsWindows()) return null;
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
var location = key?.GetValue("InstallLocation") as string;
if (string.IsNullOrEmpty(location)) return null;
var candidate = Path.Combine(location, WorkerSubdir, WorkerExe);
return File.Exists(candidate) ? candidate : null;
}
catch { return null; }
}
}
```
- [ ] **Step 4: Run, verify pass.**
---
## Task 10: App restart-worker + ensure-running
**Files:** Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, `src/ClaudeDo.App/Program.cs`
- [ ] **Step 1:** In `App/Program.cs` register the locator: `sc.AddSingleton<WorkerLocator>();` and ensure `IslandsShellViewModel` receives it (constructor injection; the VM is `AddSingleton<IslandsShellViewModel>()` so DI supplies it).
- [ ] **Step 2:** In `IslandsShellViewModel`, add a `WorkerLocator` constructor dependency and store it. Replace `RestartWorkerService` (the `ServiceController` version) with a process relaunch:
```csharp
private void RestartWorkerService()
{
var exe = _workerLocator.Find();
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
{
try { p.Kill(entireProcessTree: true); p.WaitForExit(10000); }
catch { /* may have exited */ }
finally { p.Dispose(); }
}
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
}
```
Update `RestartWorkerAsync` messages accordingly (drop the "service not installed" `InvalidOperationException` branch wording → generic failure).
- [ ] **Step 3:** Add ensure-running on startup. After the VM wires up the worker connection, schedule a one-shot check:
```csharp
private bool _ensureRunningAttempted;
private async Task EnsureWorkerRunningAsync()
{
if (_ensureRunningAttempted) return;
_ensureRunningAttempted = true;
await Task.Delay(TimeSpan.FromSeconds(4));
if (_worker.IsConnected) return;
var exe = _workerLocator.Find();
if (exe is null) return;
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); }
catch { /* logon task is the primary mechanism; this is a convenience */ }
}
```
Call `_ = EnsureWorkerRunningAsync();` from the VM's existing init path (where the connection is started). Use the actual `WorkerClient` field name and its `IsConnected` member.
- [ ] **Step 4:** Remove `using System.ServiceProcess;` and the `ServiceController` usage. Remove the `System.ServiceProcess.ServiceProcess` package reference from `ClaudeDo.Ui.csproj` if present and now unused.
- [ ] **Step 5:** Build: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` — Expected: succeeds.
- [ ] **Step 6:** Run UI tests: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj` — Expected: all pass (incl. `WorkerLocatorTests`). If `IslandsShellViewModel` construction is exercised in a test, supply a `WorkerLocator` instance.
---
## Task 11: Full build + test sweep
- [ ] **Step 1:** Build each project:
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
```
Expected: all succeed, 0 errors.
- [ ] **Step 2:** Run all test projects:
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj
dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj
```
Expected: all pass.
- [ ] **Step 3:** Grep for leftovers: `ServiceController`, `UseWindowsService`, `RegisterServiceStep`, `StopServiceStep`, `StartServiceStep`, `ServiceAccount` in `src/` — Expected: no matches (except the legacy `sc delete ClaudeDoWorker` migration/cleanup strings).
---
## Notes for the implementer
- Worker config property for the log directory: confirm the exact name on `WorkerConfig` (spec assumes `LogRoot`). Use the real one.
- `ProcessRunner.RunAsync` signature is `(string file, string args, string? workingDir, IProgress<string> progress, CancellationToken ct)` returning `(int ExitCode, string Output)` — match existing call sites.
- Keep the legacy `sc delete ClaudeDoWorker` calls (migration + uninstall) so existing service installs are cleaned up.

View File

@@ -0,0 +1,970 @@
# External MCP — UI Parity (Start & Observe) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add MCP tools so an external Claude session can fully *start* and *observe* ClaudeDo sessions (list/config management, run history, logs, agent listing, reset-failed, app-settings read), reaching UI parity for those concerns.
**Architecture:** New focused `[McpServerToolType]` classes in `src/ClaudeDo.Worker/External/`, each injecting an existing worker service (no logic duplication). All registered in the *external* `WebApplication` DI container in `Program.cs`. Mutations broadcast the same SignalR events the hub raises, keeping the UI in sync.
**Tech Stack:** .NET 8, `ModelContextProtocol.Server`, EF Core (SQLite), xUnit integration tests (real SQLite via `DbFixture`).
> **Build/test note (from project memory):** `dotnet build ClaudeDo.slnx` fails on .NET 8. Build the csproj directly:
> `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
> Test: `dotnet test tests/ClaudeDo.Worker.Tests`
---
## File Structure
**Create:**
- `src/ClaudeDo.Worker/External/ListMcpTools.cs` — list create/update/delete tools
- `src/ClaudeDo.Worker/External/ConfigMcpTools.cs` — list-config + task-config tools + DTO
- `src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs` — run history + log read tools + DTO
- `src/ClaudeDo.Worker/External/AgentMcpTools.cs` — agent listing tool
- `src/ClaudeDo.Worker/External/LifecycleMcpTools.cs` — reset-failed-task tool
- `src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs` — app-settings read tool
- `tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs`
- `tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs`
- `tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs`
- `tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs`
**Modify:**
- `src/ClaudeDo.Worker/Program.cs:188-217` — register new tool classes + services in the external builder
- `src/ClaudeDo.Worker/CLAUDE.md:27` — remove stale tag tools, refresh the External MCP tool inventory
**Reference (existing, do not change):**
- `ListRepository``AddAsync`, `UpdateAsync`, `DeleteAsync`, `GetByIdAsync`, `GetAllAsync`, `GetConfigAsync`, `SetConfigAsync`, `DeleteConfigAsync`
- `TaskRepository.UpdateAgentSettingsAsync(taskId, model?, systemPrompt?, agentPath?)`
- `TaskRunRepository``GetByTaskIdAsync`, `GetByIdAsync`, `GetLatestByTaskIdAsync`
- `TaskResetService.ResetAsync(taskId, ct)` — refuses Running, discards worktree, resets to Idle
- `AgentFileService.ScanAsync(ct)``List<AgentInfo>`; `AgentInfo(string Name, string Description, string Path)`
- `AppSettingsRepository.GetAsync()``AppSettingsEntity`
- `TaskRunEntity` fields: `Id, TaskId, RunNumber, SessionId, IsRetry, ResultMarkdown, StructuredOutputJson, ErrorMarkdown, ExitCode, TurnCount, TokensIn, TokensOut, LogPath, StartedAt, FinishedAt`
- `CommitTypeRegistry.DefaultType`
- `HubBroadcaster.ListUpdated(id)`, `.TaskUpdated(id)`
> **Spec refinement (YAGNI):** the spec listed an agent "refresh" tool. `AgentFileService.ScanAsync` reads disk fresh on every call, so a separate refresh is redundant for an MCP client. We implement `ListAgents` only.
---
## Task 1: List management tools (`ListMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/ListMcpTools.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
namespace ClaudeDo.Worker.Tests.External;
internal sealed class ListToolsHubClients : IHubClients
{
public ListToolsClientProxy Proxy { get; } = new();
public IClientProxy All => Proxy;
public IClientProxy AllExcept(IReadOnlyList<string> e) => Proxy;
public IClientProxy Client(string c) => Proxy;
public IClientProxy Clients(IReadOnlyList<string> c) => Proxy;
public IClientProxy Group(string g) => Proxy;
public IClientProxy GroupExcept(string g, IReadOnlyList<string> e) => Proxy;
public IClientProxy Groups(IReadOnlyList<string> g) => Proxy;
public IClientProxy User(string u) => Proxy;
public IClientProxy Users(IReadOnlyList<string> u) => Proxy;
}
internal sealed class ListToolsClientProxy : IClientProxy
{
public Task SendCoreAsync(string m, object?[] a, CancellationToken ct = default) => Task.CompletedTask;
}
internal sealed class ListToolsHubContext : IHubContext<WorkerHub>
{
public ListToolsHubClients RecordingClients { get; } = new();
public IHubClients Clients => RecordingClients;
public IGroupManager Groups => throw new NotImplementedException();
}
public sealed class ListMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly ListRepository _lists;
private readonly ListMcpTools _sut;
public ListMcpToolsTests()
{
_ctx = _db.CreateContext();
_lists = new ListRepository(_ctx);
_sut = new ListMcpTools(_lists, new HubBroadcaster(new ListToolsHubContext()));
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
[Fact]
public async Task CreateList_PersistsWithDefaults()
{
var dto = await _sut.CreateList("My List", null, null, CancellationToken.None);
Assert.Equal("My List", dto.Name);
var loaded = await _lists.GetByIdAsync(dto.Id);
Assert.NotNull(loaded);
Assert.Equal("chore", loaded!.DefaultCommitType);
}
[Fact]
public async Task UpdateList_PatchesNameWorkingDirAndCommitType()
{
var created = await _sut.CreateList("orig", null, null, CancellationToken.None);
var dto = await _sut.UpdateList(created.Id, "renamed", "C:/work", "feat", CancellationToken.None);
Assert.Equal("renamed", dto.Name);
Assert.Equal("C:/work", dto.WorkingDir);
var loaded = await _lists.GetByIdAsync(created.Id);
Assert.Equal("feat", loaded!.DefaultCommitType);
}
[Fact]
public async Task UpdateList_NotFound_Throws()
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.UpdateList("missing", "x", null, null, CancellationToken.None));
}
[Fact]
public async Task DeleteList_RemovesList()
{
var created = await _sut.CreateList("gone", null, null, CancellationToken.None);
await _sut.DeleteList(created.Id, CancellationToken.None);
Assert.Null(await _lists.GetByIdAsync(created.Id));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ListMcpToolsTests`
Expected: FAIL — `ListMcpTools` does not exist (compile error).
- [ ] **Step 3: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record ListSummaryDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
[McpServerToolType]
public sealed class ListMcpTools
{
private readonly ListRepository _lists;
private readonly HubBroadcaster _broadcaster;
public ListMcpTools(ListRepository lists, HubBroadcaster broadcaster)
{
_lists = lists;
_broadcaster = broadcaster;
}
[McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")]
public async Task<ListSummaryDto> CreateList(
string name, string? workingDir, string? commitType, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(name))
throw new InvalidOperationException("name is required.");
var entity = new ListEntity
{
Id = Guid.NewGuid().ToString(),
Name = name,
WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir,
DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType,
CreatedAt = DateTime.UtcNow,
};
await _lists.AddAsync(entity, cancellationToken);
await _broadcaster.ListUpdated(entity.Id);
return ToDto(entity);
}
[McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")]
public async Task<ListSummaryDto> UpdateList(
string listId, string? name, string? workingDir, string? commitType, CancellationToken cancellationToken)
{
var entity = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
if (name is not null) entity.Name = name;
if (workingDir is not null)
entity.WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir;
if (commitType is not null)
entity.DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType;
await _lists.UpdateAsync(entity, cancellationToken);
await _broadcaster.ListUpdated(listId);
return ToDto(entity);
}
[McpServerTool, Description("Delete a list and its tasks. Irreversible.")]
public async Task DeleteList(string listId, CancellationToken cancellationToken)
{
_ = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
await _lists.DeleteAsync(listId, cancellationToken);
await _broadcaster.ListUpdated(listId);
}
private static ListSummaryDto ToDto(ListEntity l) =>
new(l.Id, l.Name, l.WorkingDir, l.DefaultCommitType);
}
```
> If `CommitTypeRegistry` is not in scope, add `using ClaudeDo.Data;` (verify its namespace with a quick grep before assuming).
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ListMcpToolsTests`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ListMcpTools.cs tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs
git commit -m "feat(worker): add external MCP list-management tools"
```
---
## Task 2: List & task config tools (`ConfigMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/ConfigMcpTools.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.External;
public sealed class ConfigMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly ListRepository _lists;
private readonly TaskRepository _tasks;
private readonly ConfigMcpTools _sut;
public ConfigMcpToolsTests()
{
_ctx = _db.CreateContext();
_lists = new ListRepository(_ctx);
_tasks = new TaskRepository(_ctx);
_sut = new ConfigMcpTools(_lists, _tasks, new HubBroadcaster(new ListToolsHubContext()));
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private async Task<string> SeedListAsync()
{
var id = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
return id;
}
[Fact]
public async Task SetAndGetListConfig_RoundTrips()
{
var listId = await SeedListAsync();
await _sut.SetListConfig(listId, "sonnet", "be terse", null, CancellationToken.None);
var cfg = await _sut.GetListConfig(listId, CancellationToken.None);
Assert.NotNull(cfg);
Assert.Equal("sonnet", cfg!.Model);
Assert.Equal("be terse", cfg.SystemPrompt);
Assert.Null(cfg.AgentPath);
}
[Fact]
public async Task SetListConfig_AllNull_ClearsConfig()
{
var listId = await SeedListAsync();
await _sut.SetListConfig(listId, "sonnet", null, null, CancellationToken.None);
await _sut.SetListConfig(listId, null, null, null, CancellationToken.None);
Assert.Null(await _sut.GetListConfig(listId, CancellationToken.None));
}
[Fact]
public async Task SetTaskConfig_PersistsOverrides()
{
var listId = await SeedListAsync();
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "t",
Status = ClaudeDo.Data.Models.TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
CommitType = "chore",
};
await _tasks.AddAsync(task);
await _sut.SetTaskConfig(task.Id, "opus", null, null, CancellationToken.None);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal("opus", loaded!.Model);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ConfigMcpToolsTests`
Expected: FAIL — `ConfigMcpTools` does not exist.
- [ ] **Step 3: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
[McpServerToolType]
public sealed class ConfigMcpTools
{
private readonly ListRepository _lists;
private readonly TaskRepository _tasks;
private readonly HubBroadcaster _broadcaster;
public ConfigMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster)
{
_lists = lists;
_tasks = tasks;
_broadcaster = broadcaster;
}
[McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns null if no config is set.")]
public async Task<TaskConfigDto?> GetListConfig(string listId, CancellationToken cancellationToken)
{
var cfg = await _lists.GetConfigAsync(listId, cancellationToken);
return cfg is null ? null : new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath);
}
[McpServerTool, Description("Set a list's default model/system prompt/agent path. Passing all three as null clears the list config.")]
public async Task SetListConfig(
string listId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
{
_ = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
var m = Nullify(model);
var sp = Nullify(systemPrompt);
var ap = Nullify(agentPath);
if (m is null && sp is null && ap is null)
await _lists.DeleteConfigAsync(listId, cancellationToken);
else
await _lists.SetConfigAsync(new ListConfigEntity
{
ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap,
}, cancellationToken);
await _broadcaster.ListUpdated(listId);
}
[McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path). Pass null to clear a field.")]
public async Task SetTaskConfig(
string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
{
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
await _tasks.UpdateAgentSettingsAsync(taskId, Nullify(model), Nullify(systemPrompt), Nullify(agentPath), cancellationToken);
await _broadcaster.TaskUpdated(taskId);
}
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
}
```
> Verify `UpdateAgentSettingsAsync` accepts a `CancellationToken` (read `TaskRepository.cs:157`). If it does not, drop the `cancellationToken` argument from that call.
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ConfigMcpToolsTests`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ConfigMcpTools.cs tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs
git commit -m "feat(worker): add external MCP list/task config tools"
```
---
## Task 3: Run history & log tools (`RunHistoryMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.External;
public sealed class RunHistoryMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRunRepository _runs;
private readonly RunHistoryMcpTools _sut;
public RunHistoryMcpToolsTests()
{
_ctx = _db.CreateContext();
_runs = new TaskRunRepository(_ctx);
_sut = new RunHistoryMcpTools(_runs);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private async Task SeedTaskAsync(string taskId)
{
var lists = new ListRepository(_ctx);
var tasks = new TaskRepository(_ctx);
var listId = Guid.NewGuid().ToString();
await lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
await tasks.AddAsync(new TaskEntity
{
Id = taskId, ListId = listId, Title = "t",
Status = ClaudeDo.Data.Models.TaskStatus.Done, CreatedAt = DateTime.UtcNow, CommitType = "chore",
});
}
[Fact]
public async Task ListRuns_ReturnsProjectedRuns()
{
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId);
await _runs.AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
IsRetry = false, Prompt = "p", ResultMarkdown = "done", TokensIn = 10, TokensOut = 20,
});
var list = await _sut.ListRuns(taskId, CancellationToken.None);
Assert.Single(list);
Assert.Equal("done", list[0].ResultMarkdown);
Assert.Equal(10, list[0].TokensIn);
}
[Fact]
public async Task GetTaskLog_NoLog_Throws()
{
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.GetTaskLog(taskId, CancellationToken.None));
}
[Fact]
public async Task GetTaskLog_ReadsLatestRunLogFile()
{
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId);
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
await File.WriteAllTextAsync(logPath, "hello log");
await _runs.AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
IsRetry = false, Prompt = "p", LogPath = logPath,
});
var content = await _sut.GetTaskLog(taskId, CancellationToken.None);
Assert.Equal("hello log", content);
File.Delete(logPath);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter RunHistoryMcpToolsTests`
Expected: FAIL — `RunHistoryMcpTools` does not exist.
- [ ] **Step 3: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record RunDto(
string Id, int RunNumber, string? SessionId, bool IsRetry,
string? ResultMarkdown, string? StructuredOutputJson, string? ErrorMarkdown,
int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut,
DateTime? StartedAt, DateTime? FinishedAt);
[McpServerToolType]
public sealed class RunHistoryMcpTools
{
private readonly TaskRunRepository _runs;
public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs;
[McpServerTool, Description("List all execution runs for a task (newest run metadata, tokens, turns, result, error).")]
public async Task<IReadOnlyList<RunDto>> ListRuns(string taskId, CancellationToken cancellationToken)
{
var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken);
return runs.Select(ToDto).ToList();
}
[McpServerTool, Description("Get a single execution run by its run id.")]
public async Task<RunDto> GetRun(string runId, CancellationToken cancellationToken)
{
var run = await _runs.GetByIdAsync(runId, cancellationToken)
?? throw new InvalidOperationException($"Run {runId} not found.");
return ToDto(run);
}
[McpServerTool, Description("Fetch the raw log output of a task's latest run. Throws if no log is available.")]
public async Task<string> GetTaskLog(string taskId, CancellationToken cancellationToken)
{
var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"No runs found for task {taskId}.");
if (string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
throw new InvalidOperationException("No log available for the latest run.");
return await File.ReadAllTextAsync(run.LogPath, cancellationToken);
}
private static RunDto ToDto(TaskRunEntity r) => new(
r.Id, r.RunNumber, r.SessionId, r.IsRetry,
r.ResultMarkdown, r.StructuredOutputJson, r.ErrorMarkdown,
r.ExitCode, r.TurnCount, r.TokensIn, r.TokensOut,
r.StartedAt, r.FinishedAt);
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter RunHistoryMcpToolsTests`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs
git commit -m "feat(worker): add external MCP run-history and log tools"
```
---
## Task 4: Agent listing tool (`AgentMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/AgentMcpTools.cs`
- Test: none new — covered indirectly; `AgentFileService` already has unit coverage. (This tool is a thin pass-through.)
- [ ] **Step 1: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Agents;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
[McpServerToolType]
public sealed class AgentMcpTools
{
private readonly AgentFileService _agents;
public AgentMcpTools(AgentFileService agents) => _agents = agents;
[McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")]
public async Task<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
=> await _agents.ScanAsync(cancellationToken);
}
```
- [ ] **Step 2: Verify it compiles**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/External/AgentMcpTools.cs
git commit -m "feat(worker): add external MCP agent-listing tool"
```
---
## Task 5: Reset-failed-task tool (`LifecycleMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/LifecycleMcpTools.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs`
`TaskResetService.ResetAsync` already refuses Running tasks and discards the worktree. The MCP tool adds a guard that the task must be `Failed` (the only sensible reset target via this surface) and delegates.
- [ ] **Step 1: Write the failing test**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Tests.Infrastructure;
using ClaudeDo.Worker.Tests.Services;
using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Config;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.External;
public sealed class LifecycleMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
public LifecycleMcpToolsTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private LifecycleMcpTools BuildSut()
{
var cfg = new WorkerConfig
{
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"),
LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"),
};
var dbFactory = _db.CreateFactory();
var broadcaster = new HubBroadcaster(new ListToolsHubContext());
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger<TaskResetService>.Instance);
return new LifecycleMcpTools(_tasks, reset);
}
private async Task<TaskEntity> SeedTaskAsync(TaskStatus status)
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t",
Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore",
};
await _tasks.AddAsync(task);
return task;
}
[Fact]
public async Task ResetFailedTask_OnFailed_ResetsToIdle()
{
var task = await SeedTaskAsync(TaskStatus.Failed);
var sut = BuildSut();
await sut.ResetFailedTask(task.Id, CancellationToken.None);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Idle, loaded!.Status);
}
[Fact]
public async Task ResetFailedTask_OnNonFailed_Throws()
{
var task = await SeedTaskAsync(TaskStatus.Done);
var sut = BuildSut();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ResetFailedTask(task.Id, CancellationToken.None));
}
[Fact]
public async Task ResetFailedTask_NotFound_Throws()
{
var sut = BuildSut();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ResetFailedTask("missing", CancellationToken.None));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter LifecycleMcpToolsTests`
Expected: FAIL — `LifecycleMcpTools` does not exist.
- [ ] **Step 3: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Lifecycle;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
[McpServerToolType]
public sealed class LifecycleMcpTools
{
private readonly TaskRepository _tasks;
private readonly TaskResetService _reset;
public LifecycleMcpTools(TaskRepository tasks, TaskResetService reset)
{
_tasks = tasks;
_reset = reset;
}
[McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted.")]
public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status != TaskStatus.Failed)
throw new InvalidOperationException($"Task {taskId} is {task.Status}, not Failed. Only failed tasks can be reset via this tool.");
await _reset.ResetAsync(taskId, cancellationToken);
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter LifecycleMcpToolsTests`
Expected: PASS (3 tests). (Git-dependent worktree discard is skipped when no worktree row exists — these tasks have none.)
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/LifecycleMcpTools.cs tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs
git commit -m "feat(worker): add external MCP reset-failed-task tool"
```
---
## Task 6: App-settings read tool (`AppSettingsMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs`
- Test: none new — thin read-only pass-through over `AppSettingsRepository.GetAsync`.
This tool is read-only by design (writing app settings is out of scope). It uses the db factory (registered as a singleton in the external builder) to open a context per call, mirroring the hub's pattern.
- [ ] **Step 1: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record AppSettingsReadDto(
string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode,
string WorktreeStrategy, string? CentralWorktreeRoot,
bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays);
[McpServerToolType]
public sealed class AppSettingsMcpTools
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
[McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")]
public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken)
{
using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var row = await new AppSettingsRepository(ctx).GetAsync();
return new AppSettingsReadDto(
row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode,
row.WorktreeStrategy, row.CentralWorktreeRoot,
row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays);
}
}
```
> Verify `AppSettingsRepository.GetAsync` signature (it may take a `CancellationToken`). Adjust the call if so. Confirm `AppSettingsEntity` property names match (`DefaultModel`, `DefaultMaxTurns`, `DefaultPermissionMode`, `WorktreeStrategy`, `CentralWorktreeRoot`, `WorktreeAutoCleanupEnabled`, `WorktreeAutoCleanupDays`) — they are used identically in `WorkerHub.GetAppSettings` (lines 206-219).
- [ ] **Step 2: Verify it compiles**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs
git commit -m "feat(worker): add external MCP app-settings read tool"
```
---
## Task 7: Register new tools in the external MCP app
**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs:188-217`
The external `WebApplication` has its own DI container. Each new tool class and every service it needs must be registered there, and each class added via `.WithTools<T>()`.
- [ ] **Step 1: Add service + tool registrations**
In the `if (cfg.ExternalMcpPort > 0)` block, after the existing
`externalBuilder.Services.AddScoped<ExternalMcpService>();` line, add:
```csharp
externalBuilder.Services.AddScoped<TaskRunRepository>();
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
externalBuilder.Services.AddScoped<TaskResetService>();
externalBuilder.Services.AddScoped<ListMcpTools>();
externalBuilder.Services.AddScoped<ConfigMcpTools>();
externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
externalBuilder.Services.AddScoped<AgentMcpTools>();
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
```
And extend the `AddMcpServer()` chain:
```csharp
externalBuilder.Services.AddMcpServer()
.WithHttpTransport()
.WithTools<ExternalMcpService>()
.WithTools<ListMcpTools>()
.WithTools<ConfigMcpTools>()
.WithTools<RunHistoryMcpTools>()
.WithTools<AgentMcpTools>()
.WithTools<LifecycleMcpTools>()
.WithTools<AppSettingsMcpTools>();
```
> **Verify before editing:** confirm `WorktreeManager` and `AgentFileService` are registered as singletons in the *main* `app` container (grep `Program.cs` for `WorktreeManager` and `AgentFileService`). If `AgentFileService` is constructed with a directory string rather than DI-resolved, register it in the external builder the same way the main app does (e.g. `new AgentFileService(agentsDir)`), not via `GetRequiredService`. `TaskResetService` depends on `WorktreeManager`, `IDbContextFactory`, `HubBroadcaster`, `ITaskStateService`, `ILogger<TaskResetService>` — all already singletons in the external builder except `WorktreeManager` (added above) and the logger (provided by default logging).
- [ ] **Step 2: Build the worker**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded, no DI-related compile errors.
- [ ] **Step 3: Run the full worker test suite**
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
Expected: PASS (all existing + new tests).
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): register new external MCP tool classes"
```
---
## Task 8: Documentation cleanup
**Files:**
- Modify: `src/ClaudeDo.Worker/CLAUDE.md:27`
- [ ] **Step 1: Replace the stale External MCP inventory line**
Replace the line beginning `- **External/ExternalMcpService** — always-on MCP tools…` with an accurate inventory that drops the (non-existent) tag tools and lists the new surface:
```markdown
- **External/*** — always-on MCP tools for general Claude sessions, organized by concern:
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus` (`Idle`/`Queued`), `RunTaskNow`, `CancelTask`, `DeleteTask`
- `ListMcpTools``CreateList`, `UpdateList`, `DeleteList`
- `ConfigMcpTools``GetListConfig`, `SetListConfig`, `SetTaskConfig`
- `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog`
- `AgentMcpTools``ListAgents`
- `LifecycleMcpTools``ResetFailedTask`
- `AppSettingsMcpTools``GetAppSettings` (read-only)
- Purpose is scoped to *starting* and *observing* sessions — no worktree/merge, multi-turn, planning, or app-settings writes. Auth via optional `X-ClaudeDo-Key` header.
```
- [ ] **Step 2: Commit**
```bash
git add src/ClaudeDo.Worker/CLAUDE.md
git commit -m "docs(worker): correct external MCP tool inventory, drop removed tags"
```
---
## Self-Review
**Spec coverage:**
- List management → Task 1 ✓
- List & task config → Task 2 ✓
- Run history & logs → Task 3 ✓
- Agents (read-only) → Task 4 ✓
- Reset failed task → Task 5 ✓
- App settings (read-only) → Task 6 ✓
- DI wiring (separate external app) → Task 7 ✓
- Tag doc cleanup → Task 8 ✓
- Out-of-scope items (multi-turn, worktree ops, planning, app-settings writes, tags, agent create/edit) → not implemented ✓
**Placeholder scan:** No TBD/TODO. The three "verify before editing" notes point at real signatures the implementer must confirm (cancellation-token overloads, `AgentFileService` construction, registry namespaces) — these are verification steps with concrete fallbacks, not placeholders.
**Type consistency:** `ListSummaryDto`, `TaskConfigDto`, `RunDto`, `AppSettingsReadDto` defined once and used consistently. `AgentInfo` reused directly (no new DTO). Tool method names match between implementation, tests, and the Task-8 doc inventory (`CreateList`/`UpdateList`/`DeleteList`, `GetListConfig`/`SetListConfig`/`SetTaskConfig`, `ListRuns`/`GetRun`/`GetTaskLog`, `ListAgents`, `ResetFailedTask`, `GetAppSettings`).

View File

@@ -0,0 +1,36 @@
# UI Normalization — Visual Check
Run the app and walk each surface. Lane B intentionally shifted some values (12px→13px, 9px→10px, 16px→18px, off-palette colors folded to the palette), so small differences are expected — you're checking nothing looks *broken*.
## Global
- [ ] All text renders in **Inter Tight** (sans), not Segoe UI. Labels that were previously "off" (Settings field labels) now match.
- [ ] Mono text (chips, log lines, file paths, eyebrows, titlebar titles) still renders in JetBrains Mono.
## Main window
- [ ] Status-bar connection dot color: online = moss green, reconnecting = peat/amber, offline = blood red.
- [ ] Islands, task rows, chips, agent strips, terminal all look unchanged.
## Task row
- [ ] Schedule flyout (the date popup) renders with a visible border (was a broken/missing `BorderBrush` key — now `LineBrush`).
## Modals — now wrapped in ModalShell (check titlebar drag, ✕ close, footer buttons)
- [ ] **Settings** — titlebar "SETTINGS", drag works, ✕ closes, Cancel/Save footer. Tabs (General/Worktrees/Files/Prime Claude) intact.
- [ ] **List settings** — Delete (left) + Cancel/Save (right) footer; section panels intact.
- [ ] **Merge** — task summary + action buttons.
- [ ] **About** — version/data/logs/config labels.
- [ ] **Unfinished planning** — body text + primary action.
- [ ] **Repo import** — toolbar at top of body, repo list scrolls, footer.
- [ ] **Worktrees overview** — rows render; force-remove/phantom text is red (StatusError); state badge text legible. NOTE: window decorations changed to borderless (ModalShell draws the border) — confirm it still looks right.
- [ ] **Diff modal** — diff text mono, add/del colors, merge button in footer.
- [ ] **Conflict resolution** — now ModalShell; conflict list mono; error text red.
## Not wrapped in ModalShell (intentional — distinct chrome)
- [ ] **Worktree modal** (the big 1100×720 acrylic-blur diff window) — unchanged look, fonts slightly normalized.
- [ ] **Planning diff view** (embedded) — diff renders, mono font, warning text red.
## Date picker
- [ ] Selected day: accent background with light text (was hardcoded white → TextBrush).
## If something looks wrong
- Font/size off → check the snap mapping in `2026-05-30-ui-normalization.md` (11→Mono=11, 12→Body=13).
- A modal's layout broke → that modal's body may have coupled to the old Grid rows; revert just that file's ModalShell wrap and keep only the token changes (the fallback noted in the plan).

View File

@@ -0,0 +1,473 @@
# UI Normalization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the design tokens the single source of truth for every visual value in the Avalonia UI, remove duplicated styles, and add a reusable `ModalShell` control for the copy-pasted modal chrome.
**Architecture:** Establish global control defaults in `App.axaml`, expand/repoint brushes in `Tokens.axaml`, promote shared styles into `IslandStyles.axaml`, then mechanically migrate every view to reference tokens (snapping stray values to the nearest token per "lane B"). Off-palette colors fold into the existing palette. A new `ModalShell` templated control replaces the per-modal titlebar/border/footer markup.
**Tech Stack:** .NET 8, Avalonia 12 (Fluent theme, dark variant), compiled XAML (`x:DataType`), CommunityToolkit.Mvvm.
**Verification model:** There are no unit tests for XAML. The "test" for every task is a clean build:
- `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` (compiles Ui + Data; validates all StaticResource keys and compiled bindings)
Build with the `.csproj` directly — `.slnx` requires .NET 9 and will fail on this machine (.NET 8).
**Normalization rules (apply everywhere unless a task says otherwise):**
Font sizes — replace every `FontSize="N"` literal with the token whose value it snaps to:
| literal | token |
|---|---|
| 9 | `{StaticResource FontSizeEyebrow}` (10) |
| 10 | `{StaticResource FontSizeEyebrow}` (10) |
| 11 | `{StaticResource FontSizeMono}` (11) |
| 12 | `{StaticResource FontSizeBody}` (13) |
| 13 | `{StaticResource FontSizeBody}` (13) |
| 14 | `{StaticResource FontSizeTaskTitle}` (14) |
| 16 | `{StaticResource FontSizeH3}` (18) |
| 18 | `{StaticResource FontSizeH3}` (18) |
| 24 | `{StaticResource FontSizeH2}` (24) |
| 32 | `{StaticResource FontSizeH1}` (32) |
Spacing — modal body padding literals `16` and `20` snap to `18`; keep other axis values mapped to the nearest of SpaceXs=4/SpaceSm=8/SpaceMd=12/SpaceLg=14/SpaceXl=18/Space2Xl=24. Leave values that already equal a token as plain numbers (do **not** churn every margin into a resource ref — only modal body padding is standardized).
Corner radius — `4``6`; TextBox inputs use `8`.
Colors — fold off-palette to palette:
| literal / named | replacement |
|---|---|
| `#4CAF50` (online dot) | `{DynamicResource StatusRunningBrush}` |
| `#FFA726` (reconnecting dot) | `{DynamicResource StatusReviewBrush}` |
| `#EF5350` (offline / phantom) | `{DynamicResource StatusErrorBrush}` |
| `OrangeRed`, `Orange` | `{DynamicResource BloodBrush}` |
| `White` (badge / danger text) | `{DynamicResource TextBrush}` |
| `White` (on accent primary button) | `{DynamicResource DeepBrush}` |
| `#FF080C0B` (terminal bg) | `{DynamicResource VoidBrush}` |
| `#0DFFFFFF` (island hairline) | `{DynamicResource HairlineOverlayBrush}` |
---
## Phase 1 — Foundation
### Task 1: Add new brushes & repoint badges in Tokens.axaml
**Files:**
- Modify: `src/ClaudeDo.Ui/Design/Tokens.axaml`
- [ ] **Step 1: Add named tint, hairline brushes**
In the BRUSHES section (after the Status*Brush block ending ~line 85), add:
```xml
<!-- Subtle white overlay (island hairline border) -->
<SolidColorBrush x:Key="HairlineOverlayBrush" Color="#0DFFFFFF" />
<!-- Status tints (12% fill / 30% border of the status hue) — reused by chips & agent strips -->
<SolidColorBrush x:Key="RunningTintBrush" Color="#1F7C9166" />
<SolidColorBrush x:Key="RunningTintBorderBrush" Color="#4C7C9166" />
<SolidColorBrush x:Key="ReviewTintBrush" Color="#1FD4A574" />
<SolidColorBrush x:Key="ReviewTintBorderBrush" Color="#4CD4A574" />
<SolidColorBrush x:Key="ErrorTintBrush" Color="#1FC87060" />
<SolidColorBrush x:Key="ErrorTintBorderBrush" Color="#4CC87060" />
<SolidColorBrush x:Key="QueuedTintBrush" Color="#1F8B9D7A" />
<SolidColorBrush x:Key="QueuedTintBorderBrush" Color="#4C8B9D7A" />
```
- [ ] **Step 2: Build to verify tokens parse**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS (no errors).
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Design/Tokens.axaml
git commit -m "feat(ui): add named tint and hairline overlay brush tokens"
```
---
### Task 2: Global control defaults in App.axaml
**Files:**
- Modify: `src/ClaudeDo.App/App.axaml`
- [ ] **Step 1: Add Window default style**
Inside `<Application.Styles>`, after `<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />` and before the ListBoxItem styles, add:
```xml
<!-- Global defaults: every Window inherits Inter Tight + body size.
Controls that need mono opt in via their own class/style. -->
<Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource SansFont}" />
<Setter Property="FontSize" Value="{DynamicResource FontSizeBody}" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
</Style>
```
(FontFamily/FontSize/Foreground are inherited properties in Avalonia, so setting them on the Window root propagates to all descendant text controls.)
- [ ] **Step 2: Build**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.App/App.axaml
git commit -m "feat(ui): set global Inter Tight font default on all windows"
```
---
### Task 3: Promote shared styles into IslandStyles.axaml
**Files:**
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
- [ ] **Step 1: Add shared modal styles**
At the end of the `<Styles>` element (before the closing `</Styles>` at line ~901), add:
```xml
<!-- ============================================================ -->
<!-- SHARED MODAL STYLES (promoted from per-modal Window.Styles) -->
<!-- ============================================================ -->
<Style Selector="TextBlock.field-label">
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
<Setter Property="Margin" Value="0,0,0,4" />
</Style>
<Style Selector="TextBlock.path-mono">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<!-- Standalone modal action buttons (not the .btn family) -->
<Style Selector="Button.primary">
<Setter Property="Background" Value="{StaticResource AccentDimBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.danger">
<Setter Property="Background" Value="{StaticResource BloodBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
```
Note: `TextBlock.section-label` already exists at line ~864 — do NOT re-add it.
- [ ] **Step 2: Replace hardcoded values inside existing IslandStyles rules**
Apply the normalization rules to the existing style setters in this file:
- Every `FontSize="N"` setter → the snapped token ref (table above). Specific lines: 149 (10→FontSizeEyebrow), 206 (11→FontSizeMono), 252 (13→FontSizeBody), 397 (11→FontSizeMono), 453 (9→FontSizeEyebrow), 475 (10→FontSizeEyebrow), 483 (10→FontSizeEyebrow), 556 (12→FontSizeBody), 573 (9→FontSizeEyebrow), 597 (12→FontSizeBody), 622 (10→FontSizeEyebrow), 638 (12→FontSizeBody), 697 (14→FontSizeTaskTitle), 771 (10→FontSizeEyebrow), 783 (10→FontSizeEyebrow), 788 (10→FontSizeEyebrow), 819 (11→FontSizeMono), 867 (10→FontSizeEyebrow), 884 (9→FontSizeEyebrow).
- Chip tint backgrounds/borders → named brushes:
- line 155/156 `#1F7C9166`/`#4C7C9166``{StaticResource RunningTintBrush}`/`{StaticResource RunningTintBorderBrush}`
- 163/164 review tints → `ReviewTintBrush`/`ReviewTintBorderBrush`
- 171/172 error tints → `ErrorTintBrush`/`ErrorTintBorderBrush`
- 179/180 queued tints → `QueuedTintBrush`/`QueuedTintBorderBrush`
- agent-strip tints at 361/362 (`#147C9166`/`#4C7C9166`), 365/366, 368/369, 374/375 → the matching `*TintBrush`/`*TintBorderBrush` (snap the `#14` alpha to the shared `#1F` tint).
- line 123 `#0DFFFFFF``{StaticResource HairlineOverlayBrush}`.
- line 389 & 810 `#FF080C0B``{StaticResource VoidBrush}`.
- line 887 badge `White``{StaticResource TextBrush}`.
- Badge brushes at lines 88-90: replace the three `<SolidColorBrush>` definitions with palette refs:
```xml
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="{StaticResource SageColor}"/>
```
- Corner radius `4` setters (447 live-chip, 813 task-live-tail `5`→leave, badges 878 `3`→leave) → only snap `4``6` where it appears as `CornerRadius="4"` on live-chip (447) and kbd (614) and badge tints. Leave `3` and `5` as-is (no nearby token; they're intentional micro-radii). NOTE: if unsure, leave radius alone — radius churn is lowest priority.
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml
git commit -m "refactor(ui): tokenize IslandStyles values and add shared modal styles"
```
---
## Phase 2 — Per-view token migration (independent; parallelizable)
For each task: open the file, apply the **normalization rules** (font/color/spacing/radius tables at top). Remove any local `Window.Styles` block that only redefines `section-label`, `field-label`, `path-mono`, `Button.primary`, or `Button.danger` (now shared from IslandStyles). Keep local styles that are genuinely unique to that view. After each file, build and commit.
Each task ends with:
- Build: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` → PASS
- Commit: `git add <file> && git commit -m "refactor(ui): tokenize <view>"`
### Task 4: MainWindow.axaml
- Snap all `FontSize` literals (lines ~46,52,59,67,112,136,209,222,231).
- Status dots: `#4CAF50``StatusRunningBrush`, `#FFA726``StatusReviewBrush`, `#EF5350``StatusErrorBrush` (lines ~200,203,205).
### Task 5: Islands — ListsIslandView.axaml, TasksIslandView.axaml
- ListsIslandView: snap FontSize (18,10,12 at lines ~18,49,57,58,59); username TextBlock (~57) gets no explicit FontFamily (inherits SansFont now — correct, leave it).
- TasksIslandView: snap FontSize (24,11 at ~15,19).
### Task 6: DetailsIslandView.axaml
- Snap all FontSize (10,14,11,10,13,12 at lines ~54,57,92,114,138,142,199,269).
- `OrangeRed``BloodBrush` (~154).
- TextBox `CornerRadius="6"``8` (~172,274). TextBox `Padding="8"` leave.
- Remove any redundant inline label styles superseded by shared `field-label`.
### Task 7: TaskRowView.axaml (includes the BorderBrush bug fix)
- Snap FontSize (10,14 at ~85,103).
- **Bug fix:** `BorderBrush="{DynamicResource BorderBrush}"``{DynamicResource LineBrush}` (the schedule-flyout border, ~line 188/222). `BorderBrush` is not a defined key.
- Schedule flyout: title/labels inherit SansFont now (leave unset).
### Task 8: AgentStripView.axaml, SessionTerminalView.axaml
- AgentStrip: snap FontSize (10,9 at ~22,29,73,78); commit chip radius `4``6` (~102).
- SessionTerminal: snap FontSize (10,11 at ~17,69).
### Task 9: ThemedDatePicker.axaml
- Snap any FontSize literals; popup border `CornerRadius="10"` → leave (10 = ChipCornerRadius value, acceptable) OR `{StaticResource ChipCornerRadius}`. Tokenize colors if any literals present.
---
## Phase 3 — ModalShell control
### Task 10: Create ModalShell control
**Files:**
- Create: `src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml.cs`
- Create: `src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml`
- [ ] **Step 1: Write the code-behind (templated control)**
`ModalShell.axaml.cs`:
```csharp
using System;
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
namespace ClaudeDo.Ui.Views.Controls;
/// <summary>Reusable modal chrome: titlebar (drag + close) wrapping a body and optional footer.</summary>
public class ModalShell : ContentControl
{
public static readonly StyledProperty<string?> TitleProperty =
AvaloniaProperty.Register<ModalShell, string?>(nameof(Title));
public static readonly StyledProperty<object?> FooterProperty =
AvaloniaProperty.Register<ModalShell, object?>(nameof(Footer));
public static readonly StyledProperty<ICommand?> CloseCommandProperty =
AvaloniaProperty.Register<ModalShell, ICommand?>(nameof(CloseCommand));
public string? Title { get => GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
public object? Footer { get => GetValue(FooterProperty); set => SetValue(FooterProperty, value); }
public ICommand? CloseCommand { get => GetValue(CloseCommandProperty); set => SetValue(CloseCommandProperty, value); }
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (e.NameScope.Find<Border>("PART_TitleBar") is { } bar)
bar.PointerPressed += OnTitleBarPressed;
}
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed
&& VisualRoot is Window w)
w.BeginMoveDrag(e);
}
}
```
- [ ] **Step 2: Write the ControlTheme**
`ModalShell.axaml`:
```xml
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls">
<ControlTheme x:Key="{x:Type ctl:ModalShell}" TargetType="ctl:ModalShell">
<Setter Property="Template">
<ControlTemplate>
<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource ModalCornerRadius}"
ClipToBounds="True">
<DockPanel>
<!-- Title bar -->
<Border Name="PART_TitleBar" DockPanel.Dock="Top" Height="36"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="{TemplateBinding Title}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{DynamicResource FontSizeMono}"
LetterSpacing="1.4"
Foreground="{DynamicResource TextBrush}"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn" Content="✕"
FontSize="{DynamicResource FontSizeBody}"
Command="{TemplateBinding CloseCommand}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Footer (optional) -->
<Border Name="PART_Footer" DockPanel.Dock="Bottom"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0"
IsVisible="{TemplateBinding Footer, Converter={x:Static ObjectConverters.IsNotNull}}">
<ContentPresenter Content="{TemplateBinding Footer}" Margin="16,8"/>
</Border>
<!-- Body -->
<ContentPresenter Content="{TemplateBinding Content}"/>
</DockPanel>
</Border>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>
```
- [ ] **Step 3: Register the ControlTheme**
In `src/ClaudeDo.App/App.axaml`, inside `<ResourceDictionary.MergedDictionaries>` (after the Tokens include), add:
```xml
<ResourceInclude Source="avares://ClaudeDo.Ui/Views/Controls/ModalShell.axaml" />
```
- [ ] **Step 4: Build**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml.cs src/ClaudeDo.App/App.axaml
git commit -m "feat(ui): add reusable ModalShell control"
```
---
### Task 11: Migrate SettingsModalView to ModalShell (reference migration)
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
- Modify (if needed): `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml.cs`
- [ ] **Step 1: Replace chrome with ModalShell**
- Add namespace if missing: `xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"` (already present).
- Remove the local `Window.Styles` entries for `section-label`, `field-label`, `path-mono`, `Button.danger`, `Button.primary` (now shared). Keep any genuinely unique styles.
- Replace the outer `<Border>...<Grid RowDefinitions="36,*,52">` structure with:
```xml
<ctl:ModalShell Title="SETTINGS" CloseCommand="{Binding CancelCommand}">
<ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="Save" Classes="primary" Command="{Binding SaveCommand}" IsEnabled="{Binding !IsBusy}" MinWidth="90"/>
</StackPanel>
</ctl:ModalShell.Footer>
<!-- existing DockPanel body (tabs + validation strip) goes here unchanged -->
</ctl:ModalShell>
```
- The body is the existing `<DockPanel Grid.Row="1">` content minus `Grid.Row`.
- Snap remaining FontSize literals in the body per the rules.
- [ ] **Step 2: Remove obsolete drag handler if now unused**
If `TitleBar_PointerPressed` in `SettingsModalView.axaml.cs` is no longer referenced (ModalShell handles dragging), delete the method and the `x:Name="TitleBar"`/`PointerPressed` wiring. If the build complains about an unused handler, that's the signal.
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml.cs
git commit -m "refactor(ui): migrate SettingsModal to ModalShell"
```
---
### Task 12: Migrate remaining modals to ModalShell
Repeat the Task 11 pattern for each modal below. One commit per file. Each: swap chrome → `ModalShell`, lift action buttons into `ModalShell.Footer`, drop local duplicate styles, delete now-unused `*_PointerPressed` drag handlers, snap FontSize/colors per rules, build, commit.
- [ ] **12a:** `ListSettingsModalView.axaml` (+ `.axaml.cs`)
- [ ] **12b:** `MergeModalView.axaml` (+ `.axaml.cs`)
- [ ] **12c:** `AboutModalView.axaml` (+ `.axaml.cs`) — labels inherit SansFont now.
- [ ] **12d:** `UnfinishedPlanningModalView.axaml` (+ `.axaml.cs`)
- [ ] **12e:** `RepoImportModalView.axaml` (+ `.axaml.cs`)
- [ ] **12f:** `WorktreesOverviewModalView.axaml` (+ `.axaml.cs`) — also fold `Border.wt-row` to reuse `task-row` if trivial; snap FontSize; `#EF5350``StatusErrorBrush`; `White` badge text→`TextBrush`.
Each ends with build PASS + `git commit -m "refactor(ui): migrate <Modal> to ModalShell"`.
---
### Task 13: DiffModalView, PlanningDiffView, ConflictResolutionView (Static→Dynamic + chrome)
These three currently use `StaticResource` for token lookups. Migrate chrome to `ModalShell` where they are full windows, and convert token references.
- [ ] **Step 1: Convert resource references**
In each of `DiffModalView.axaml`, `PlanningDiffView.axaml`, `ConflictResolutionView.axaml`: change every `{StaticResource <Brush/Token>}` used in an **element attribute** to `{DynamicResource ...}`. Leave `{StaticResource ...}` inside `<Style>`/`Setter` blocks (Avalonia styles resolve StaticResource fine and DynamicResource in setters is discouraged).
- [ ] **Step 2: Apply normalization rules**
- Snap FontSize literals.
- `Consolas,Menlo,monospace` raw font (PlanningDiffView ~98, ConflictResolution ~47) → `{DynamicResource MonoFont}`.
- `Orange`/`OrangeRed``{DynamicResource BloodBrush}`.
- DiffModal tints `#1A4A6B4A`/`#1AC87060``{DynamicResource RunningTintBrush}`/`{DynamicResource ErrorTintBrush}`.
- Migrate window chrome to `ModalShell` if the file is a Window with the titlebar/footer pattern (DiffModalView, ConflictResolutionView). PlanningDiffView is an embedded view — only convert resources + fonts, no ModalShell.
- [ ] **Step 3: Build + commit (one per file)**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` → PASS
Commit: `git commit -m "refactor(ui): tokenize and dynamic-ize <view>"`
---
## Phase 4 — Final verification
### Task 14: Full build + visual checklist
- [ ] **Step 1: Build both projects**
Run:
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: both PASS.
- [ ] **Step 2: Grep for stragglers**
Confirm no remaining hardcoded values slipped through:
- `FontSize="` with a numeric literal in any `Views/**/*.axaml` (should be near-zero; only token refs remain).
- Off-palette hex (`#4CAF50`, `#FFA726`, `#EF5350`, `#FF080C0B`, `OrangeRed`, `Orange`) — should be zero.
- [ ] **Step 3: Produce the human visual-check checklist**
Write a short checklist (`docs/superpowers/plans/2026-05-30-ui-normalization-visualcheck.md`) listing each view/modal and what to eyeball (font looks like Inter Tight, status dots correct color, modal titlebars/footers intact, badges distinguishable, diff/planning views render). This is the regression gate the user runs by launching the app.
---
## Self-Review notes
- **Spec coverage:** global defaults (T2), token source-of-truth fonts/spacing/radius (rules + T3T13), color fold (T1,T3,T4,T6,T12,T13), shared styles (T3), ModalShell (T10T13), bug fixes — BorderBrush (T7), Static→Dynamic (T13). All spec sections mapped.
- **Risk note:** ModalShell migration (T11T13) is the highest-risk part because each modal's body layout differs. Tasks are per-file so a failure is isolated. If a modal's body has tight coupling to the old Grid rows, keeping that modal's hand-rolled chrome (and only tokenizing it) is an acceptable fallback — note it in the commit.
- **Line numbers** are from the pre-change audit and may drift as edits land; treat them as guides, locate by content.

View File

@@ -0,0 +1,175 @@
# Waiting for Review — Task State — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a `WaitingForReview` lifecycle state that standalone tasks enter after a successful run, with approve / reject-rerun / reject-park / cancel exits, exposed via UI and MCP.
**Architecture:** New enum value + nullable `ReviewFeedback` column. `TaskStateService` gains review transitions. `TaskRunner.HandleSuccess` routes standalone-task success to review. `QueueService.RunInSlotAsync` resumes the Claude session when re-running a rejected task. New MCP `review_task` tool + UI commands.
**Tech Stack:** .NET 8, EF Core (SQLite, TEXT enum), SignalR, Avalonia MVVM, xUnit.
**Scope decision (locked):** Only standalone tasks (`ParentTaskId == null`) route to `WaitingForReview`. Planning **child** tasks continue to `Done` on success so the sequential planning chain (which advances on *terminal* states) is unaffected. Flagged for user confirmation.
---
## Task 1: Data layer — enum, converter, column
**Files:**
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs`
- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`
- Create: EF migration via CLI
- [ ] **Step 1:** Add `WaitingForReview` to `TaskStatus` enum (after `Running`) and add `public string? ReviewFeedback { get; set; }` to `TaskEntity`.
- [ ] **Step 2:** In `TaskEntityConfiguration`, add `TaskStatus.WaitingForReview => "waiting_for_review"` to `StatusToString` and `"waiting_for_review" => TaskStatus.WaitingForReview` to `StatusFromString`; map the column: `builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback");`
- [ ] **Step 3:** Create migration: `dotnet ef migrations add AddReviewFeedback --project src/ClaudeDo.Data/ClaudeDo.Data.csproj`. Verify it only adds the `review_feedback` TEXT column (nullable). If `dotnet ef` unavailable, hand-write the migration + designer following the latest migration in `Migrations/`.
- [ ] **Step 4:** Build `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`. Expected: success.
- [ ] **Step 5:** Commit.
## Task 2: Worker — review transitions in TaskStateService
**Files:**
- Modify: `src/ClaudeDo.Worker/State/TaskStateService.cs`
- Modify: `src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs` (add new method signatures)
- Test: `tests/ClaudeDo.Worker.Tests/...` (state transition tests)
New methods (all return `TransitionResult`, broadcast `TaskUpdated`):
- `SubmitForReviewAsync(taskId, finishedAt, result, ct)` — guard `Status == Running`; set `Status=WaitingForReview, FinishedAt, Result`. Does NOT call `OnChildTerminalAsync` (review is non-terminal; only invoked for standalone tasks anyway).
- `ApproveReviewAsync(taskId, ct)` — guard `Status == WaitingForReview`; set `Status=Done`.
- `RejectToQueueAsync(taskId, feedback, ct)` — reject empty/whitespace feedback (`TransitionResult(false, "Feedback is required to reject for re-run.")`); guard `Status == WaitingForReview`; set `Status=Queued, ReviewFeedback=feedback`; `_waker.Wake()`.
- `RejectToIdleAsync(taskId, ct)` — guard `Status == WaitingForReview`; set `Status=Idle, ReviewFeedback=null` (leave `Result` intact).
- `ClearReviewFeedbackAsync(taskId, ct)` — set `ReviewFeedback=null` (no status change, no guard); used by the runner after consuming feedback.
- Extend `CancelAsync` guard: `(Status == Running || Status == Queued || Status == WaitingForReview)`.
- [ ] **Step 1:** Write failing tests in a new `tests/ClaudeDo.Worker.Tests/State/ReviewTransitionTests.cs` (follow existing TaskStateService test setup). Cover: submit-for-review from Running; approve from WaitingForReview→Done; reject-to-queue stores feedback + status Queued; empty feedback rejected; reject-to-idle clears feedback + keeps Result; cancel from WaitingForReview→Cancelled; invalid (approve from Idle) returns `!Ok`.
- [ ] **Step 2:** Run tests, expect FAIL (methods missing).
- [ ] **Step 3:** Implement the methods + interface signatures + CancelAsync guard.
- [ ] **Step 4:** Run tests, expect PASS.
- [ ] **Step 5:** Commit.
## Task 3: Worker — route standalone success to review
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` (`HandleSuccess`)
- [ ] **Step 1:** In `HandleSuccess`, after commit, branch:
```csharp
var finishedAt = DateTime.UtcNow;
if (task.ParentTaskId is null)
{
await _state.SubmitForReviewAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt);
}
else
{
await _state.CompleteAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
}
```
- [ ] **Step 2:** Build worker. Expected: success.
- [ ] **Step 3:** Commit.
## Task 4: Worker — resume-aware re-run in QueueService
**Files:**
- Modify: `src/ClaudeDo.Worker/Queue/QueueService.cs` (`RunInSlotAsync`)
- Test: `tests/ClaudeDo.Worker.Tests/...`
- [ ] **Step 1:** In `RunInSlotAsync`, after loading `task`:
```csharp
if (!string.IsNullOrWhiteSpace(task.ReviewFeedback))
{
var feedback = task.ReviewFeedback!;
string? sessionId;
using (var ctx = _dbFactory.CreateDbContext())
sessionId = (await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, ct))?.SessionId;
await _state.ClearReviewFeedbackAsync(taskId, ct); // inject ITaskStateService
if (sessionId is not null)
{
await _runner.ContinueAsync(taskId, feedback, "queue", ct);
return;
}
task.Description = string.IsNullOrWhiteSpace(task.Description)
? $"Reviewer feedback: {feedback}"
: $"{task.Description}\n\nReviewer feedback: {feedback}";
}
await _runner.RunAsync(task, "queue", ct);
```
Inject `ITaskStateService _state` into `QueueService` (add to ctor + DI already provides it).
- [ ] **Step 2:** Build worker, expect success.
- [ ] **Step 3:** Commit.
## Task 5: MCP — review_task tool + status reference
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- [ ] **Step 1:** Add `review_task` tool:
```csharp
[McpServerTool, Description(
"Review a task that is WaitingForReview. decision: 'approve' (→ Done), " +
"'reject_rerun' (→ Queued, resumes the agent session with feedback — feedback required), " +
"'reject_park' (→ Idle for manual editing), 'cancel' (→ Cancelled). ")]
public async Task<TaskDto> ReviewTask(string taskId, string decision, string? feedback, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
TransitionResult r = decision.ToLowerInvariant() switch
{
"approve" => await _state.ApproveReviewAsync(taskId, cancellationToken),
"reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
"reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken),
"cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
_ => throw new InvalidOperationException($"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
};
if (!r.Ok) throw new InvalidOperationException(r.Reason ?? "Review action failed.");
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
}
```
- [ ] **Step 2:** Add `WaitingForReview` to `GetTaskStatusValues` list; update the validation strings in `ListTasks` and the lifecycle text in `GetTask`/`UpdateTaskStatus` to include `WaitingForReview`.
- [ ] **Step 3:** Build worker, expect success.
- [ ] **Step 4:** Commit.
## Task 6: UI — client + hub methods
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
- [ ] **Step 1:** Hub: add `ApproveReview(taskId)`, `RejectReviewToQueue(taskId, feedback)`, `RejectReviewToIdle(taskId)`, `CancelReview(taskId)` — each calls the matching `_state` method via `HubGuard`-style mapping (`if (!result.Ok) throw new HubException(...)`).
- [ ] **Step 2:** `IWorkerClient` + `WorkerClient`: add `ApproveReviewAsync`, `RejectReviewToQueueAsync(taskId, feedback)`, `RejectReviewToIdleAsync`, `CancelReviewAsync` invoking the hub methods. Add no-op/stub impls to `StubWorkerClient`.
- [ ] **Step 3:** Build App + Ui.Tests. Expected: success.
- [ ] **Step 4:** Commit.
## Task 7: UI — converter, row VM, view buttons
**Files:**
- Modify: `src/ClaudeDo.Ui/Converters/StatusColorConverter.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (commands)
- Modify: the task row/detail AXAML to surface Approve / Reject / Park / Cancel when `IsWaitingForReview`
- [ ] **Step 1:** `StatusColorConverter`: add `"waiting_for_review" => Brushes.MediumPurple,` (placeholder — user does visual pass).
- [ ] **Step 2:** `TaskRowViewModel`: add `public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;`, raise it in `OnStatusChanged`, and add `(TaskStatus.WaitingForReview, _) => "review"` to `StatusChipClass`.
- [ ] **Step 3:** `TasksIslandViewModel`: add relay commands `ApproveReview`, `RejectReviewRerun` (prompts for feedback), `RejectReviewPark`, `CancelReview` operating on the selected/target row, calling the new client methods.
- [ ] **Step 4:** Add buttons to the relevant view bound to those commands, visible when `IsWaitingForReview`. Reject-rerun uses a text-input flyout/dialog for required feedback.
- [ ] **Step 5:** Build App + Ui.Tests. Expected: success. (Visual layout: flagged for user's visual pass — cannot render here.)
- [ ] **Step 6:** Commit.
## Task 8: Docs + full verification
**Files:**
- Modify: root `CLAUDE.md`, `src/ClaudeDo.Data/CLAUDE.md`, `src/ClaudeDo.Worker/CLAUDE.md`
- [ ] **Step 1:** Update status flow lines + worker transition table to include `WaitingForReview` and the new transitions.
- [ ] **Step 2:** Build all projects (csproj individually — `.slnx` needs .NET 9) and run `dotnet test tests/ClaudeDo.Worker.Tests`, `tests/ClaudeDo.Ui.Tests`, `tests/ClaudeDo.Data.Tests`. Expected: all green.
- [ ] **Step 3:** Commit.
## Self-Review notes
- Spec coverage: §1 state machine → Tasks 2,3; §2 data → Task 1; §3 transitions → Task 2; §4 resume → Task 4; §5 MCP → Task 5; §6 hub → Task 6; §7 UI → Tasks 6,7; §8 docs → Task 8; testing → Tasks 2,4,8.
- Method names consistent across tasks: `SubmitForReviewAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync` (state); `ApproveReview`/`RejectReviewToQueue`/`RejectReviewToIdle`/`CancelReview` (hub); `ApproveReviewAsync`/`RejectReviewToQueueAsync`/`RejectReviewToIdleAsync`/`CancelReviewAsync` (client).

View File

@@ -0,0 +1,829 @@
# Worker Lifecycle Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the worker owned by a single external mechanism (a per-user Startup-folder shortcut in production), stop the App from auto-spawning its own worker, and show an actionable prompt when the App can't connect.
**Architecture:** Installer creates a `.lnk` in the Windows Startup folder instead of a Scheduled Task (migrating existing installs by deleting the old task). The App's `IslandsShellViewModel` drops `EnsureWorkerRunningAsync` and instead runs a one-shot grace timer that opens a `WorkerConnectionModal` (Start Worker / Rerun Installer / Dismiss) if still offline; the footer status pill becomes a button that reopens it.
**Tech Stack:** .NET 8, WPF installer (COM `IShellLink` for shortcuts), Avalonia + CommunityToolkit.Mvvm UI, xUnit.
---
## File Structure
**Installer (`src/ClaudeDo.Installer`)**
- Create: `Core/ShortcutFactory.cs` — shared `IShellLink` COM helper (`CreateShortcut`).
- Create: `Core/AutostartShortcut.cs` — install/remove the worker Startup-folder `.lnk`.
- Modify: `Steps/CreateShortcutsStep.cs` — use `ShortcutFactory`, drop embedded COM.
- Modify: `Steps/RegisterAutostartStep.cs` — Startup shortcut + legacy-task delete (no more task XML).
- Modify: `Steps/StartWorkerStep.cs``Process.Start` instead of `schtasks /Run`.
- Modify: `Steps/StopWorkerStep.cs` — drop `schtasks /End`.
- Modify: `Core/UninstallRunner.cs` — remove the Startup `.lnk`.
- Delete: `Core/ScheduledTaskXml.cs` (and its test).
**App (`src/ClaudeDo.Ui`)**
- Create: `ViewModels/Modals/WorkerConnectionModalViewModel.cs`.
- Create: `Views/Modals/WorkerConnectionModalView.axaml` (+ `.axaml.cs`).
- Modify: `ViewModels/IslandsShellViewModel.cs` — remove auto-spawn; add hook, command, grace timer, decision gate.
- Modify: `Views/MainWindow.axaml.cs` — wire the new modal.
- Modify: `Views/MainWindow.axaml` — clickable status pill.
**Tests**
- Modify: `tests/ClaudeDo.Installer.Tests/` — delete `ScheduledTaskXmlTests.cs`; add `ShortcutFactoryTests.cs`, `AutostartShortcutTests.cs`.
- Add: `tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs`.
---
## Task 1: ShortcutFactory (shared COM helper)
**Files:**
- Create: `src/ClaudeDo.Installer/Core/ShortcutFactory.cs`
- Modify: `src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs`
- Test: `tests/ClaudeDo.Installer.Tests/ShortcutFactoryTests.cs`
- [ ] **Step 1: Write the failing test**
`tests/ClaudeDo.Installer.Tests/ShortcutFactoryTests.cs`:
```csharp
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public class ShortcutFactoryTests
{
[Fact]
public void CreateShortcut_writes_lnk_file()
{
var dir = Path.Combine(Path.GetTempPath(), "cdshortcut-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var target = Path.Combine(dir, "fake.exe");
File.WriteAllText(target, "");
var lnk = Path.Combine(dir, "x.lnk");
ShortcutFactory.CreateShortcut(lnk, target, dir, "desc");
Assert.True(File.Exists(lnk));
}
finally { Directory.Delete(dir, recursive: true); }
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter ShortcutFactoryTests`
Expected: FAIL — `ShortcutFactory` does not exist (compile error).
- [ ] **Step 3: Create `ShortcutFactory` (move COM interop out of `CreateShortcutsStep`)**
`src/ClaudeDo.Installer/Core/ShortcutFactory.cs`:
```csharp
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Text;
namespace ClaudeDo.Installer.Core;
public static class ShortcutFactory
{
public static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description)
{
var link = (IShellLink)new ShellLink();
link.SetPath(targetPath);
link.SetWorkingDirectory(workingDir);
link.SetDescription(description);
link.SetIconLocation(targetPath, 0);
var file = (IPersistFile)link;
file.Save(shortcutPath, false);
}
[ComImport]
[Guid("00021401-0000-0000-C000-000000000046")]
private class ShellLink { }
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("000214F9-0000-0000-C000-000000000046")]
private interface IShellLink
{
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, int fFlags);
void GetIDList(out IntPtr ppidl);
void SetIDList(IntPtr pidl);
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
void GetHotkey(out short pwHotkey);
void SetHotkey(short wHotkey);
void GetShowCmd(out int piShowCmd);
void SetShowCmd(int iShowCmd);
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon);
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved);
void Resolve(IntPtr hwnd, int fFlags);
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
}
}
```
- [ ] **Step 4: Replace the embedded COM in `CreateShortcutsStep` with the helper**
In `src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs`: delete the private `CreateShortcut` method and the entire `#region COM Interop for IShellLink` block (lines 47-90), remove the now-unused `using System.Runtime.InteropServices;`, `using System.Runtime.InteropServices.ComTypes;`, and `using System.Text;`. Replace the two `CreateShortcut(...)` call sites with `ShortcutFactory.CreateShortcut(...)`:
```csharp
ShortcutFactory.CreateShortcut(startMenuPath, appExe, workingDir, "ClaudeDo Task Manager");
```
```csharp
ShortcutFactory.CreateShortcut(desktopPath, appExe, workingDir, "ClaudeDo Task Manager");
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter ShortcutFactoryTests`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Installer/Core/ShortcutFactory.cs src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs tests/ClaudeDo.Installer.Tests/ShortcutFactoryTests.cs
git commit -m "refactor(installer): extract ShortcutFactory COM helper"
```
---
## Task 2: AutostartShortcut helper
**Files:**
- Create: `src/ClaudeDo.Installer/Core/AutostartShortcut.cs`
- Test: `tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs`
- [ ] **Step 1: Write the failing tests**
`tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs`:
```csharp
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public class AutostartShortcutTests
{
private static string TempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "cdautostart-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
[Fact]
public void Install_creates_lnk_with_expected_name()
{
var startup = TempDir();
var workerDir = TempDir();
try
{
var workerExe = Path.Combine(workerDir, "ClaudeDo.Worker.exe");
File.WriteAllText(workerExe, "");
AutostartShortcut.Install(startup, workerExe);
Assert.True(File.Exists(Path.Combine(startup, AutostartShortcut.FileName)));
}
finally { Directory.Delete(startup, true); Directory.Delete(workerDir, true); }
}
[Fact]
public void Remove_deletes_existing_lnk()
{
var startup = TempDir();
var workerDir = TempDir();
try
{
var workerExe = Path.Combine(workerDir, "ClaudeDo.Worker.exe");
File.WriteAllText(workerExe, "");
AutostartShortcut.Install(startup, workerExe);
AutostartShortcut.Remove(startup);
Assert.False(File.Exists(Path.Combine(startup, AutostartShortcut.FileName)));
}
finally { Directory.Delete(startup, true); Directory.Delete(workerDir, true); }
}
[Fact]
public void Remove_is_noop_when_missing()
{
var startup = TempDir();
try { AutostartShortcut.Remove(startup); } // must not throw
finally { Directory.Delete(startup, true); }
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter AutostartShortcutTests`
Expected: FAIL — `AutostartShortcut` does not exist.
- [ ] **Step 3: Create `AutostartShortcut`**
`src/ClaudeDo.Installer/Core/AutostartShortcut.cs`:
```csharp
using System.IO;
namespace ClaudeDo.Installer.Core;
public static class AutostartShortcut
{
public const string FileName = "ClaudeDo Worker.lnk";
public static string DefaultStartupDir =>
Environment.GetFolderPath(Environment.SpecialFolder.Startup);
public static string PathIn(string startupDir) => Path.Combine(startupDir, FileName);
public static void Install(string startupDir, string workerExe)
{
Directory.CreateDirectory(startupDir);
var workingDir = Path.GetDirectoryName(workerExe) ?? startupDir;
ShortcutFactory.CreateShortcut(PathIn(startupDir), workerExe, workingDir, "ClaudeDo background worker");
}
public static void Remove(string startupDir)
{
var path = PathIn(startupDir);
if (File.Exists(path)) File.Delete(path);
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter AutostartShortcutTests`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Installer/Core/AutostartShortcut.cs tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs
git commit -m "feat(installer): add AutostartShortcut helper for Startup-folder lnk"
```
---
## Task 3: RegisterAutostartStep → Startup shortcut + task migration
**Files:**
- Modify: `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs`
- Delete: `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs`
- Delete: `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs`
- [ ] **Step 1: Replace the step body**
Rewrite `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs` to:
```csharp
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class RegisterAutostartStep : IInstallStep
{
public const string LegacyTaskName = "ClaudeDoWorker";
private const string LegacyServiceName = "ClaudeDoWorker";
public string Name => "Register Autostart";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
if (!File.Exists(workerExe))
return StepResult.Fail($"Worker executable not found: {workerExe}");
// 1) Migrate away the legacy Windows service if present.
progress.Report("Checking for legacy worker service...");
var (queryExit, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
if (queryExit == 0)
{
progress.Report("Removing legacy worker service...");
await ProcessRunner.RunAsync("sc.exe", $"stop {LegacyServiceName}", null, progress, ct);
await ProcessRunner.RunAsync("sc.exe", $"delete {LegacyServiceName}", null, progress, ct);
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (q, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
if (q != 0) break;
await Task.Delay(1000, ct);
}
}
// 2) Migrate away the legacy logon scheduled task if present (best-effort).
progress.Report("Removing legacy logon task...");
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{LegacyTaskName}\" /F", null, progress, ct);
// 3) Register per-user autostart via a Startup-folder shortcut.
progress.Report("Creating Startup shortcut...");
try
{
AutostartShortcut.Install(AutostartShortcut.DefaultStartupDir, workerExe);
}
catch (Exception ex)
{
return StepResult.Fail($"Failed to create Startup shortcut: {ex.Message}");
}
return StepResult.Ok();
}
}
```
- [ ] **Step 2: Delete the obsolete scheduled-task code and its test**
Run:
```bash
git rm src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs
```
- [ ] **Step 3: Build the installer to verify it compiles**
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
Expected: Build succeeded. (If `RegisterAutostartStep.TaskName` was referenced elsewhere, the build will flag it — Task 4 and Task 5 update those references; if the build fails only there, proceed to those tasks before re-running.)
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs
git commit -m "feat(installer): register autostart via Startup shortcut, drop scheduled task"
```
---
## Task 4: StartWorkerStep + StopWorkerStep
**Files:**
- Modify: `src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`
- Modify: `src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`
- [ ] **Step 1: Rewrite `StartWorkerStep` to launch the exe directly**
`src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`:
```csharp
using System.Diagnostics;
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StartWorkerStep : IInstallStep
{
public string Name => "Start Worker";
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
if (!File.Exists(workerExe))
return Task.FromResult(StepResult.Fail($"Worker executable not found: {workerExe}"));
progress.Report("Starting worker...");
try
{
Process.Start(new ProcessStartInfo(workerExe) { UseShellExecute = true });
return Task.FromResult(StepResult.Ok());
}
catch (Exception ex)
{
return Task.FromResult(StepResult.Fail($"Failed to start worker: {ex.Message}"));
}
}
}
```
- [ ] **Step 2: Drop the `schtasks /End` call in `StopWorkerStep`**
In `src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`, remove these two lines (the task no longer exists; the process kill below is the real stop):
```csharp
progress.Report("Stopping worker task (if running)...");
await ProcessRunner.RunAsync("schtasks.exe", $"/End /TN \"{TaskName}\"", null, progress, ct);
```
Keep the `public const string TaskName = "ClaudeDoWorker";` line — `UninstallRunner` still references it for legacy-task cleanup (Task 5). The method keeps its `async` modifier (it still has `await Task.CompletedTask;`).
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
Expected: Build succeeded.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Installer/Steps/StartWorkerStep.cs src/ClaudeDo.Installer/Steps/StopWorkerStep.cs
git commit -m "feat(installer): start worker via Process.Start, drop schtasks stop"
```
---
## Task 5: UninstallRunner removes the Startup shortcut
**Files:**
- Modify: `src/ClaudeDo.Installer/Core/UninstallRunner.cs`
- [ ] **Step 1: Add Startup `.lnk` removal**
In `src/ClaudeDo.Installer/Core/UninstallRunner.cs`, the shortcut-removal block (step 4, around lines 53-60) currently removes the Desktop and Start Menu `.lnk`s. Add the Startup shortcut removal right after them:
```csharp
// 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest).
progress.Report("Removing shortcuts...");
TryDeleteFile(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
"ClaudeDo.lnk"));
TryDeleteFile(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
"Programs", "ClaudeDo.lnk"));
TryDeleteFile(AutostartShortcut.PathIn(AutostartShortcut.DefaultStartupDir));
```
The existing `schtasks /Delete /TN "{StopWorkerStep.TaskName}" /F` line (step 3) stays — it cleans up the legacy task on machines that still have it.
- [ ] **Step 2: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Installer/Core/UninstallRunner.cs
git commit -m "feat(installer): remove Startup worker shortcut on uninstall"
```
---
## Task 6: App stops auto-spawning the worker
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- [ ] **Step 1: Remove the auto-spawn call**
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, delete this line from the constructor (line 224):
```csharp
_ = EnsureWorkerRunningAsync();
```
- [ ] **Step 2: Remove the `EnsureWorkerRunningAsync` method and its flag**
Delete the `_ensureRunningAttempted` field (line 308) and the whole `EnsureWorkerRunningAsync` method (lines 310-320):
```csharp
private bool _ensureRunningAttempted;
private async Task EnsureWorkerRunningAsync()
{
if (_ensureRunningAttempted) return;
_ensureRunningAttempted = true;
await Task.Delay(TimeSpan.FromSeconds(4));
if (Worker?.IsConnected == true) return;
var exe = _workerLocator.Find();
if (exe is null) return;
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); }
catch { /* logon task is the primary mechanism; this is a convenience */ }
}
```
Keep `RestartWorkerAsync` / `RestartWorkerService` (still used by the existing Restart button). `_workerLocator` stays in use (RestartWorkerService + Task 8).
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded (no remaining references to `EnsureWorkerRunningAsync`).
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
git commit -m "refactor(ui): stop auto-spawning the worker on app start"
```
---
## Task 7: WorkerConnectionModal (VM + View)
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/WorkerConnectionModalViewModel.cs`
- Create: `src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml`
- Create: `src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml.cs`
- [ ] **Step 1: Create the ViewModel**
`src/ClaudeDo.Ui/ViewModels/Modals/WorkerConnectionModalViewModel.cs`:
```csharp
using System;
using System.Diagnostics;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
{
private readonly WorkerLocator _workerLocator;
private readonly InstallerLocator _installerLocator;
public WorkerConnectionModalViewModel(WorkerLocator workerLocator, InstallerLocator installerLocator)
{
_workerLocator = workerLocator;
_installerLocator = installerLocator;
}
public Action? CloseAction { get; set; }
[RelayCommand] private void Close() => CloseAction?.Invoke();
[RelayCommand]
private void StartWorker()
{
var exe = _workerLocator.Find();
if (exe is null) return;
try { Process.Start(new ProcessStartInfo(exe) { UseShellExecute = true }); }
catch { /* nothing useful to show */ }
CloseAction?.Invoke();
}
[RelayCommand]
private void RerunInstaller()
{
var path = _installerLocator.Find();
if (path is null) return;
try
{
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
Environment.Exit(0);
}
catch { /* nothing useful to show */ }
}
}
```
- [ ] **Step 2: Create the View (mirrors `AboutModalView` + `ModalShell`)**
`src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml`:
```xml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
x:Class="ClaudeDo.Ui.Views.Modals.WorkerConnectionModalView"
x:DataType="vm:WorkerConnectionModalViewModel"
Title="Worker not reachable"
Width="520" Height="240"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
<ctl:ModalShell Title="WORKER NOT REACHABLE" CloseCommand="{Binding CloseCommand}">
<Grid RowDefinitions="*,Auto" Margin="20,16">
<TextBlock Grid.Row="0" Classes="meta" TextWrapping="Wrap"
Text="ClaudeDo can't reach the background worker. It is normally started automatically at logon. You can start it now, or reinstall if the problem persists."/>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" Margin="0,16,0,0">
<Button Classes="btn" Content="Dismiss" Command="{Binding CloseCommand}"/>
<Button Classes="btn" Content="Rerun Installer" Command="{Binding RerunInstallerCommand}"/>
<Button Classes="btn primary" Content="Start Worker" Command="{Binding StartWorkerCommand}"/>
</StackPanel>
</Grid>
</ctl:ModalShell>
</Window>
```
`src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml.cs`:
```csharp
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace ClaudeDo.Ui.Views.Modals;
public partial class WorkerConnectionModalView : Window
{
public WorkerConnectionModalView()
{
InitializeComponent();
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
}
```
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/WorkerConnectionModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml.cs
git commit -m "feat(ui): add worker connection help modal"
```
---
## Task 8: Shell hook, command, grace timer + decision gate
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs`
- [ ] **Step 1: Write the failing test for the decision gate**
`tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs`:
```csharp
using ClaudeDo.Ui.ViewModels;
using Xunit;
namespace ClaudeDo.Ui.Tests;
public class ConnectionPromptGateTests
{
[Fact]
public void Shows_once_when_offline()
{
var vm = new IslandsShellViewModel();
Assert.True(vm.DecideShowConnectionPrompt(isOffline: true));
Assert.False(vm.DecideShowConnectionPrompt(isOffline: true)); // not a second time
}
[Fact]
public void Does_not_show_when_connected_before_grace()
{
var vm = new IslandsShellViewModel();
Assert.False(vm.DecideShowConnectionPrompt(isOffline: false));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter ConnectionPromptGateTests`
Expected: FAIL — `DecideShowConnectionPrompt` does not exist.
- [ ] **Step 3: Add the hook, command, gate, and grace timer**
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`:
Add a hook property near the other `Show*Modal` hooks (after line 52):
```csharp
// Set by MainWindow to open the worker-connection help dialog.
public Func<Modals.WorkerConnectionModalViewModel, Task>? ShowWorkerConnectionModal { get; set; }
```
Add the gate field + method and the open command (place near `OpenAbout`, around line 271):
```csharp
private bool _connectionPromptShown;
internal bool DecideShowConnectionPrompt(bool isOffline)
{
if (!isOffline) return false;
if (_connectionPromptShown) return false;
_connectionPromptShown = true;
return true;
}
private async Task OpenWorkerConnectionHelpAsync()
{
var vm = new Modals.WorkerConnectionModalViewModel(_workerLocator, _installerLocator);
if (ShowWorkerConnectionModal is not null) await ShowWorkerConnectionModal(vm);
}
[RelayCommand]
private Task OpenWorkerConnectionHelp() => OpenWorkerConnectionHelpAsync();
```
Add the grace timer field near `_clearTimer` (line 74):
```csharp
private readonly System.Timers.Timer _connectTimer = new(12_000) { AutoReset = false };
```
Wire and start it inside the **public** constructor (after the `_primeStatusTimer.Elapsed` wiring, near line 222 — NOT in the parameterless test constructor):
```csharp
_connectTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() =>
{
if (DecideShowConnectionPrompt(IsOffline)) _ = OpenWorkerConnectionHelpAsync();
});
_connectTimer.Start();
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter ConnectionPromptGateTests`
Expected: PASS (2 tests).
- [ ] **Step 5: Build the app**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs
git commit -m "feat(ui): prompt once on worker connection failure with grace timer"
```
---
## Task 9: Wire the modal in MainWindow + clickable status pill
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
- [ ] **Step 1: Wire the dialog hook**
In `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`, inside `OnDataContextChanged`, after the existing `vm.ShowRepoImportModal = ...` block (line 70), add:
```csharp
vm.ShowWorkerConnectionModal = async (connVm) =>
{
var dlg = new WorkerConnectionModalView { DataContext = connVm };
connVm.CloseAction = () => dlg.Close();
await dlg.ShowDialog(this);
};
```
(`ClaudeDo.Ui.Views.Modals` is already imported at line 10.)
- [ ] **Step 2: Make the status pill a button**
In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, replace the left "connection pill" `StackPanel` (lines 190-202) with a `Button` wrapping the same content:
```xml
<!-- Left: connection pill (click to open worker help) -->
<Button DockPanel.Dock="Left"
Command="{Binding OpenWorkerConnectionHelpCommand}"
Background="Transparent" BorderThickness="0" Padding="0"
Cursor="Hand" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<Ellipse Width="7" Height="7" Fill="{DynamicResource StatusRunningBrush}"
IsVisible="{Binding Worker.IsConnected}"/>
<Ellipse Width="7" Height="7" Fill="{DynamicResource StatusReviewBrush}"
IsVisible="{Binding Worker.IsReconnecting}"/>
<Ellipse Width="7" Height="7" Fill="{DynamicResource StatusErrorBrush}"
IsVisible="{Binding IsOffline}"/>
<TextBlock Classes="eyebrow"
Text="{Binding ConnectionText, Converter={StaticResource UpperCase}}"
LetterSpacing="1.4"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
```
- [ ] **Step 3: Build the app**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded.
- [ ] **Step 4: Manual verification**
Start the worker (or leave it stopped) and run the App:
- Worker stopped → after ~12s the "WORKER NOT REACHABLE" dialog appears once. **Start Worker** launches it (footer pill turns ONLINE); **Rerun Installer** launches the installer and exits; **Dismiss** closes and does not reappear automatically.
- Click the footer status pill anytime → the dialog reopens.
- Worker running before launch → no dialog appears.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/MainWindow.axaml.cs src/ClaudeDo.Ui/Views/MainWindow.axaml
git commit -m "feat(ui): wire worker connection modal and make status pill clickable"
```
---
## Task 10: Full build + test sweep
- [ ] **Step 1: Build the touched projects**
Run:
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
```
Expected: both Build succeeded.
- [ ] **Step 2: Run the affected test suites**
Run:
```bash
dotnet test tests/ClaudeDo.Installer.Tests
dotnet test tests/ClaudeDo.Ui.Tests
```
Expected: all pass; no references to the deleted `ScheduledTaskXml`.
- [ ] **Step 3: Final commit (if any stragglers)**
```bash
git add -A
git commit -m "chore: worker lifecycle redesign cleanup" || echo "nothing to commit"
```

View File

@@ -0,0 +1,983 @@
# Prime Recurring Weekday Schedule — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the Prime schedule's date-range model with a recurring weekday model — pick a set of weekdays plus a time, and the ping fires on the next eligible day the worker is running.
**Architecture:** A `[Flags] PrimeDays` weekday bitmask stored as a single `days_of_week` int column replaces `StartDate`/`EndDate`/`WorkdaysOnly`. `NextDueCalculator` walks forward to the next selected weekday; the existing 30-minute catch-up and already-fired-today logic are untouched. UI swaps the range picker + MonFri checkbox for seven toggle buttons. Both SignalR DTO copies carry a single `int Days`.
**Tech Stack:** .NET 8, EF Core (SQLite), Avalonia 12 (CommunityToolkit.Mvvm), SignalR, xUnit.
**Spec:** `docs/superpowers/specs/2026-06-02-prime-recurring-weekdays-design.md`
**Build/test note:** `dotnet build ClaudeDo.slnx` needs .NET 9; on .NET 8 build individual csproj. Commands in this plan use the per-project form.
---
## File Structure
- `src/ClaudeDo.Data/Models/PrimeDays.cs`**new**, `[Flags]` enum.
- `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs` — swap fields.
- `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs` — column mapping.
- `src/ClaudeDo.Data/Migrations/*` — new migration + snapshot.
- `src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs` — upsert fields + ordering.
- `src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs``int Days`.
- `src/ClaudeDo.Worker/Prime/NextDueCalculator.cs` — weekday eligibility.
- `src/ClaudeDo.Worker/Prime/PrimeScheduler.cs``ToDto` mapping.
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — list/upsert mapping.
- `src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs``int Days`.
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs` — 7 day bools.
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs` — defaults + validation.
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml``day-toggle` style class.
- `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` — row template.
- Tests: `NextDueCalculatorTests`, `PrimeSchedulerTests`, `PrimeScheduleRepositoryTests`, `PrimeClaudeTabViewModelTests`.
- Docs: `src/ClaudeDo.Data/CLAUDE.md`, root `CLAUDE.md`.
---
## Task 1: PrimeDays enum + entity + configuration
**Files:**
- Create: `src/ClaudeDo.Data/Models/PrimeDays.cs`
- Modify: `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs`
- Modify: `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs`
- [ ] **Step 1: Create the flags enum**
`src/ClaudeDo.Data/Models/PrimeDays.cs`:
```csharp
namespace ClaudeDo.Data.Models;
[Flags]
public enum PrimeDays
{
None = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 4,
Thursday = 8,
Friday = 16,
Saturday = 32,
Sunday = 64,
Weekdays = Monday | Tuesday | Wednesday | Thursday | Friday, // 31
All = Weekdays | Saturday | Sunday, // 127
}
```
- [ ] **Step 2: Swap entity fields**
In `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs`, remove `StartDate`, `EndDate`, `WorkdaysOnly` and add `Days`. Result:
```csharp
namespace ClaudeDo.Data.Models;
public sealed class PrimeScheduleEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public PrimeDays Days { get; set; } = PrimeDays.Weekdays;
public TimeSpan TimeOfDay { get; set; }
public bool Enabled { get; set; } = true;
public DateTimeOffset? LastRunAt { get; set; }
public string? PromptOverride { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
```
- [ ] **Step 3: Update entity configuration**
In `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs`, replace the `start_date`/`end_date`/`workdays_only` property lines with a `days_of_week` mapping (EF maps the enum to INTEGER automatically):
```csharp
builder.Property(s => s.Days).HasColumnName("days_of_week")
.IsRequired().HasDefaultValue(PrimeDays.Weekdays);
builder.Property(s => s.TimeOfDay).HasColumnName("time_of_day").IsRequired();
builder.Property(s => s.Enabled).HasColumnName("enabled").IsRequired().HasDefaultValue(true);
```
Leave `Id`, `LastRunAt`, `PromptOverride`, `CreatedAt` mappings unchanged. Add `using ClaudeDo.Data.Models;` if not present (it already is).
- [ ] **Step 4: Build the Data project**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
Expected: FAILS — `PrimeScheduleRepository`, snapshot, etc. still reference removed fields. That is expected; Tasks 23 fix it. (If you prefer a clean build gate, proceed to Task 2 before building.)
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Models/PrimeDays.cs src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs
git commit -m "feat(data): model Prime schedule as weekday bitmask"
```
---
## Task 2: Repository
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs`
- [ ] **Step 1: Update `ListAsync` ordering**
The old ordering used `StartDate`. Order by `TimeOfDay`:
```csharp
public async Task<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default)
{
var rows = await _context.PrimeSchedules.AsNoTracking()
.OrderBy(s => s.TimeOfDay)
.ToListAsync(ct);
return rows;
}
```
- [ ] **Step 2: Update `UpsertAsync` field copy**
Replace the three removed-field assignments with `Days`:
```csharp
else
{
existing.Days = entity.Days;
existing.TimeOfDay = entity.TimeOfDay;
existing.Enabled = entity.Enabled;
existing.PromptOverride = entity.PromptOverride;
}
```
Leave `GetAsync`, `DeleteAsync`, `UpdateLastRunAsync` unchanged.
- [ ] **Step 3: Commit** (build verified after migration in Task 3)
```bash
git add src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs
git commit -m "feat(data): persist weekday bitmask in prime schedule repo"
```
---
## Task 3: EF migration
**Files:**
- Create: `src/ClaudeDo.Data/Migrations/<timestamp>_PrimeWeekdays.cs` (generated)
- Modify: `src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs` (generated)
- [ ] **Step 1: Generate the migration**
Run from repo root:
```bash
dotnet ef migrations add PrimeWeekdays --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: a new `*_PrimeWeekdays.cs` file and an updated snapshot. (If `dotnet ef` is unavailable, hand-write the migration using the body below.)
- [ ] **Step 2: Replace the generated `Up` body with an explicit backfill**
EF's auto-generated drop/add would discard existing schedules' weekday intent. Edit the new migration's `Up` to add the column, backfill from `workdays_only`, then drop the old columns:
```csharp
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "days_of_week",
table: "prime_schedules",
type: "INTEGER",
nullable: false,
defaultValue: 31);
migrationBuilder.Sql(
"UPDATE prime_schedules SET days_of_week = CASE WHEN workdays_only = 1 THEN 31 ELSE 127 END;");
migrationBuilder.DropColumn(name: "start_date", table: "prime_schedules");
migrationBuilder.DropColumn(name: "end_date", table: "prime_schedules");
migrationBuilder.DropColumn(name: "workdays_only", table: "prime_schedules");
}
```
- [ ] **Step 3: Replace the generated `Down` body**
```csharp
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateOnly>(
name: "start_date", table: "prime_schedules",
type: "TEXT", nullable: false, defaultValue: new DateOnly(2000, 1, 1));
migrationBuilder.AddColumn<DateOnly>(
name: "end_date", table: "prime_schedules",
type: "TEXT", nullable: false, defaultValue: new DateOnly(2099, 12, 31));
migrationBuilder.AddColumn<bool>(
name: "workdays_only", table: "prime_schedules",
type: "INTEGER", nullable: false, defaultValue: true);
migrationBuilder.Sql(
"UPDATE prime_schedules SET workdays_only = CASE WHEN days_of_week = 127 THEN 0 ELSE 1 END;");
migrationBuilder.DropColumn(name: "days_of_week", table: "prime_schedules");
}
```
Add `using System;` at the top of the migration file if `DateOnly` defaults require it (the existing AddPrimeSchedules migration already imports `System`).
- [ ] **Step 4: Build the Data project**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Migrations
git commit -m "feat(data): migrate prime schedules to days_of_week bitmask"
```
---
## Task 4: Worker DTO + NextDueCalculator (TDD)
**Files:**
- Modify: `src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs`
- Modify: `src/ClaudeDo.Worker/Prime/NextDueCalculator.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs`
- [ ] **Step 1: Update the Worker DTO**
`src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs`:
```csharp
namespace ClaudeDo.Worker.Prime;
public sealed record PrimeScheduleDto(
Guid Id,
int Days,
TimeSpan TimeOfDay,
bool Enabled,
DateTimeOffset? LastRunAt,
string? PromptOverride);
```
- [ ] **Step 2: Rewrite the calculator tests**
Replace the entire body of `tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs`. Note: 2026-05-05 is a Tuesday; 2026-05-08 is a Friday; 2026-05-09/10 are Sat/Sun; 2026-05-11 is a Monday.
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Prime;
namespace ClaudeDo.Worker.Tests.Prime;
public class NextDueCalculatorTests
{
private static PrimeScheduleDto Schedule(
PrimeDays days, TimeSpan time,
bool enabled = true, DateTimeOffset? lastRun = null) =>
new(Guid.NewGuid(), (int)days, time, enabled, lastRun, null);
[Fact]
public void Disabled_Schedule_Returns_Null()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var s = Schedule(PrimeDays.All, new(7, 0, 0), enabled: false);
Assert.Null(NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30)));
}
[Fact]
public void No_Days_Selected_Returns_Null()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var s = Schedule(PrimeDays.None, new(7, 0, 0));
Assert.Null(NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30)));
}
[Fact]
public void Future_Same_Day_Returns_Today_At_Target()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); // Tue
var s = Schedule(PrimeDays.All, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(new DateTimeOffset(2026, 5, 5, 7, 0, 0, now.Offset), r!.At);
Assert.False(r.FireImmediately);
}
[Fact]
public void Within_CatchUp_Window_Fires_Immediately()
{
var now = new DateTimeOffset(2026, 5, 5, 7, 15, 0, TimeSpan.FromHours(2));
var s = Schedule(PrimeDays.All, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.True(r!.FireImmediately);
}
[Fact]
public void Past_CatchUp_Window_Skips_To_Next_Eligible_Day()
{
var now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)); // Tue
var s = Schedule(PrimeDays.All, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(new DateOnly(2026, 5, 6), DateOnly.FromDateTime(r!.At.LocalDateTime));
}
[Fact]
public void Weekdays_Only_Skips_Weekend()
{
var now = new DateTimeOffset(2026, 5, 8, 8, 0, 0, TimeSpan.FromHours(2)); // Fri, past catch-up
var s = Schedule(PrimeDays.Weekdays, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(DayOfWeek.Monday, r!.At.LocalDateTime.DayOfWeek);
Assert.Equal(new DateOnly(2026, 5, 11), DateOnly.FromDateTime(r.At.LocalDateTime));
}
[Fact]
public void Single_Day_Schedule_Targets_That_Weekday()
{
var now = new DateTimeOffset(2026, 5, 5, 8, 0, 0, TimeSpan.FromHours(2)); // Tue, past catch-up
var s = Schedule(PrimeDays.Friday, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(DayOfWeek.Friday, r!.At.LocalDateTime.DayOfWeek);
Assert.Equal(new DateOnly(2026, 5, 8), DateOnly.FromDateTime(r.At.LocalDateTime));
}
[Fact]
public void Already_Fired_Today_Skips_To_Next_Eligible_Day()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var lastRun = new DateTimeOffset(2026, 5, 5, 7, 1, 0, TimeSpan.FromHours(2));
var s = Schedule(PrimeDays.All, new(7, 0, 0), lastRun: lastRun);
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(new DateOnly(2026, 5, 6), DateOnly.FromDateTime(r!.At.LocalDateTime));
}
[Fact]
public void Multiple_Schedules_Returns_Earliest()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var early = Schedule(PrimeDays.All, new(7, 0, 0));
var late = Schedule(PrimeDays.All, new(9, 0, 0));
var r = NextDueCalculator.Compute(new[] { late, early }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(early.Id, r!.Schedule.Id);
}
}
```
- [ ] **Step 3: Run the tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter FullyQualifiedName~NextDueCalculatorTests`
Expected: FAIL — `PrimeScheduleDto` no longer has `StartDate`/`EndDate`/`workdaysOnly`, and the calculator still references them (compile errors).
- [ ] **Step 4: Rewrite the calculator**
Replace the entire body of `src/ClaudeDo.Worker/Prime/NextDueCalculator.cs`:
```csharp
using ClaudeDo.Data.Models;
namespace ClaudeDo.Worker.Prime;
public sealed record NextDue(PrimeScheduleDto Schedule, DateTimeOffset At, bool FireImmediately);
public static class NextDueCalculator
{
public static NextDue? Compute(
IEnumerable<PrimeScheduleDto> schedules,
DateTimeOffset now,
TimeSpan catchUp)
{
NextDue? best = null;
foreach (var s in schedules)
{
if (!s.Enabled) continue;
var due = ComputeFor(s, now, catchUp);
if (due is null) continue;
if (best is null || due.At < best.At) best = due;
}
return best;
}
private static NextDue? ComputeFor(PrimeScheduleDto s, DateTimeOffset now, TimeSpan catchUp)
{
if ((PrimeDays)s.Days == PrimeDays.None) return null;
var todayLocal = DateOnly.FromDateTime(now.LocalDateTime);
var alreadyFiredToday = s.LastRunAt is { } last &&
DateOnly.FromDateTime(last.LocalDateTime) == todayLocal;
if (!alreadyFiredToday && IsEligibleDay(s, todayLocal))
{
var todayTarget = ToOffset(todayLocal, s.TimeOfDay, now.Offset);
if (todayTarget >= now)
return new NextDue(s, todayTarget, false);
if (now <= todayTarget + catchUp)
return new NextDue(s, now, true);
}
var d = todayLocal.AddDays(1);
for (int i = 0; i < 7; i++)
{
if (IsEligibleDay(s, d))
return new NextDue(s, ToOffset(d, s.TimeOfDay, now.Offset), false);
d = d.AddDays(1);
}
return null;
}
private static bool IsEligibleDay(PrimeScheduleDto s, DateOnly d) =>
((PrimeDays)s.Days & ToFlag(d.DayOfWeek)) != PrimeDays.None;
private static PrimeDays ToFlag(DayOfWeek dow) => dow switch
{
DayOfWeek.Monday => PrimeDays.Monday,
DayOfWeek.Tuesday => PrimeDays.Tuesday,
DayOfWeek.Wednesday => PrimeDays.Wednesday,
DayOfWeek.Thursday => PrimeDays.Thursday,
DayOfWeek.Friday => PrimeDays.Friday,
DayOfWeek.Saturday => PrimeDays.Saturday,
DayOfWeek.Sunday => PrimeDays.Sunday,
_ => PrimeDays.None,
};
private static DateTimeOffset ToOffset(DateOnly day, TimeSpan time, TimeSpan offset) =>
new(day.Year, day.Month, day.Day, time.Hours, time.Minutes, time.Seconds, offset);
}
```
- [ ] **Step 5: Run the calculator tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter FullyQualifiedName~NextDueCalculatorTests`
Expected: still FAILS to build — `PrimeScheduler.ToDto` and `WorkerHub` mappings reference removed fields. Proceed to Tasks 56, then re-run.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs src/ClaudeDo.Worker/Prime/NextDueCalculator.cs tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs
git commit -m "feat(worker): compute prime due-time from weekday bitmask"
```
---
## Task 5: PrimeScheduler.ToDto + scheduler tests
**Files:**
- Modify: `src/ClaudeDo.Worker/Prime/PrimeScheduler.cs:104-105`
- Test: `tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs`
- [ ] **Step 1: Update the `ToDto` mapping**
Replace the `ToDto` method in `PrimeScheduler.cs`:
```csharp
private static PrimeScheduleDto ToDto(Data.Models.PrimeScheduleEntity e) =>
new(e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride);
```
- [ ] **Step 2: Update scheduler test fixtures**
In `tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs`, every `new PrimeScheduleEntity { ... }` initializer sets `StartDate`/`EndDate`/`WorkdaysOnly`. Replace those three lines in each of the three initializers (lines ~48-52, ~89-94, ~131-136) with a single `Days` assignment. Each initializer becomes:
```csharp
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.All,
TimeOfDay = new TimeSpan(7, 0, 0),
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
```
Add `using ClaudeDo.Data.Models;` to the file's usings if not already present (it is, via line 1).
- [ ] **Step 3: Run scheduler + calculator tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~Prime"`
Expected: still build-fails until `WorkerHub` (Task 6) compiles. After Task 6, this command must PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Prime/PrimeScheduler.cs tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs
git commit -m "test(worker): adapt prime scheduler tests to weekday model"
```
---
## Task 6: WorkerHub mapping + repository tests
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:488-518`
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs`
- [ ] **Step 1: Update `ListPrimeSchedules`**
```csharp
public async Task<List<PrimeScheduleDto>> ListPrimeSchedules()
{
using var ctx = _dbFactory.CreateDbContext();
var rows = await new PrimeScheduleRepository(ctx).ListAsync();
return rows.Select(e => new PrimeScheduleDto(
e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList();
}
```
- [ ] **Step 2: Update `UpsertPrimeSchedule`**
```csharp
public async Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto)
{
using var ctx = _dbFactory.CreateDbContext();
var repo = new PrimeScheduleRepository(ctx);
var existing = await repo.GetAsync(dto.Id);
var entity = new ClaudeDo.Data.Models.PrimeScheduleEntity
{
Id = dto.Id == Guid.Empty ? Guid.NewGuid() : dto.Id,
Days = (ClaudeDo.Data.Models.PrimeDays)dto.Days,
TimeOfDay = dto.TimeOfDay,
Enabled = dto.Enabled,
PromptOverride = dto.PromptOverride,
CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow,
LastRunAt = existing?.LastRunAt,
};
await repo.UpsertAsync(entity);
_primeSignal.Signal();
return new PrimeScheduleDto(entity.Id, (int)entity.Days, entity.TimeOfDay,
entity.Enabled, entity.LastRunAt, entity.PromptOverride);
}
```
`DeletePrimeSchedule` is unchanged.
- [ ] **Step 3: Update repository tests**
In `tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs`, replace each entity initializer's `StartDate`/`EndDate`/`WorkdaysOnly` lines with `Days = PrimeDays.Weekdays,` (drop them where only `StartDate`/`EndDate` appear). The three initializers become:
```csharp
// Upsert_Then_List_RoundTrips
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.Weekdays,
TimeOfDay = new TimeSpan(7, 0, 0),
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
```
```csharp
// UpdateLastRunAt_Persists
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.Weekdays,
TimeOfDay = new TimeSpan(7, 0, 0),
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
```
```csharp
// Delete_Removes_Row
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.All,
TimeOfDay = TimeSpan.Zero,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
```
Add an assertion in `Upsert_Then_List_RoundTrips` after the existing time assertion:
```csharp
Assert.Equal(PrimeDays.Weekdays, rows[0].Days);
```
- [ ] **Step 4: Build worker + run all worker tests**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj && dotnet test tests/ClaudeDo.Worker.Tests`
Expected: PASS (all Prime + repository tests green).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs
git commit -m "feat(worker): map prime schedule weekday bitmask over the hub"
```
---
## Task 7: UI DTO + ViewModels + tests (TDD)
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs`
- [ ] **Step 1: Update the UI DTO**
`src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs` (keep `PrimeFiredEvent` unchanged):
```csharp
namespace ClaudeDo.Ui.Services;
public sealed record PrimeScheduleDto(
Guid Id,
int Days,
TimeSpan TimeOfDay,
bool Enabled,
DateTimeOffset? LastRunAt,
string? PromptOverride);
public sealed record PrimeFiredEvent(
Guid ScheduleId,
bool Success,
string Message,
DateTimeOffset FiredAt);
```
- [ ] **Step 2: Rewrite the row VM**
Replace the body of `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs`:
```csharp
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
{
private const int Mon = 1, Tue = 2, Wed = 4, Thu = 8, Fri = 16, Sat = 32, Sun = 64;
public Guid Id { get; }
public bool IsExisting { get; }
[ObservableProperty] private bool _enabled;
[ObservableProperty] private bool _monday;
[ObservableProperty] private bool _tuesday;
[ObservableProperty] private bool _wednesday;
[ObservableProperty] private bool _thursday;
[ObservableProperty] private bool _friday;
[ObservableProperty] private bool _saturday;
[ObservableProperty] private bool _sunday;
[ObservableProperty] private TimeSpan _timeOfDay;
[ObservableProperty] private DateTimeOffset? _lastRunAt;
public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—";
partial void OnLastRunAtChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastRunLabel));
public PrimeScheduleRowViewModel(PrimeScheduleDto dto, bool isExisting)
{
Id = dto.Id;
IsExisting = isExisting;
Enabled = dto.Enabled;
Monday = (dto.Days & Mon) != 0;
Tuesday = (dto.Days & Tue) != 0;
Wednesday = (dto.Days & Wed) != 0;
Thursday = (dto.Days & Thu) != 0;
Friday = (dto.Days & Fri) != 0;
Saturday = (dto.Days & Sat) != 0;
Sunday = (dto.Days & Sun) != 0;
TimeOfDay = dto.TimeOfDay;
LastRunAt = dto.LastRunAt;
}
public int DaysMask()
{
int m = 0;
if (Monday) m |= Mon;
if (Tuesday) m |= Tue;
if (Wednesday) m |= Wed;
if (Thursday) m |= Thu;
if (Friday) m |= Fri;
if (Saturday) m |= Sat;
if (Sunday) m |= Sun;
return m;
}
public PrimeScheduleDto ToDto() =>
new(Id, DaysMask(), TimeOfDay, Enabled, LastRunAt, null);
}
```
- [ ] **Step 3: Update the tab VM**
In `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs`, replace `Validate` and `AddSchedule`:
```csharp
public string? Validate()
{
foreach (var r in Rows)
{
if (r.DaysMask() == 0)
return $"Schedule {r.TimeOfDay:hh\\:mm}: select at least one day.";
if (r.TimeOfDay < TimeSpan.Zero || r.TimeOfDay >= TimeSpan.FromDays(1))
return "Time must be between 00:00 and 23:59.";
}
return null;
}
```
```csharp
[RelayCommand]
private void AddSchedule()
{
var dto = new PrimeScheduleDto(
Id: Guid.NewGuid(),
Days: 31, // MonFri
TimeOfDay: new TimeSpan(7, 0, 0),
Enabled: true,
LastRunAt: null,
PromptOverride: null);
Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: false));
}
```
`LoadAsync`, `SaveAsync`, `RemoveSchedule`, `ApplyFiredEvent` are unchanged.
- [ ] **Step 4: Rewrite the tab VM tests**
Replace the body of `tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs`:
```csharp
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class PrimeClaudeTabViewModelTests
{
private sealed class FakeApi : IPrimeScheduleApi
{
public List<PrimeScheduleDto> Stored { get; } = new();
public List<PrimeScheduleDto> Upserts { get; } = new();
public List<Guid> Deletes { get; } = new();
public Task<List<PrimeScheduleDto>> ListAsync() => Task.FromResult(Stored.ToList());
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto)
{
Upserts.Add(dto);
return Task.FromResult<PrimeScheduleDto?>(dto);
}
public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; }
}
private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time) =>
new(id, days, time, true, null, null);
[Fact]
public async Task Load_Populates_Rows()
{
var api = new FakeApi();
api.Stored.Add(Dto(Guid.NewGuid(), 31, new TimeSpan(7, 0, 0)));
var vm = new PrimeClaudeTabViewModel(api);
await vm.LoadAsync();
Assert.Single(vm.Rows);
}
[Fact]
public void AddSchedule_Appends_Row_With_Defaults()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
Assert.Single(vm.Rows);
Assert.True(vm.Rows[0].Enabled);
Assert.True(vm.Rows[0].Monday);
Assert.True(vm.Rows[0].Friday);
Assert.False(vm.Rows[0].Saturday);
Assert.Equal(new TimeSpan(7, 0, 0), vm.Rows[0].TimeOfDay);
}
[Fact]
public void Row_Decomposes_And_Recomposes_Days()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
var row = vm.Rows[0];
Assert.Equal(31, row.DaysMask());
row.Saturday = true;
Assert.Equal(63, row.DaysMask());
}
[Fact]
public async Task Save_Diffs_New_And_Removed_Rows()
{
var api = new FakeApi();
var keptId = Guid.NewGuid();
var deletedId = Guid.NewGuid();
api.Stored.Add(Dto(keptId, 31, new TimeSpan(7, 0, 0)));
api.Stored.Add(Dto(deletedId, 31, new TimeSpan(8, 0, 0)));
var vm = new PrimeClaudeTabViewModel(api);
await vm.LoadAsync();
vm.RemoveScheduleCommand.Execute(vm.Rows.Single(r => r.Id == deletedId));
vm.AddScheduleCommand.Execute(null);
await vm.SaveAsync();
Assert.Contains(deletedId, api.Deletes);
Assert.Equal(2, api.Upserts.Count);
}
[Fact]
public void Validate_Reports_No_Days_Selected()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
var row = vm.Rows[0];
row.Monday = row.Tuesday = row.Wednesday = row.Thursday = row.Friday = false;
Assert.NotNull(vm.Validate());
}
[Fact]
public void Validate_Passes_With_One_Day()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
Assert.Null(vm.Validate());
}
}
```
- [ ] **Step 5: Run UI tests**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter FullyQualifiedName~PrimeClaudeTabViewModelTests`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs
git commit -m "feat(ui): drive prime schedule rows from weekday toggles"
```
---
## Task 8: XAML — toggle-button row
**Files:**
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
- Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
- [ ] **Step 1: Add a `day-toggle` style class**
Append to `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (inside the root `<Styles>` element, alongside the other style selectors). Uses existing dynamic-resource tokens — no hardcoded colors:
```xml
<Style Selector="ToggleButton.day-toggle">
<Setter Property="MinWidth" Value="34"/>
<Setter Property="Padding" Value="6,4"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="4"/>
</Style>
<Style Selector="ToggleButton.day-toggle:checked /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
</Style>
```
If `AccentBrush` is not a defined token, use the brush the project uses for primary/selected affordances (check the `primary` button style in this file and reuse that brush). Final visual pass is the user's.
- [ ] **Step 2: Replace the Prime row template**
In `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`, replace the `<Grid ...>` inside the Prime `DataTemplate` (currently columns `Auto,*,Auto,Auto,Auto,Auto` with the `ThemedDatePicker` and MonFri checkbox) with:
```xml
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="8">
<CheckBox Grid.Column="0" IsChecked="{Binding Enabled, Mode=TwoWay}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<ToggleButton Classes="day-toggle" Content="Mo" IsChecked="{Binding Monday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Tu" IsChecked="{Binding Tuesday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="We" IsChecked="{Binding Wednesday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Th" IsChecked="{Binding Thursday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Fr" IsChecked="{Binding Friday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Sa" IsChecked="{Binding Saturday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Su" IsChecked="{Binding Sunday, Mode=TwoWay}"/>
</StackPanel>
<TextBox Grid.Column="2" Width="64"
Text="{Binding TimeOfDay, Mode=TwoWay, Converter={StaticResource TimeSpanToHhmm}}"
VerticalAlignment="Center"/>
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
MinWidth="80"/>
<Button Classes="icon-btn" Grid.Column="4" Content="✕"
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
CommandParameter="{Binding}"/>
</Grid>
```
- [ ] **Step 3: Update the explainer text**
Replace the intro `TextBlock` Text in the Prime tab (`SettingsModalView.axaml`):
```xml
Text="Prime your Claude usage window by firing a single non-interactive ping on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately."/>
```
- [ ] **Step 4: Remove the now-unused range converter (only if unreferenced)**
The `DateOnlyToDateTime` resource on line 23 was used only by the range picker. Grep the file: if `DateOnlyToDateTime` has no other reference, remove the `<conv:DateOnlyToDateTimeConverter x:Key="DateOnlyToDateTime"/>` line. Keep `TimeSpanToHhmm` (still used).
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.
- [ ] **Step 5: Manual UI check**
Start the worker, then the app. Open Settings → Prime Claude. Verify: a row shows 7 toggle buttons with MonFri lit by default; toggling Sat/Sun persists after Save+reopen; clearing all days shows the validation error on Save. (UI correctness can only be confirmed in the running app — state so explicitly if it cannot be run.)
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
git commit -m "feat(ui): replace prime date range with weekday toggle buttons"
```
---
## Task 9: Docs
**Files:**
- Modify: `src/ClaudeDo.Data/CLAUDE.md`
- Modify: `CLAUDE.md`
- [ ] **Step 1: Update the Data CLAUDE.md**
In `src/ClaudeDo.Data/CLAUDE.md`, the Models section has no PrimeSchedule line today; add one under Models, and confirm the `prime_schedules` table mention in the Schema section stays accurate:
```markdown
- **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, stored as `days_of_week` int), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on the selected weekdays; no date range.
```
- [ ] **Step 2: Update the root CLAUDE.md if Prime is described**
Grep `CLAUDE.md` for "Prime"; if there is a Prime description mentioning a date range, update it to "recurring weekday schedule". If there is no such line, make no change.
- [ ] **Step 3: Full test sweep**
Run: `dotnet test tests/ClaudeDo.Worker.Tests && dotnet test tests/ClaudeDo.Ui.Tests`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Data/CLAUDE.md CLAUDE.md
git commit -m "docs: describe recurring-weekday Prime schedule"
```
---
## Self-Review Notes
- **Spec coverage:** data model (T1), scheduling logic (T4), UI toggles (T7T8), migration+backfill (T3), both DTOs (T4/T7), tests (T4T7), out-of-scope items excluded. ✓
- **Type consistency:** entity `PrimeDays Days`; both DTOs `int Days`; hub/scheduler cast `(int)`/`(PrimeDays)` at boundaries; calculator casts `(PrimeDays)s.Days`; row VM exposes 7 bools + `DaysMask()`. ✓
- **Build ripple:** a single type change breaks several projects at once, so some intermediate steps note expected build failures; the gating green builds are T3 Step 4 (Data), T6 Step 4 (Worker + tests), T8 Step 4 (App). ✓
```

View File

@@ -0,0 +1,517 @@
# Daily Prep — Live Output View + Clear Day — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stream the daily-prep run's output into a live, human-readable view (a new mode in the Details island), and add a "Clear Day" button that empties MyDay.
**Architecture:** The worker broadcasts `PrepStarted/PrepLine/PrepFinished` over SignalR (mirroring `TaskStarted/TaskMessage/TaskFinished`). `PrimeRunner` forwards each Claude stdout line instead of discarding it. The UI `WorkerClient` re-raises these as events; `DetailsIslandViewModel` gains a `PrepLog` + `IsPrepMode` panel rendered with the existing terminal renderer. A `ClearMyDay` hub method bulk-clears `IsMyDay`. MyDay header gets "Vorbereitungs-Log" and "Tag leeren" buttons.
**Tech Stack:** .NET 8, ASP.NET Core SignalR, EF Core (SQLite), Avalonia + CommunityToolkit.Mvvm, xUnit.
**Spec:** `docs/superpowers/specs/2026-06-03-daily-prep-live-view-design.md`
---
## Build & test commands
`.slnx` needs .NET 9; build/test individual csproj with `-c Release` (a running Worker may lock Debug).
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
```
UI cannot be GUI-smoke-tested headlessly — note that explicitly where it applies; the human verifies visuals.
## Reference anchors (verify before editing — line numbers drift)
- `src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs` — currently only `PrimeFiredAsync`.
- `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs:13-57` — broadcast methods; `PrimeFired` at ~52-56.
- `src/ClaudeDo.Worker/Prime/PrimeRunner.cs:31-79``FireAsync`; discard lambda at ~55-60; ctor at ~19-29.
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs:542-549``RunDailyPrepNow` (uses `_broadcaster`); DailyNote CRUD at 559-583 (shows the db-context pattern this hub uses).
- `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs:19``TaskMessageEvent`; `:55``RunDailyPrepNowAsync`.
- `src/ClaudeDo.Ui/Services/WorkerClient.cs:99-122``TaskStarted/Finished/Message` hub.On; `:170-173``PrimeFired` hub.On (the pattern to copy).
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs``IsNotesMode` ~56, `Log` ~193, ctor/subscriptions ~272-337, `OnTaskMessage` ~339-363 (stdout→`StreamLineFormatter``Log`), `ShowNotes` ~478-483.
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml:131-302` — body grid; task panel `IsVisible="{Binding !IsNotesMode}"`, notes panel `IsVisible="{Binding IsNotesMode}"`; `SessionTerminalView` embedded ~295.
- `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml:54-75``ItemsControl ItemsSource="{Binding Log}"` + the `LogLineViewModel` item template to reuse.
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs``NotesRequested` ~29, `OpenNotesCommand`+`PrepareDayCommand` ~33-45, `ShowNotesRow`/`IsMyDayList` ~65-66, both set in `LoadForList` ~212-213.
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml:69-84` — Notes + PrepareDay buttons (styling to copy).
- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs:199-201` — island event wiring; `:225``PrimeFired` subscription.
- Fakes to keep in sync: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (`FakeWorkerClient`).
---
## Task 1: Worker — prep output broadcast + streaming
**Files:**
- Modify: `src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs`
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`
- Modify: `src/ClaudeDo.Worker/Prime/PrimeRunner.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs`
- [ ] **Step 1: Write the failing test.** Extend `PrimeRunnerTests` with a fake `IPrimeBroadcaster` that records calls. The fake `IClaudeProcess` should invoke `onStdoutLine` with two sample lines and return `RunResult { ExitCode = 0, ResultMarkdown = "ok" }`.
```csharp
[Fact]
public async Task FireAsync_streams_started_lines_and_finished()
{
var broadcaster = new RecordingPrimeBroadcaster();
var claude = new FakeClaudeProcess(emitLines: new[] { "{\"a\":1}", "{\"b\":2}" }, exitCode: 0, result: "ok");
var runner = NewRunner(claude, broadcaster); // build with temp-sqlite dbFactory + fake clock + logger + broadcaster
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
var outcome = await runner.FireAsync(schedule, CancellationToken.None);
Assert.True(outcome.Success);
Assert.Equal(1, broadcaster.StartedCount);
Assert.Equal(new[] { "{\"a\":1}", "{\"b\":2}" }, broadcaster.Lines);
Assert.Single(broadcaster.FinishedResults);
Assert.True(broadcaster.FinishedResults[0]);
}
```
`RecordingPrimeBroadcaster` implements `IPrimeBroadcaster`: `StartedCount`, `List<string> Lines`, `List<bool> FinishedResults`, and a no-op `PrimeFiredAsync`. If the existing `FakeClaudeProcess` cannot emit lines, add an optional `emitLines` parameter that loops `await onStdoutLine(line)` before returning.
- [ ] **Step 2: Run — expect FAIL** (interface methods + ctor param missing).
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter PrimeRunner
```
- [ ] **Step 3: Extend `IPrimeBroadcaster`:**
```csharp
public interface IPrimeBroadcaster
{
Task PrimeFiredAsync(Guid scheduleId, bool success, string message, DateTimeOffset firedAt);
Task PrepStartedAsync();
Task PrepLineAsync(string line);
Task PrepFinishedAsync(bool success);
}
```
(Keep the existing `PrimeFiredAsync` signature exactly as it is in the current file.)
- [ ] **Step 4: Implement in `HubBroadcaster`** (add next to `PrimeFired`):
```csharp
public Task PrepStarted() => _hub.Clients.All.SendAsync("PrepStarted");
public Task PrepLine(string line) => _hub.Clients.All.SendAsync("PrepLine", line);
public Task PrepFinished(bool success) => _hub.Clients.All.SendAsync("PrepFinished", success);
Task IPrimeBroadcaster.PrepStartedAsync() => PrepStarted();
Task IPrimeBroadcaster.PrepLineAsync(string line) => PrepLine(line);
Task IPrimeBroadcaster.PrepFinishedAsync(bool success) => PrepFinished(success);
```
(Match the existing explicit-interface style used for `PrimeFiredAsync`.)
- [ ] **Step 5: Wire `PrimeRunner`.** Add `IPrimeBroadcaster _broadcaster` as a ctor param (and field). Rewrite the body of `FireAsync` after the gate check to:
```csharp
if (!await _gate.WaitAsync(0, ct))
return new PrimeRunOutcome(false, "Daily prep already running");
var success = false;
try
{
await _broadcaster.PrepStartedAsync();
var cwd = Paths.AppDataRoot();
Directory.CreateDirectory(cwd);
int maxTasks;
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
{
var settings = await new AppSettingsRepository(dbCtx).GetAsync(ct);
maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
}
var today = DateOnly.FromDateTime(_clock.Now.LocalDateTime);
var prompt = DailyPrepPrompt.BuildPrompt(maxTasks, today);
var args = DailyPrepPrompt.BuildArgs(MaxTurns);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(FireTimeout);
var result = await _claude.RunAsync(
arguments: args,
prompt: prompt,
workingDirectory: cwd,
onStdoutLine: line => _broadcaster.PrepLineAsync(line),
ct: timeoutCts.Token);
success = result.IsSuccess;
return success
? new PrimeRunOutcome(true, "Daily prep complete")
: new PrimeRunOutcome(false, $"exit code {result.ExitCode}");
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Daily prep run failed");
return new PrimeRunOutcome(false, ex.Message);
}
finally
{
await _broadcaster.PrepFinishedAsync(success);
_gate.Release();
}
```
DI is unchanged: `AddSingleton<IPrimeRunner, PrimeRunner>()` resolves `IPrimeBroadcaster` (registered as `sp => sp.GetRequiredService<HubBroadcaster>()`).
- [ ] **Step 6: Update existing `PrimeRunnerTests` ctor calls** to pass the recording broadcaster; build + run.
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter PrimeRunner
```
- [ ] **Step 7: Commit.**
```bash
git add src/ClaudeDo.Worker/Prime src/ClaudeDo.Worker/Hub/HubBroadcaster.cs tests/ClaudeDo.Worker.Tests/Prime
git commit -m "feat(daily-prep): stream prep output via PrepStarted/PrepLine/PrepFinished"
```
---
## Task 2: Worker — `ClearMyDay` hub method
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- Test: a new/existing hub test under `tests/ClaudeDo.Worker.Tests/Hub/` (mirror an existing hub test that seeds a real SQLite db and constructs `WorkerHub`)
- [ ] **Step 1: Write the failing test.** Seed three tasks: two with `IsMyDay=true` (one Idle, one Done), one with `IsMyDay=false`. Construct `WorkerHub` the way existing hub tests do (the same `null!` argument list, plus a recording `HubBroadcaster`/clients). Call `ClearMyDay()`; assert both MyDay rows are now `false`, the third is untouched, and the returned count is 2.
```csharp
[Fact]
public async Task ClearMyDay_clears_all_isMyDay_tasks()
{
// seed via the test's db helper ...
var hub = NewHub(/* ... */);
var cleared = await hub.ClearMyDay();
Assert.Equal(2, cleared);
await using var ctx = NewContext();
Assert.False(await ctx.Tasks.AnyAsync(t => t.IsMyDay));
}
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Add the method** to `WorkerHub` (use the same db-context acquisition the neighbouring hub methods use — e.g. `_dbFactory`/repository field name found in the file — and the existing `_broadcaster` field):
```csharp
public async Task<int> ClearMyDay()
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var ids = await ctx.Tasks.Where(t => t.IsMyDay).Select(t => t.Id).ToListAsync();
if (ids.Count == 0) return 0;
await ctx.Tasks.Where(t => t.IsMyDay)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.IsMyDay, false));
foreach (var id in ids)
await _broadcaster.TaskUpdated(id);
return ids.Count;
}
```
If `WorkerHub` does not already have an `IDbContextFactory<ClaudeDoDbContext>` field, use whatever data-access dependency the other hub methods use (read the file). Do NOT add a new ctor param unless unavoidable (it would break hub-test fakes — if you must, update all `new WorkerHub(...)` call sites).
- [ ] **Step 4: Run — expect PASS.** Build Worker.
- [ ] **Step 5: Commit.**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Hub
git commit -m "feat(daily-prep): add ClearMyDay hub method"
```
---
## Task 3: UI — WorkerClient prep events + ClearMyDayAsync
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify fakes: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (FakeWorkerClient)
- [ ] **Step 1: Declare on `IWorkerClient`** (near `TaskMessageEvent` / `RunDailyPrepNowAsync`):
```csharp
event Action? PrepStartedEvent;
event Action<string>? PrepLineEvent;
event Action<bool>? PrepFinishedEvent;
Task ClearMyDayAsync();
```
- [ ] **Step 2: Implement in `WorkerClient`.** Add the events; register hub callbacks mirroring the `PrimeFired` registration (~line 170):
```csharp
public event Action? PrepStartedEvent;
public event Action<string>? PrepLineEvent;
public event Action<bool>? PrepFinishedEvent;
// in the hub-wiring section:
_hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke()));
_hub.On<string>("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line)));
_hub.On<bool>("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok)));
public Task ClearMyDayAsync() => _connection.InvokeAsync("ClearMyDay");
```
(Use the exact connection field name and async-call style of neighbouring methods like `RunDailyPrepNowAsync` / `GenerateWeekReport`. `ClearMyDay` returns `int` on the hub; invoking it as a void `InvokeAsync("ClearMyDay")` is fine, or `InvokeAsync<int>` if you want the count.)
- [ ] **Step 3: Update the fakes.** Add the three events (as `public event …` auto-implemented) and `ClearMyDayAsync() => Task.CompletedTask` to both `StubWorkerClient` and `FakeWorkerClient`. For the ClearDay command test (Task 5), give `StubWorkerClient` a `ClearMyDayCalls` counter incremented in `ClearMyDayAsync`.
- [ ] **Step 4: Build App + both test projects; fix any remaining fake gaps.**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
- [ ] **Step 5: Commit.**
```bash
git add src/ClaudeDo.Ui/Services tests
git commit -m "feat(daily-prep): expose prep stream events and ClearMyDay on the UI worker client"
```
---
## Task 4: UI — Details island prep mode + live log
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
- Test: `tests/ClaudeDo.Ui.Tests/...DetailsIslandViewModel...` (mirror existing Details VM tests; if none, add a small test file)
- [ ] **Step 1: Write the failing test.** Construct `DetailsIslandViewModel` with a `StubWorkerClient` (mirror existing construction). Then:
```csharp
[Fact]
public void PrepLine_event_appends_to_PrepLog()
{
var stub = new StubWorkerClient();
var vm = NewDetailsVm(stub);
stub.RaisePrepLine("{\"type\":\"assistant\",\"text\":\"hi\"}"); // helper that invokes PrepLineEvent
Assert.NotEmpty(vm.PrepLog);
}
[Fact]
public void ShowPrep_sets_prep_mode_and_clears_notes_mode()
{
var vm = NewDetailsVm(new StubWorkerClient());
vm.ShowPrep();
Assert.True(vm.IsPrepMode);
Assert.False(vm.IsNotesMode);
}
```
Add `RaisePrepStarted/RaisePrepLine/RaisePrepFinished` helpers to `StubWorkerClient` that invoke the corresponding events.
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Implement in `DetailsIslandViewModel`:**
- Add `[ObservableProperty] private bool _isPrepMode;` and `[ObservableProperty] private bool _isPrepRunning;`.
- Add `public ObservableCollection<LogLineViewModel> PrepLog { get; } = new();`.
- In the ctor, subscribe: `_worker.PrepStartedEvent += OnPrepStarted; _worker.PrepLineEvent += OnPrepLine; _worker.PrepFinishedEvent += OnPrepFinished;` (guard with the same `_worker is not null` pattern used for other events).
- Handlers:
```csharp
private void OnPrepStarted()
{
PrepLog.Clear();
IsPrepRunning = true;
}
private void OnPrepLine(string line) => AppendStdoutLine(PrepLog, line);
private void OnPrepFinished(bool success) => IsPrepRunning = false;
```
- Factor the stdout-formatting currently inside `OnTaskMessage` into a reusable
`private void AppendStdoutLine(ObservableCollection<LogLineViewModel> target, string line)`
that runs the line through `StreamLineFormatter` and appends `LogLineViewModel`(s).
Have `OnTaskMessage`'s stdout branch call `AppendStdoutLine(Log, strippedLine)` so both
paths share one implementation. (Events arrive already on the UI thread via
`Dispatcher.UIThread.Post` in `WorkerClient`, so direct collection mutation is correct.)
- Add `public void ShowPrep()` mirroring `ShowNotes()`: call `Bind(null)`, set
`IsNotesMode = false`, `IsPrepMode = true`.
- In `ShowNotes()` add `IsPrepMode = false`. In `Bind(...)` reset both `IsNotesMode` and
`IsPrepMode` to false (find where `IsNotesMode` is reset; add `IsPrepMode` beside it).
- [ ] **Step 4: Update `DetailsIslandView.axaml`.**
- Change the task-details panel visibility from `IsVisible="{Binding !IsNotesMode}"` to a
converter-free multi-condition. Avalonia lacks `&&` in bindings, so add a computed
property `public bool IsTaskDetailVisible => !IsNotesMode && !IsPrepMode;` to the VM
(raise its change notification from the `OnIsNotesModeChanged`/`OnIsPrepModeChanged`
partial methods generated by `[ObservableProperty]`) and bind the task panel to
`IsVisible="{Binding IsTaskDetailVisible}"`.
- Add a third panel after the notes panel:
```xml
<Panel IsVisible="{Binding IsPrepMode}">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Margin="16,12"
Text="{loc:Tr details.prepTitle}" Classes="h2"/>
<ScrollViewer>
<ItemsControl ItemsSource="{Binding PrepLog}"/>
</ScrollViewer>
</DockPanel>
</Panel>
```
The `ItemsControl` reuses the implicit `LogLineViewModel` `DataTemplate` that
`SessionTerminalView` relies on. If that template is defined locally inside
`SessionTerminalView.axaml` (not in a shared resource), either move it to a shared
`ResourceDictionary` (e.g. App resources) and reference it from both, or set the
`ItemsControl.ItemTemplate` to a copy of that template. Prefer sharing over copying.
Add `details.prepTitle` ("Daily prep" / "Tagesvorbereitung") to both locale json files.
- [ ] **Step 5: Run UI tests — expect PASS; build App.**
```bash
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
```
- [ ] **Step 6: Commit.**
```bash
git add src/ClaudeDo.Ui src/ClaudeDo.Localization tests/ClaudeDo.Ui.Tests
git commit -m "feat(daily-prep): add live prep-output mode to the Details island"
```
---
## Task 5: UI — MyDay buttons + shell wiring
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- Modify: `src/ClaudeDo.Localization/locales/en.json`, `de.json`
- Test: `tests/ClaudeDo.Worker.Tests/UiVm/...` or `tests/ClaudeDo.Ui.Tests/...` (TasksIslandViewModel)
- [ ] **Step 1: Write the failing tests.**
```csharp
[Fact]
public async Task ClearDayCommand_calls_worker()
{
var stub = new StubWorkerClient();
var vm = NewTasksVm(stub);
await vm.ClearDayCommand.ExecuteAsync(null);
Assert.Equal(1, stub.ClearMyDayCalls);
}
[Fact]
public async Task PrepareDayCommand_raises_PrepRequested()
{
var vm = NewTasksVm(new StubWorkerClient());
var raised = false;
vm.PrepRequested += () => raised = true;
await vm.PrepareDayCommand.ExecuteAsync(null);
Assert.True(raised);
}
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Implement in `TasksIslandViewModel`:**
- Add `public event Action? PrepRequested;` next to `NotesRequested`.
- In `PrepareDayAsync` (the existing `[RelayCommand]`), raise `PrepRequested?.Invoke();`
in addition to the existing `RunDailyPrepNowAsync()` call.
- Add:
```csharp
[RelayCommand]
private void ShowPrepLog() => PrepRequested?.Invoke();
[RelayCommand]
private async Task ClearDayAsync()
{
if (_worker is null) return;
try { await _worker.ClearMyDayAsync(); }
catch { /* worker offline; broadcast will reconcile on return */ }
}
```
- [ ] **Step 4: Add the two buttons** to the MyDay header in `TasksIslandView.axaml`,
immediately after the existing "Prepare day" button (~line 84), copying its styling
(`DockPanel.Dock="Top" Classes="btn" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Margin="16,0,16,8" IsVisible="{Binding IsMyDayList}"`):
```xml
<Button DockPanel.Dock="Top" Classes="btn" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Margin="16,0,16,8"
IsVisible="{Binding IsMyDayList}"
Command="{Binding ShowPrepLogCommand}"
Content="{loc:Tr tasks.prepLog}"/>
<Button DockPanel.Dock="Top" Classes="btn" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Margin="16,0,16,8"
IsVisible="{Binding IsMyDayList}"
Command="{Binding ClearDayCommand}"
Content="{loc:Tr tasks.clearDay}"/>
```
Add `tasks.prepLog` (en "Prep log" / de "Vorbereitungs-Log") and `tasks.clearDay`
(en "Clear day" / de "Tag leeren") to both locale json files.
- [ ] **Step 5: Wire the shell.** In `IslandsShellViewModel` where `Tasks.NotesRequested`
is wired (~line 201), add:
```csharp
Tasks.PrepRequested += () => Details.ShowPrep();
```
- [ ] **Step 6: Run tests + build App.**
```bash
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
```
- [ ] **Step 7: Manual smoke (human, not headless):** start Worker + App, open MyDay, click
"Tag vorbereiten" → Details island opens in prep mode and streams readable lines; click
"Tag leeren" → MyDay empties; after a scheduled run, "Vorbereitungs-Log" opens the filled
log. Confirm the three buttons only appear on MyDay.
- [ ] **Step 8: Commit.**
```bash
git add src/ClaudeDo.Ui src/ClaudeDo.Localization tests
git commit -m "feat(daily-prep): add Prep-log and Clear-day buttons to MyDay header"
```
---
## Final verification
- [ ] Build Worker + App (Release).
- [ ] `dotnet test` Worker.Tests, Ui.Tests, Localization.Tests — all green.
- [ ] Manual: prep streams live into the Details island (manual opens it; scheduled fills it silently, opened via the button); Clear Day empties MyDay immediately.
## Notes / risks
- Mode flags `IsNotesMode` / `IsPrepMode` are mutually exclusive; the task-details panel
uses the computed `IsTaskDetailVisible`. Verify all three modes switch cleanly.
- Reusing the `LogLineViewModel` template: prefer promoting it to a shared resource over
copying, to avoid drift between the session terminal and the prep log.
- `ClearMyDay` broadcasts one `TaskUpdated` per affected id; MyDay is small (capped), so
this is fine.
- Keep `PrimeRunner`'s "already running" early-return emitting no prep events.

View File

@@ -0,0 +1,736 @@
# Daily Prep ("Prime Claude") Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn the Prime Time warm-up into a daily preparation where Claude reads open tasks and moves an effort-aware, capped subset into MyDay, triggered by the Prime schedule and a manual button.
**Architecture:** Agentic. Two new tools on the always-on `ExternalMcpService` (`get_daily_prep_candidates`, `set_my_day` with a server-side cap-guard). The existing `PrimeRunner` is rewritten to launch a headless `claude -p` run with a fixed parameterized prompt and `--allowedTools` for those two tools, relying on the already-registered `claudedo` MCP (no separate `--mcp-config`). A new `DailyPrepMaxTasks` app setting drives the cap. A manual hub method reuses the same runner with a single-flight guard.
**Tech Stack:** .NET 8, ASP.NET Core, EF Core (SQLite), SignalR, ModelContextProtocol, Avalonia (CommunityToolkit.Mvvm), xUnit.
**Spec:** `docs/superpowers/specs/2026-06-03-daily-prep-design.md`
---
## Deviation from spec (deliberate, to minimize churn)
The spec proposed renaming `IPrimeRunner`/`PrimeRunner`/`PrimeScheduler``DailyPrep*`. **We keep the existing names and the `FireAsync(PrimeScheduleDto, ct)` signature** and only rewrite the runner body. This avoids touching the scheduler, DI registration, `IPrimeBroadcaster`, and the existing Prime tests for a pure rename. The per-schedule `PromptOverride` field becomes unused by the runner (left in the DB/UI untouched).
## Build & test commands (this repo)
`.slnx` needs .NET 9; on .NET 8 build/test individual projects. Use `-c Release` if a running Worker locks `Debug`.
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
```
Tests use **real SQLite + real git** (project convention). Mirror the setup already present in the test file you are extending.
---
## File Structure
**Create**
- `src/ClaudeDo.Data/Migrations/<timestamp>_DailyPrepMaxTasks.cs` (+ Designer, via `dotnet ef`)
- `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs` — pure prompt + args builder (easy to unit-test)
**Modify**
- `src/ClaudeDo.Data/Models/AppSettingsEntity.cs` — add `DailyPrepMaxTasks`
- `src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs` — map column
- `src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs` — persist field in `UpdateAsync`
- `src/ClaudeDo.Worker/External/ExternalMcpService.cs` — add 2 tools + DTOs
- `src/ClaudeDo.Worker/Prime/PrimeRunner.cs` — rewrite body to daily prep + single-flight
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add `DailyPrepMaxTasks` to AppSettings DTO + `RunDailyPrepNow`
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — mirror `DailyPrepMaxTasks` in the UI AppSettings DTO + add `RunDailyPrepNow` call
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs` (+ its view) — numeric editor for `DailyPrepMaxTasks`
- MyDay list header view + its ViewModel — "Tag vorbereiten" button + command
**Test**
- `tests/ClaudeDo.Data.Tests/...AppSettings...` — new field persists / default 5
- `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` — candidate filter + set_my_day + cap-guard
- `tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs` — prompt/args content
- `tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs` (if present) — single-flight + success/failure via `IClaudeProcess` fake
---
## Task 1: `DailyPrepMaxTasks` app setting
**Files:**
- Modify: `src/ClaudeDo.Data/Models/AppSettingsEntity.cs`
- Modify: `src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs`
- Modify: `src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs`
- Create (via `dotnet ef`): `src/ClaudeDo.Data/Migrations/<timestamp>_DailyPrepMaxTasks.cs`
- Test: `tests/ClaudeDo.Data.Tests` (extend existing AppSettings repository test, or add `AppSettingsRepositoryTests.cs`)
- [ ] **Step 1: Write the failing test**
In a Data.Tests file (mirror the existing repo test harness that opens a real SQLite `ClaudeDoDbContext`):
```csharp
[Fact]
public async Task DailyPrepMaxTasks_defaults_to_5_and_persists()
{
await using var ctx = NewContext(); // existing helper that migrates a temp sqlite db
var repo = new AppSettingsRepository(ctx);
var initial = await repo.GetAsync();
Assert.Equal(5, initial.DailyPrepMaxTasks);
initial.DailyPrepMaxTasks = 8;
await repo.UpdateAsync(initial);
var reloaded = await repo.GetAsync();
Assert.Equal(8, reloaded.DailyPrepMaxTasks);
}
```
- [ ] **Step 2: Run it — expect FAIL** (`AppSettingsEntity` has no `DailyPrepMaxTasks`).
```bash
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter DailyPrepMaxTasks_defaults_to_5_and_persists
```
- [ ] **Step 3: Add the property** to `AppSettingsEntity.cs` after `StandupWeekday`:
```csharp
// Max number of open tasks the daily prep ("Prime Claude") may place in MyDay.
public int DailyPrepMaxTasks { get; set; } = 5;
```
- [ ] **Step 4: Map the column** in `AppSettingsEntityConfiguration.cs`, after the `StandupWeekday` mapping (before `builder.HasData(...)`):
```csharp
builder.Property(s => s.DailyPrepMaxTasks)
.HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5);
```
- [ ] **Step 5: Persist it** in `AppSettingsRepository.UpdateAsync`, after the `StandupWeekday` assignment:
```csharp
row.DailyPrepMaxTasks = updated.DailyPrepMaxTasks < 1 ? 1 : updated.DailyPrepMaxTasks;
```
- [ ] **Step 6: Generate the migration** (regenerates the model snapshot — do NOT hand-edit the snapshot):
```bash
dotnet ef migrations add DailyPrepMaxTasks \
-p src/ClaudeDo.Data/ClaudeDo.Data.csproj \
-s src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Verify the generated `Up` contains an `AddColumn<int>("daily_prep_max_tasks", ... defaultValue: 5)` and an `UpdateData` setting the singleton row's `daily_prep_max_tasks` to 5. If `dotnet ef` is unavailable, hand-write the migration mirroring `20260603072822_WeeklyReport.cs` **and** add the matching `Property<int>("DailyPrepMaxTasks").HasColumnName("daily_prep_max_tasks")` line to `ClaudeDoDbContextModelSnapshot.cs` under the `AppSettingsEntity` builder.
- [ ] **Step 7: Run the test — expect PASS.**
- [ ] **Step 8: Commit.**
```bash
git add src/ClaudeDo.Data tests/ClaudeDo.Data.Tests
git commit -m "feat(daily-prep): add DailyPrepMaxTasks app setting"
```
---
## Task 2: `get_daily_prep_candidates` MCP tool
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
Read `ExternalMcpServiceTests.cs` first and reuse its existing harness (how it builds an `ExternalMcpService` with a real SQLite context, `ListRepository`, `TaskRepository`, fake `HubBroadcaster`, etc.). The new tool reads **all** lists/tasks itself via the injected `_dbFactory`, so it needs no new constructor args.
- [ ] **Step 1: Write the failing test.** Seed: a list with `WorkingDir = @"D:\work\repo"` holding two `Idle` tasks (one blocked, one not) and one `Done` task; a second list with `WorkingDir = @"C:\Private\secret"` holding one `Idle` task; a third list with `WorkingDir = null` holding one `Idle` task; and one `Idle` task with `IsMyDay = true` in the first list. Set `AppSettings.ReportExcludedPaths = "[\"C:\\\\Private\"]"`.
```csharp
[Fact]
public async Task GetDailyPrepCandidates_filters_by_status_block_and_excluded_repo()
{
// ... seed as described, using the file's existing seed helpers ...
var svc = NewService();
var result = await svc.GetDailyPrepCandidates(CancellationToken.None);
// Only the non-blocked, Idle, non-MyDay task in the non-excluded repo is a candidate.
Assert.Single(result.Candidates);
Assert.Equal("idle-unblocked", result.Candidates[0].Id);
// The Idle MyDay task is reported separately, not as a candidate.
Assert.Single(result.CurrentMyDay);
Assert.Equal(1, result.MaxTasks > 0 ? 1 : 1); // MaxTasks comes from AppSettings (default 5)
Assert.Equal(5, result.MaxTasks);
}
```
- [ ] **Step 2: Run it — expect FAIL** (method missing).
- [ ] **Step 3: Add the DTOs** near the other record declarations at the top of `ExternalMcpService.cs`:
```csharp
public sealed record DailyPrepCandidateDto(
string Id, string ListId, string ListName, string Title, string? Description,
bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt);
public sealed record DailyPrepDataDto(
int MaxTasks,
IReadOnlyList<DailyPrepCandidateDto> Candidates,
IReadOnlyList<DailyPrepCandidateDto> CurrentMyDay);
```
- [ ] **Step 4: Add the tool method** to the `ExternalMcpService` class body:
```csharp
[McpServerTool, Description(
"Daily prep: returns the open tasks eligible for today's MyDay selection. " +
"candidates = Idle, not blocked, in a git repo not excluded from the weekly report, and not already in MyDay. " +
"currentMyDay = Idle tasks already flagged IsMyDay (count them toward the cap). " +
"maxTasks = the hard cap on total open MyDay tasks. Use set_my_day to add tasks (never exceed maxTasks).")]
public async Task<DailyPrepDataDto> GetDailyPrepCandidates(CancellationToken cancellationToken)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
var excludes = DailyPrepFilter.ParseExcludes(settings.ReportExcludedPaths);
var maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
var idle = await ctx.Tasks
.AsNoTracking()
.Include(t => t.List)
.Where(t => t.Status == TaskStatus.Idle)
.ToListAsync(cancellationToken);
var currentMyDay = idle
.Where(t => t.IsMyDay)
.OrderBy(t => t.SortOrder)
.Select(ToCandidate)
.ToList();
var candidates = idle
.Where(t => !t.IsMyDay
&& t.BlockedByTaskId == null
&& DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes))
.OrderBy(t => t.CreatedAt)
.Select(ToCandidate)
.ToList();
return new DailyPrepDataDto(maxTasks, candidates, currentMyDay);
}
private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new(
t.Id, t.ListId, t.List?.Name ?? "", t.Title, t.Description,
t.IsStarred, t.ScheduledFor, t.CreatedAt);
```
- [ ] **Step 5: Add the filter helper** as a small static class at the bottom of `ExternalMcpService.cs` (single-consumer helper lives beside its consumer, per repo convention):
```csharp
internal static class DailyPrepFilter
{
public static string[] ParseExcludes(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return [];
try
{
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
return list is null ? [] : list.Select(Normalize).Where(p => p.Length > 0).ToArray();
}
catch (System.Text.Json.JsonException) { return []; }
}
public static bool IsIncludedRepo(string? workingDir, string[] excludes)
{
if (string.IsNullOrWhiteSpace(workingDir)) return false; // not a repo → excluded
var norm = Normalize(workingDir);
return !excludes.Any(p => norm.StartsWith(p, StringComparison.OrdinalIgnoreCase));
}
private static string Normalize(string path) =>
path.Trim().Replace('/', '\\').TrimEnd('\\');
}
```
Add `using ClaudeDo.Data.Repositories;` if not already present (it is, via existing usings).
- [ ] **Step 6: Run the test — expect PASS.**
- [ ] **Step 7: Commit.**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "feat(daily-prep): add get_daily_prep_candidates MCP tool"
```
---
## Task 3: `set_my_day` MCP tool with cap-guard
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
- [ ] **Step 1: Write the failing tests.**
```csharp
[Fact]
public async Task SetMyDay_sets_flag_and_sort_order()
{
var svc = NewService();
var id = await SeedIdleTask("My task"); // existing/added helper returning task id
var dto = await svc.SetMyDay(id, isMyDay: true, sortOrder: 3, CancellationToken.None);
Assert.True(dto.IsMyDay);
Assert.Equal(3, dto.SortOrder);
}
[Fact]
public async Task SetMyDay_rejects_when_cap_reached()
{
// AppSettings.DailyPrepMaxTasks = 1 (set in seed)
var svc = NewService();
var first = await SeedIdleTask("a");
var second = await SeedIdleTask("b");
await svc.SetMyDay(first, true, null, CancellationToken.None);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.SetMyDay(second, true, null, CancellationToken.None));
Assert.Contains("limit", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SetMyDay_unset_is_always_allowed()
{
var svc = NewService();
var id = await SeedIdleTask("a");
await svc.SetMyDay(id, true, null, CancellationToken.None);
var dto = await svc.SetMyDay(id, false, null, CancellationToken.None);
Assert.False(dto.IsMyDay);
}
```
`SetMyDay` returns the existing `TaskDto`. Add a `SortOrder` field to `TaskDto` — see Step 3a. (`SeedIdleTask` / the `DailyPrepMaxTasks=1` seed reuse the file's existing seeding helpers.)
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3a: Add `SortOrder` to `TaskDto`** (record + `ToDto`) so the result reflects ordering:
In the `TaskDto` record add `int SortOrder` as the last positional member, and in `ToDto(TaskEntity t)` add `t.SortOrder` as the last argument. (Update any test that constructs `TaskDto` positionally — search the test project.)
- [ ] **Step 3b: Add the tool method:**
```csharp
[McpServerTool, Description(
"Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " +
"(use consecutive sortOrder values to keep related tasks together). " +
"Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " +
"clearing (isMyDay=false) is always allowed.")]
public async Task<TaskDto> SetMyDay(
string taskId,
bool isMyDay,
int? sortOrder,
CancellationToken cancellationToken)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (isMyDay && !task.IsMyDay)
{
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
var max = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
var openMyDay = await ctx.Tasks.CountAsync(
t => t.IsMyDay && t.Status == TaskStatus.Idle, cancellationToken);
if (openMyDay >= max)
throw new InvalidOperationException(
$"MyDay limit {max} reached. Clear a task before adding another.");
}
task.IsMyDay = isMyDay;
if (sortOrder is not null) task.SortOrder = sortOrder.Value;
await ctx.SaveChangesAsync(cancellationToken);
await _broadcaster.TaskUpdated(taskId);
return ToDto(task);
}
```
- [ ] **Step 4: Run — expect PASS.**
- [ ] **Step 5: Commit.**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests
git commit -m "feat(daily-prep): add set_my_day MCP tool with cap-guard"
```
---
## Task 4: Rewrite `PrimeRunner` to run the daily prep
**Files:**
- Create: `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs`
- Modify: `src/ClaudeDo.Worker/Prime/PrimeRunner.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs`, and extend `PrimeRunnerTests.cs` if it exists
The runner needs the cap `X` (read from `AppSettings`) and today's date. Inject `IDbContextFactory<ClaudeDoDbContext>` into `PrimeRunner` (it is resolvable in the main app DI) and an `IPrimeClock` for the date (already registered).
- [ ] **Step 1: Write failing prompt/args tests.**
```csharp
public class DailyPrepPromptTests
{
[Fact]
public void Build_prompt_contains_cap_and_date()
{
var prompt = DailyPrepPrompt.BuildPrompt(maxTasks: 5, today: new DateOnly(2026, 6, 3));
Assert.Contains("5", prompt);
Assert.Contains("2026-06-03", prompt);
Assert.Contains("get_daily_prep_candidates", prompt);
Assert.Contains("set_my_day", prompt);
}
[Fact]
public void Build_args_allows_only_the_two_tools()
{
var args = DailyPrepPrompt.BuildArgs(maxTurns: 30);
Assert.Contains("--output-format stream-json", args);
Assert.Contains("--max-turns 30", args);
Assert.Contains("--allowedTools", args);
Assert.Contains("mcp__claudedo__get_daily_prep_candidates", args);
Assert.Contains("mcp__claudedo__set_my_day", args);
}
}
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Create `DailyPrepPrompt.cs`:**
```csharp
namespace ClaudeDo.Worker.Prime;
public static class DailyPrepPrompt
{
public const string CandidatesTool = "mcp__claudedo__get_daily_prep_candidates";
public const string SetMyDayTool = "mcp__claudedo__set_my_day";
public static string BuildArgs(int maxTurns) =>
"-p --output-format stream-json --verbose --permission-mode acceptEdits " +
$"--max-turns {maxTurns} " +
$"--allowedTools {CandidatesTool} {SetMyDayTool}";
public static string BuildPrompt(int maxTasks, DateOnly today) =>
$"""
Du bereitest meinen Arbeitstag fuer {today:yyyy-MM-dd} vor.
1. Rufe {CandidatesTool} auf.
2. Behalte bereits als MyDay markierte offene Tasks (currentMyDay) — entferne sie nicht.
3. Fuelle bis maximal {maxTasks} offene Tasks GESAMT in MyDay auf (currentMyDay zaehlt mit). Niemals mehr.
4. Schaetze pro Kandidat grob den Aufwand und waehle eine machbare Mischung (nicht nur Grossbrocken).
Priorisiere isStarred, faellige (scheduledFor) und aeltere Tasks.
5. Lege thematisch verwandte Tasks durch aufeinanderfolgende sortOrder-Werte nebeneinander.
6. Setze die Auswahl via {SetMyDayTool}(taskId, true, sortOrder). Markiere nichts ausserhalb der Kandidatenliste.
Wenn es keine Kandidaten gibt, tue nichts.
""";
}
```
- [ ] **Step 4: Run prompt tests — expect PASS.**
- [ ] **Step 5: Rewrite `PrimeRunner.cs`:**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Prime;
public sealed class PrimeRunner : IPrimeRunner
{
private static readonly TimeSpan FireTimeout = TimeSpan.FromMinutes(5);
private const int MaxTurns = 30;
private readonly IClaudeProcess _claude;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IPrimeClock _clock;
private readonly ILogger<PrimeRunner> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
public PrimeRunner(
IClaudeProcess claude,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
IPrimeClock clock,
ILogger<PrimeRunner> logger)
{
_claude = claude;
_dbFactory = dbFactory;
_clock = clock;
_logger = logger;
}
public async Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
{
if (!await _gate.WaitAsync(0, ct))
return new PrimeRunOutcome(false, "Daily prep already running");
try
{
var cwd = Paths.AppDataRoot();
Directory.CreateDirectory(cwd);
int maxTasks;
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
{
var settings = await new AppSettingsRepository(dbCtx).GetAsync(ct);
maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
}
var today = DateOnly.FromDateTime(_clock.Now.LocalDateTime);
var prompt = DailyPrepPrompt.BuildPrompt(maxTasks, today);
var args = DailyPrepPrompt.BuildArgs(MaxTurns);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(FireTimeout);
var result = await _claude.RunAsync(
arguments: args,
prompt: prompt,
workingDirectory: cwd,
onStdoutLine: _ => Task.CompletedTask,
ct: timeoutCts.Token);
return result.IsSuccess
? new PrimeRunOutcome(true, "Daily prep complete")
: new PrimeRunOutcome(false, $"exit code {result.ExitCode}");
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Daily prep run failed");
return new PrimeRunOutcome(false, ex.Message);
}
finally
{
_gate.Release();
}
}
}
```
- [ ] **Step 6: Fix the DI registration is unchanged** (`AddSingleton<IPrimeRunner, PrimeRunner>()` already works — the new ctor deps `IDbContextFactory` and `IPrimeClock` are registered). Build the Worker.
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
```
- [ ] **Step 7: Update/extend `PrimeRunnerTests.cs`** (if present) to match the new ctor: construct `PrimeRunner` with a fake `IClaudeProcess`, a real temp-SQLite `IDbContextFactory`, a fake `IPrimeClock`, and a logger. Add:
```csharp
[Fact]
public async Task FireAsync_returns_already_running_when_gate_held()
{
var runner = NewRunner(claudeDelay: TimeSpan.FromSeconds(2));
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
var first = runner.FireAsync(schedule, CancellationToken.None);
var second = await runner.FireAsync(schedule, CancellationToken.None);
Assert.False(second.Success);
Assert.Contains("already running", second.Message, StringComparison.OrdinalIgnoreCase);
await first;
}
```
If no `PrimeRunnerTests.cs` exists, create one. The fake `IClaudeProcess` should optionally delay (to keep the gate held) and return a successful `RunResult { ExitCode = 0, ResultMarkdown = "ok" }`.
- [ ] **Step 8: Run — expect PASS.**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "DailyPrepPrompt|PrimeRunner"
```
- [ ] **Step 9: Commit.**
```bash
git add src/ClaudeDo.Worker/Prime tests/ClaudeDo.Worker.Tests/Prime
git commit -m "feat(daily-prep): run daily prep from PrimeRunner via allowed MCP tools"
```
---
## Task 5: Hub — `RunDailyPrepNow` + expose `DailyPrepMaxTasks`
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
Read `WorkerHub.cs` first. It already exposes a `GetAppSettings`/`UpdateAppSettings` pair backed by a DTO record (the one carrying `ReportExcludedPaths`, `StandupWeekday`).
- [ ] **Step 1: Add `DailyPrepMaxTasks` to the hub AppSettings DTO record** (the record near the top of `WorkerHub.cs` that lists `ReportExcludedPaths`). Add `int DailyPrepMaxTasks` as a member. In the read mapping (`GetAppSettings`, where `row.ReportExcludedPaths` is read) add `row.DailyPrepMaxTasks`; in the write mapping (`UpdateAppSettings`, where `ReportExcludedPaths = dto.ReportExcludedPaths`) add `DailyPrepMaxTasks = dto.DailyPrepMaxTasks`.
- [ ] **Step 2: Add the hub method.** Inject `IPrimeRunner` and `HubBroadcaster` if the hub does not already have them (the hub is constructed by SignalR via DI; both are registered singletons). Then:
```csharp
public async Task<bool> RunDailyPrepNow()
{
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
var firedAt = DateTimeOffset.Now;
var outcome = await _primeRunner.FireAsync(schedule, Context.ConnectionAborted);
await _broadcaster.PrimeFired(Guid.Empty, outcome.Success, outcome.Message, firedAt);
return outcome.Success;
}
```
Add `using ClaudeDo.Worker.Prime;` to `WorkerHub.cs` if missing.
> **Caution (memory):** changing the `WorkerHub` constructor breaks hand-rolled hub-test fakes in `ClaudeDo.Worker.Tests` and possibly `ClaudeDo.Ui.Tests`. After editing, build the test projects and fix every `new WorkerHub(...)` / fake `IWorkerClient` construction the compiler flags.
- [ ] **Step 3: Mirror the DTO in the UI** (`WorkerClient.cs`, the AppSettings DTO around line 498): add `int DailyPrepMaxTasks` to the record (same position as in the hub DTO). Add a `RunDailyPrepNow` client call:
```csharp
public Task<bool> RunDailyPrepNowAsync() =>
_connection.InvokeAsync<bool>("RunDailyPrepNow");
```
(Match the exact connection field/name and the async-wrapper style used by neighbouring calls like `GenerateWeekReport`.)
- [ ] **Step 4: Build Worker + App + test projects; fix any broken fakes.**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
- [ ] **Step 5: Commit.**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/WorkerClient.cs tests
git commit -m "feat(daily-prep): add RunDailyPrepNow hub method and expose DailyPrepMaxTasks"
```
---
## Task 6: Settings UI — edit `DailyPrepMaxTasks`
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs`
- Modify: the Prime Claude tab markup in `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` (load/save wiring, where other AppSettings fields are mapped)
Read these three files first; mirror how an existing numeric AppSetting (e.g. `MaxParallelExecutions` or `WorktreeAutoCleanupDays`) is loaded from the hub DTO, bound, and saved back.
- [ ] **Step 1: Add an observable property** to `PrimeClaudeTabViewModel.cs`:
```csharp
[ObservableProperty] private int _dailyPrepMaxTasks = 5;
```
- [ ] **Step 2: Wire load/save** in `SettingsModalViewModel.cs`: where the AppSettings DTO is read into the tabs, set `PrimeClaude.DailyPrepMaxTasks = dto.DailyPrepMaxTasks;`. Where the DTO is written, include `DailyPrepMaxTasks = PrimeClaude.DailyPrepMaxTasks`. (Use the exact tab property name for the Prime Claude tab in that VM.)
- [ ] **Step 3: Add the editor** in the Prime Claude tab of `SettingsModalView.axaml`, near the schedule list:
```xml
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Text="{x:Static loc:L.Settings_DailyPrepMaxTasks}" VerticalAlignment="Center"/>
<NumericUpDown Minimum="1" Maximum="50" Increment="1" Width="100"
Value="{Binding PrimeClaude.DailyPrepMaxTasks}"/>
</StackPanel>
```
Add the `Settings_DailyPrepMaxTasks` key to both `locales/en.json` and `locales/de.json` (en: "Max tasks per day", de: "Max. Aufgaben pro Tag"). If the tab does not use localized labels yet, use a plain `Text="Max tasks per day"` string to match its current style.
- [ ] **Step 4: Build the App; smoke-build the UI.**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
```
- [ ] **Step 5: Commit.**
```bash
git add src/ClaudeDo.Ui src/ClaudeDo.Localization
git commit -m "feat(daily-prep): add DailyPrepMaxTasks editor to Prime Claude settings"
```
---
## Task 7: MyDay header — "Tag vorbereiten" button
**Files:**
- Modify: the ViewModel backing the MyDay list view (the one that exposes the smart-list header/toolbar; find it under `src/ClaudeDo.Ui/ViewModels/Islands/` — likely the tasks/list island VM that has access to `IWorkerClient`)
- Modify: the corresponding view (`.axaml`) that renders the list header
Read the island VM + view first. Find where the active list is known to be `smart:my-day` so the button can be shown only there (mirror any existing conditional header content). The VM already holds a worker-client reference used by other commands (e.g. RunNow) — reuse it.
- [ ] **Step 1: Add the command** to the island VM:
```csharp
[RelayCommand]
private async Task PrepareDayAsync()
{
await _workerClient.RunDailyPrepNowAsync();
}
```
(Use the VM's existing worker-client field name. The MyDay list refreshes automatically via the `TaskUpdated` broadcast the tools emit, so no manual reload is needed.)
- [ ] **Step 2: Add an `IsMyDayList` (or reuse existing selected-list) guard** so the button only appears on the MyDay smart list. If the VM already exposes the selected list id, add:
```csharp
public bool IsMyDayList => SelectedListId == "smart:my-day";
```
and raise its change notification wherever `SelectedListId` changes (mirror existing patterns; if a `[NotifyPropertyChangedFor]` or manual `OnPropertyChanged` is already used for the selection, add this property to it).
- [ ] **Step 3: Add the button** to the list header in the view, visible only on MyDay:
```xml
<Button Content="{x:Static loc:L.MyDay_PrepareDay}"
Command="{Binding PrepareDayCommand}"
IsVisible="{Binding IsMyDayList}"/>
```
Add `MyDay_PrepareDay` to `locales/en.json` ("Prepare day") and `locales/de.json` ("Tag vorbereiten"), or a plain string if the view is not localized.
- [ ] **Step 4: Build the App.**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
```
- [ ] **Step 5: Manual smoke (cannot be unit-tested):** start the Worker and App, open MyDay, click "Tag vorbereiten", confirm tasks appear (capped) and the button is hidden on other lists. Report results explicitly — do not claim UI success without running it.
- [ ] **Step 6: Commit.**
```bash
git add src/ClaudeDo.Ui src/ClaudeDo.Localization
git commit -m "feat(daily-prep): add Prepare-day button to MyDay header"
```
---
## Final verification
- [ ] `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
- [ ] `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
- [ ] `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
- [ ] `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release`
- [ ] `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
- [ ] End-to-end manual run: schedule fires (or button) → Claude calls the two tools → MyDay gets a capped subset; re-run keeps existing MyDay and tops up without exceeding the cap.
## Notes / risks
- Relies on the globally registered `claudedo` MCP (installer `RegisterMcpStep`). If absent, the prep run produces 0 changes — acceptable for v1.
- `--permission-mode acceptEdits` + explicit `--allowedTools` pre-approves exactly the two tools so the headless run never blocks on a permission prompt.
- The cap-guard counts `Idle && IsMyDay` tasks; it is the source of truth for the "never move everything in" invariant regardless of Claude's behavior.
- Future phase (out of scope): external ticket sources (Jira) feed into `get_daily_prep_candidates` behind a task-source abstraction.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,972 @@
# Bundled Prompts Overhaul Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Externalize every bundled prose prompt into editable files with strong defaults, collapse system+agent, and add an inline `CLAUDEDO_BLOCKED:` roadblock protocol surfaced at review.
**Architecture:** `PromptFiles` becomes the single source of prompt defaults + a pure token renderer. Each consumer (TaskRunner, PlanningSessionManager, DailyPrepPrompt, WeekReportPromptBuilder) reads its prompt via `PromptFiles`. `StreamAnalyzer` collects roadblock markers from streamed assistant text; the runner folds them into the review result.
**Tech Stack:** .NET 8, xUnit, EF Core (no schema change in this plan).
Spec: `docs/superpowers/specs/2026-06-04-bundled-prompts-overhaul-design.md`
---
## File structure
- `src/ClaudeDo.Data/PromptFiles.cs` — new `PromptKind` members, new defaults, `RenderTemplate` + `ReadOrDefault` + `Render`.
- `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs` — collect `Blocks` from assistant text.
- `src/ClaudeDo.Worker/Runner/RunResult.cs` — carry `Blocks`.
- `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs` — pass `Blocks`; expose no-result prefix const.
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — drop agent file; retry via `retry.md`; fold blocks into review result.
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — read planning prompts via `PromptFiles`.
- `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs` — read `daily-prep.md`.
- `src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs` — read `weekly-report.md`.
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs` + its view — expose new prompt files, drop agent.
- Tests in `tests/ClaudeDo.Data.Tests` and `tests/ClaudeDo.Worker.Tests`.
Build commands (this repo is on .NET 8 — build per project, not the .slnx):
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
---
## Task 1: PromptFiles — kinds, defaults, pure renderer
**Files:**
- Modify: `src/ClaudeDo.Data/PromptFiles.cs`
- Test: `tests/ClaudeDo.Data.Tests/PromptFilesTests.cs` (create)
- [ ] **Step 1: Write failing tests for the pure renderer**
Create `tests/ClaudeDo.Data.Tests/PromptFilesTests.cs`:
```csharp
using ClaudeDo.Data;
namespace ClaudeDo.Data.Tests;
public class PromptFilesTests
{
[Fact]
public void RenderTemplate_replaces_known_tokens()
{
var outp = PromptFiles.RenderTemplate(
"Plan for {date}, cap {maxTasks}.",
new Dictionary<string, string> { ["date"] = "2026-06-04", ["maxTasks"] = "5" });
Assert.Equal("Plan for 2026-06-04, cap 5.", outp);
}
[Fact]
public void RenderTemplate_leaves_unknown_braces_intact()
{
var outp = PromptFiles.RenderTemplate(
"## {Wochentag}, {dd.MM.yyyy} — {start}",
new Dictionary<string, string> { ["start"] = "01.06.2026" });
Assert.Equal("## {Wochentag}, {dd.MM.yyyy} — 01.06.2026", outp);
}
[Fact]
public void DefaultFor_system_mentions_blocked_marker_and_scope()
{
var d = PromptFiles.DefaultFor(PromptKind.System);
Assert.Contains("CLAUDEDO_BLOCKED:", d);
Assert.Contains("unattended", d, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void DefaultFor_planning_initial_has_title_and_description_tokens()
{
var d = PromptFiles.DefaultFor(PromptKind.PlanningInitial);
Assert.Contains("{title}", d);
Assert.Contains("{description}", d);
}
[Fact]
public void PathFor_planning_is_planning_system_file()
{
Assert.EndsWith("planning-system.md", PromptFiles.PathFor(PromptKind.Planning));
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release`
Expected: FAIL — `RenderTemplate`/`DefaultFor` don't exist, `PromptKind.PlanningInitial` undefined.
- [ ] **Step 3: Rewrite PromptFiles.cs**
Replace the entire contents of `src/ClaudeDo.Data/PromptFiles.cs` with:
```csharp
using System.Text;
namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport }
public static class PromptFiles
{
public static string Root => Path.Combine(Paths.AppDataRoot(), "prompts");
public static string PathFor(PromptKind kind) => kind switch
{
PromptKind.System => Path.Combine(Root, "system.md"),
PromptKind.Planning => Path.Combine(Root, "planning-system.md"),
PromptKind.PlanningInitial => Path.Combine(Root, "planning-initial.md"),
PromptKind.Retry => Path.Combine(Root, "retry.md"),
PromptKind.DailyPrep => Path.Combine(Root, "daily-prep.md"),
PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"),
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
public static void EnsureExists(PromptKind kind)
{
Directory.CreateDirectory(Root);
var path = PathFor(kind);
if (File.Exists(path)) return;
File.WriteAllText(path, DefaultFor(kind));
}
public static string? ReadOrNull(PromptKind kind)
{
var path = PathFor(kind);
if (!File.Exists(path)) return null;
var content = File.ReadAllText(path).Trim();
return string.IsNullOrEmpty(content) ? null : content;
}
/// <summary>File content if present and non-empty, otherwise the bundled default.</summary>
public static string ReadOrDefault(PromptKind kind) => ReadOrNull(kind) ?? DefaultFor(kind);
/// <summary>Render a prompt: read file-or-default, then substitute named tokens.</summary>
public static string Render(PromptKind kind, IReadOnlyDictionary<string, string> values)
=> RenderTemplate(ReadOrDefault(kind), values);
/// <summary>Replace only the given {name} tokens; any other braces pass through untouched.</summary>
public static string RenderTemplate(string template, IReadOnlyDictionary<string, string> values)
{
var sb = new StringBuilder(template);
foreach (var (key, val) in values)
sb.Replace("{" + key + "}", val);
return sb.ToString();
}
public static string DefaultFor(PromptKind kind) => kind switch
{
PromptKind.System => SystemDefault,
PromptKind.Planning => PlanningSystemDefault,
PromptKind.PlanningInitial => PlanningInitialDefault,
PromptKind.Retry => RetryDefault,
PromptKind.DailyPrep => DailyPrepDefault,
PromptKind.WeeklyReport => WeeklyReportDefault,
_ => ""
};
private const string SystemDefault = """
# Working Agreement
You are completing one well-defined task autonomously in a git repository.
## Scope
- Do exactly what the task asks no unrequested refactors, renames, dependency
changes, or "while I'm here" cleanup.
- If intent is ambiguous, state the assumption you're making and proceed with the
most reasonable reading. Stop only if you genuinely cannot move forward.
- Prefer three similar lines over a premature abstraction. Don't build for
hypothetical future needs.
## Working in the repo
- Read a file before editing it. Match the conventions already in this codebase
they override generic defaults.
- Prefer editing existing files to creating new ones. Don't write comments that
just restate the code.
- Validate only at real boundaries (user input, external APIs).
## Finishing
- Before claiming done, verify: run the build and relevant tests, confirm they
pass, and report what you ran. If you couldn't verify something, say so plainly.
- Make focused commits using the repository's existing commit-message convention.
## Safety
- Never force-push, hard-reset, or delete branches/files beyond the task's scope
without being asked.
- Don't introduce injection/XSS/secret-leak issues. Never commit credentials.
## You are running unattended
You run autonomously with no human watching. There is no one to answer mid-task
questions, so never stop to ask make the most reasonable decision, note the
assumption, and continue.
## When you are blocked
If something genuinely prevents you from completing part of the task (missing
credentials, contradictory requirements, a destructive action you won't take
unasked), do NOT silently give up. Write this marker on its own line, then keep
working on whatever else you can:
CLAUDEDO_BLOCKED: <one short sentence describing what blocked you>
Emit it as many times as needed once per distinct blocker. Use it only for true
blockers, not for routine decisions you can make yourself.
""";
private const string PlanningSystemDefault = """
You are the planning assistant for ClaudeDo. Your job is to break a task into
smaller, independently executable subtasks the session ends by creating those
subtasks.
Start every session by invoking the `superpowers:brainstorming` skill (Skill
tool) and follow it end to end: clarifying questions one at a time, then 23
approaches with a recommendation, then a short design. Do not create any subtasks
until the user has approved the design.
You can ONLY shape this task's plan you cannot edit files or touch other tasks.
The tools available to you are: CreateChildTask, ListChildTasks, UpdateChildTask,
DeleteChildTask, UpdatePlanningTask, and Finalize. Use nothing else.
Once the design is approved, create the child tasks with CreateChildTask, then
call Finalize. Keep each subtask concrete and self-contained with a clear
done-state, ordered so dependencies come first.
""";
private const string PlanningInitialDefault = """
# Task to plan: {title}
{description}
""";
private const string RetryDefault = """
The task did not complete on the previous attempt you may have run out of
turns, hit an error, or stopped before finishing.
Review the work already done in this session and the current state of the
repository, identify what is still incomplete or broken, and finish the task.
Don't restart from scratch or repeat a failed approach. Verify the result
(build + tests) before you stop.
""";
private const string DailyPrepDefault = """
You are preparing my workday for {date}.
1. Call mcp__claudedo__get_daily_prep_candidates.
2. Keep tasks already marked MyDay (currentMyDay) never remove them.
3. Fill MyDay to at most {maxTasks} open tasks TOTAL (currentMyDay counts). Never exceed it.
4. Estimate each candidate's effort and pick a feasible mix not only big items.
Prioritize isStarred, due (scheduledFor), and older tasks.
5. Place related tasks next to each other using consecutive sortOrder values.
6. Apply via mcp__claudedo__set_my_day(taskId, true, sortOrder). Never mark anything
outside the candidate list.
If there are no candidates, do nothing.
""";
private const string WeeklyReportDefault = """
You are generating a concise weekly standup report for a software developer,
covering {start} to {end}.
Rules:
- Write the ENTIRE report in German.
- Group by day. One "## {Wochentag}, {dd.MM.yyyy}" section per day that has
activity (German weekday names). Omit days with no activity.
- Within each day: 35 first-person, past-tense bullets ("- Habe X umgesetzt",
"- Y behoben"). Merge related small work into one bullet.
- Drop trivia: typo fixes, pure exploration, false starts, tooling/log noise.
- Blend the developer's own notes and the derived activity into ONE deduplicated
bullet list per day. The notes are authoritative never omit or contradict them.
- Name the project/repo when it adds clarity.
- Output ONLY the dated sections. No preamble, no intro, no closing remarks.
Two sections follow below: an activity log derived from Claude session history,
and the developer's own notes. Base the report on both; the notes are
authoritative where they conflict with the derived activity.
""";
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release`
Expected: PASS (5 new tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/PromptFiles.cs tests/ClaudeDo.Data.Tests/PromptFilesTests.cs
git commit -m "feat(prompts): externalize prompt kinds with defaults and token renderer"
```
---
## Task 2: TaskRunner — drop agent file from system prompt merge
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:382-386`
- [ ] **Step 1: Remove the agent-file read and merge**
In `ResolveConfigAsync`, replace:
```csharp
var systemFile = PromptFiles.ReadOrNull(PromptKind.System);
var agentFile = PromptFiles.ReadOrNull(PromptKind.Agent);
var instructions = MergeInstructions(
systemFile, global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt, agentFile);
```
with:
```csharp
var systemFile = PromptFiles.ReadOrNull(PromptKind.System);
var instructions = MergeInstructions(
systemFile, global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt);
```
- [ ] **Step 2: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: PASS (no reference to `PromptKind.Agent` remains).
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
git commit -m "refactor(prompts): collapse agent prompt into system prompt"
```
---
## Task 3: Retry prompt from file + conditional stderr append
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs:101-103` (expose prefix const)
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` (add `BuildRetryPrompt`, use it at ~L107)
- Test: `tests/ClaudeDo.Worker.Tests/Runner/RetryPromptTests.cs` (create)
- [ ] **Step 1: Write failing tests for the retry-prompt helper**
Create `tests/ClaudeDo.Worker.Tests/Runner/RetryPromptTests.cs`:
```csharp
using ClaudeDo.Worker.Runner;
namespace ClaudeDo.Worker.Tests.Runner;
public class RetryPromptTests
{
[Fact]
public void Generic_no_result_error_is_not_appended()
{
var prompt = TaskRunner.BuildRetryPrompt($"{ClaudeProcess.NoResultPrefix} 1 and no result.");
Assert.DoesNotContain("Captured error", prompt);
Assert.Contains("did not complete", prompt);
}
[Fact]
public void Real_error_is_appended()
{
var prompt = TaskRunner.BuildRetryPrompt("error CS1002: ; expected");
Assert.Contains("Captured error", prompt);
Assert.Contains("CS1002", prompt);
}
[Fact]
public void Null_error_yields_bare_prompt()
{
var prompt = TaskRunner.BuildRetryPrompt(null);
Assert.DoesNotContain("Captured error", prompt);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RetryPromptTests`
Expected: FAIL — `BuildRetryPrompt` / `NoResultPrefix` don't exist.
- [ ] **Step 3: Expose the no-result prefix in ClaudeProcess**
In `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs`, add the const near the top of the class and use it in the error fallback. Replace:
```csharp
var error = lastStderr.Length > 0
? lastStderr.ToString().Trim()
: $"Claude exited with code {exitCode} and no result.";
```
with:
```csharp
var error = lastStderr.Length > 0
? lastStderr.ToString().Trim()
: $"{NoResultPrefix} {exitCode} and no result.";
```
and add inside the class (e.g. just below the fields):
```csharp
public const string NoResultPrefix = "Claude exited with code";
```
- [ ] **Step 4: Add BuildRetryPrompt to TaskRunner and use it**
In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, add this static method (next to `MergeInstructions`):
```csharp
public static string BuildRetryPrompt(string? capturedError)
{
var basePrompt = PromptFiles.ReadOrDefault(PromptKind.Retry);
var isReal = !string.IsNullOrWhiteSpace(capturedError)
&& !capturedError!.StartsWith(ClaudeProcess.NoResultPrefix, StringComparison.Ordinal);
return isReal
? $"{basePrompt}\n\nCaptured error from the failed run:\n\n{capturedError!.Trim()}"
: basePrompt;
}
```
Then replace the inline retry prompt at ~L107:
```csharp
var retryPrompt = $"The previous attempt failed with:\n\n{result.ErrorMarkdown}\n\nTry again and fix the issues.";
```
with:
```csharp
var retryPrompt = BuildRetryPrompt(result.ErrorMarkdown);
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RetryPromptTests`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/ClaudeProcess.cs src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/RetryPromptTests.cs
git commit -m "feat(prompts): retry prompt from file, append only real captured errors"
```
---
## Task 4: PlanningSessionManager reads planning prompts from files
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (`BuildSystemPrompt` ~L366, `BuildInitialPrompt` ~L392)
- [ ] **Step 1: Replace BuildSystemPrompt body**
Replace the whole method body of `BuildSystemPrompt()` with:
```csharp
private static string BuildSystemPrompt() => PromptFiles.ReadOrDefault(PromptKind.Planning);
```
(Delete the inline fallback string literal that followed.)
- [ ] **Step 2: Replace BuildInitialPrompt body**
Replace the whole method body of `BuildInitialPrompt(TaskEntity task)` with:
```csharp
private static string BuildInitialPrompt(TaskEntity task) =>
PromptFiles.Render(PromptKind.PlanningInitial, new Dictionary<string, string>
{
["title"] = task.Title,
["description"] = task.Description ?? "",
});
```
Ensure `using ClaudeDo.Data;` is present (it is — `PromptFiles` lived there already via `ReadOrNull`).
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "refactor(prompts): planning prompts read from editable files"
```
---
## Task 5: DailyPrepPrompt reads from file
**Files:**
- Modify: `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs`
- [ ] **Step 1: Update DailyPrepPromptTests to assert the English default render**
Replace the `Build_prompt_contains_cap_and_date` test body with:
```csharp
[Fact]
public void Build_prompt_contains_cap_and_date()
{
var prompt = DailyPrepPrompt.BuildPrompt(maxTasks: 5, today: new DateOnly(2026, 6, 3));
Assert.Contains("5", prompt);
Assert.Contains("2026-06-03", prompt);
Assert.Contains("get_daily_prep_candidates", prompt);
Assert.Contains("set_my_day", prompt);
Assert.Contains("preparing my workday", prompt);
}
```
(The new assertion pins the English default; the file-read path is exercised by the same default when no `daily-prep.md` exists.)
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter DailyPrepPromptTests`
Expected: FAIL — current German prompt has no "preparing my workday".
- [ ] **Step 3: Rewrite BuildPrompt to read the file**
In `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs`, replace the `BuildPrompt` method with:
```csharp
public static string BuildPrompt(int maxTasks, DateOnly today) =>
ClaudeDo.Data.PromptFiles.Render(
ClaudeDo.Data.PromptKind.DailyPrep,
new Dictionary<string, string>
{
["date"] = today.ToString("yyyy-MM-dd"),
["maxTasks"] = maxTasks.ToString(),
});
```
Leave `BuildArgs`, `LogPath`, and the tool-name consts unchanged.
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter DailyPrepPromptTests`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs
git commit -m "feat(prompts): daily-prep prompt from file, English default"
```
---
## Task 6: WeekReportPromptBuilder reads instructions from file
**Files:**
- Modify: `src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs`
- Check: `tests/ClaudeDo.Worker.Tests/Report/WeekReportPromptBuilderTests.cs`
- [ ] **Step 1: Replace the inline Instructions with a file read**
In `WeekReportPromptBuilder.Build`, replace:
```csharp
var sb = new StringBuilder();
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, Instructions,
start.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
end.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)));
sb.AppendLine();
```
with:
```csharp
var sb = new StringBuilder();
sb.AppendLine(ClaudeDo.Data.PromptFiles.Render(
ClaudeDo.Data.PromptKind.WeeklyReport,
new Dictionary<string, string>
{
["start"] = start.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
["end"] = end.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
}));
sb.AppendLine();
```
Then delete the now-unused `private const string Instructions = ...` block. (The `{Wochentag}`/`{dd.MM.yyyy}` literals inside the default survive because `RenderTemplate` only replaces `{start}`/`{end}`.)
- [ ] **Step 2: Verify the existing builder test still passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter WeekReportPromptBuilderTests`
Expected: PASS. If a test asserted exact old wording, update it to assert the date appears and that activity/notes sections render (the new default keeps German output rules).
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs tests/ClaudeDo.Worker.Tests/Report/WeekReportPromptBuilderTests.cs
git commit -m "feat(prompts): weekly-report instructions from file, point at data sections"
```
---
## Task 7: StreamAnalyzer collects roadblock markers
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs`
- [ ] **Step 1: Write failing tests**
Append to `StreamAnalyzerTests`:
```csharp
[Fact]
public void Collects_Blocked_Markers_From_Assistant_Text()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"assistant","message":{"content":[{"type":"text","text":"working\nCLAUDEDO_BLOCKED: missing API key\nmoving on"}]}}""");
analyzer.ProcessLine("""{"type":"assistant","message":{"content":[{"type":"text","text":"CLAUDEDO_BLOCKED: cannot reach db"}]}}""");
analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
var result = analyzer.GetResult();
Assert.Equal(2, result.Blocks.Count);
Assert.Equal("missing API key", result.Blocks[0]);
Assert.Equal("cannot reach db", result.Blocks[1]);
}
[Fact]
public void Strips_Blocked_Markers_From_Result_Text()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"result","result":"All set.\nCLAUDEDO_BLOCKED: no creds\nDone.","session_id":"s1"}""");
var result = analyzer.GetResult();
Assert.DoesNotContain("CLAUDEDO_BLOCKED", result.ResultMarkdown);
Assert.Single(result.Blocks);
Assert.Equal("no creds", result.Blocks[0]);
}
[Fact]
public void No_Markers_Means_Empty_Blocks()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
Assert.Empty(analyzer.GetResult().Blocks);
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter StreamAnalyzerTests`
Expected: FAIL — `Blocks` doesn't exist.
- [ ] **Step 3: Implement marker collection in StreamAnalyzer**
In `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs`:
Add to `StreamResult`:
```csharp
public IReadOnlyList<string> Blocks { get; set; } = Array.Empty<string>();
```
Add a field and a constant to `StreamAnalyzer`:
```csharp
private readonly List<string> _blocks = new();
private const string BlockedPrefix = "CLAUDEDO_BLOCKED:";
```
In the `case "result":` branch, after `_resultMarkdown` is assigned, scan and strip:
```csharp
if (root.TryGetProperty("result", out var resultProp))
_resultMarkdown = StripAndCollect(resultProp.GetString());
```
In the `case "assistant":` branch, collect from text content (keep `_turnCount++`):
```csharp
case "assistant":
_turnCount++;
CollectFromAssistant(root);
break;
```
Add these helpers to the class:
```csharp
private void CollectFromAssistant(JsonElement root)
{
if (!root.TryGetProperty("message", out var msg)) return;
if (!msg.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array) return;
foreach (var block in content.EnumerateArray())
if (block.TryGetProperty("type", out var t) && t.GetString() == "text"
&& block.TryGetProperty("text", out var txt))
ScanForBlocks(txt.GetString());
}
private void ScanForBlocks(string? text)
{
if (string.IsNullOrEmpty(text)) return;
foreach (var line in text.Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith(BlockedPrefix, StringComparison.Ordinal))
_blocks.Add(trimmed[BlockedPrefix.Length..].Trim());
}
}
private string? StripAndCollect(string? text)
{
if (string.IsNullOrEmpty(text)) return text;
ScanForBlocks(text);
var kept = text.Split('\n')
.Where(l => !l.Trim().StartsWith(BlockedPrefix, StringComparison.Ordinal));
return string.Join('\n', kept).Trim();
}
```
Add `Blocks = _blocks` to the `GetResult()` initializer:
```csharp
public StreamResult GetResult() => new()
{
ResultMarkdown = FallbackResult(),
StructuredOutputJson = _structuredOutputJson,
SessionId = _sessionId,
TurnCount = _turnCount,
TokensIn = _tokensIn,
TokensOut = _tokensOut,
ApiRetryCount = _apiRetryCount,
Blocks = _blocks,
};
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter StreamAnalyzerTests`
Expected: PASS (all old + 3 new).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs
git commit -m "feat(roadblock): collect and strip CLAUDEDO_BLOCKED markers in StreamAnalyzer"
```
---
## Task 8: RunResult + ClaudeProcess carry Blocks
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/RunResult.cs`
- Modify: `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs:89-113`
- [ ] **Step 1: Add Blocks to RunResult**
In `src/ClaudeDo.Worker/Runner/RunResult.cs`, add inside the class:
```csharp
public IReadOnlyList<string> Blocks { get; init; } = Array.Empty<string>();
```
- [ ] **Step 2: Populate Blocks in both RunResult returns**
In `ClaudeProcess.RunAsync`, add `Blocks = streamResult.Blocks,` to **both** the success `RunResult { ... }` (after `TokensOut`) and the error `RunResult { ... }` initializer.
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/RunResult.cs src/ClaudeDo.Worker/Runner/ClaudeProcess.cs
git commit -m "feat(roadblock): carry blocks through RunResult"
```
---
## Task 9: Fold roadblocks into the review result
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` (`HandleSuccess` ~L319-352; add `ComposeReviewResult`)
- Test: `tests/ClaudeDo.Worker.Tests/Runner/ReviewResultTests.cs` (create)
- [ ] **Step 1: Write failing tests for the compose helper**
Create `tests/ClaudeDo.Worker.Tests/Runner/ReviewResultTests.cs`:
```csharp
using ClaudeDo.Worker.Runner;
namespace ClaudeDo.Worker.Tests.Runner;
public class ReviewResultTests
{
[Fact]
public void No_blocks_returns_result_unchanged()
{
Assert.Equal("done", TaskRunner.ComposeReviewResult("done", Array.Empty<string>()));
}
[Fact]
public void Blocks_are_appended_as_a_section()
{
var outp = TaskRunner.ComposeReviewResult("done", new[] { "no creds", "db down" });
Assert.Contains("⚠ Roadblocks", outp);
Assert.Contains("- no creds", outp);
Assert.Contains("- db down", outp);
Assert.Contains("done", outp);
}
[Fact]
public void Null_result_with_blocks_still_lists_them()
{
var outp = TaskRunner.ComposeReviewResult(null, new[] { "x" });
Assert.Contains("⚠ Roadblocks", outp);
Assert.Contains("- x", outp);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter ReviewResultTests`
Expected: FAIL — `ComposeReviewResult` doesn't exist.
- [ ] **Step 3: Add ComposeReviewResult and use it in HandleSuccess**
In `TaskRunner`, add:
```csharp
public static string? ComposeReviewResult(string? result, IReadOnlyList<string> blocks)
{
if (blocks.Count == 0) return result;
var section = "⚠ Roadblocks reported during the run:\n"
+ string.Join('\n', blocks.Select(b => $"- {b}"));
return string.IsNullOrWhiteSpace(result) ? section : $"{result}\n\n{section}";
}
```
In `HandleSuccess`, compute the composed result once and pass it to both terminal writes:
```csharp
var finishedAt = DateTime.UtcNow;
var reviewResult = ComposeReviewResult(result.ResultMarkdown, result.Blocks);
if (task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None)
{
await _state.SubmitForReviewAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt);
}
else
{
await _state.CompleteAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
}
```
(Make sure `using System.Linq;` is available — it is, via implicit usings.)
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter ReviewResultTests`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/ReviewResultTests.cs
git commit -m "feat(roadblock): surface reported roadblocks in the review result"
```
---
## Task 10: Files-settings UI exposes the new prompt files
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs`
- Modify: the Files settings view (find with: `Grep "SystemPromptPath" src/ClaudeDo.Ui` → the `.axaml` binding to `OpenPromptCommand`)
- [ ] **Step 1: Replace the prompt-path properties**
In `FilesSettingsTabViewModel`, replace the three path properties with the new set (drop Agent, add the rest):
```csharp
public string SystemPromptPath { get; } = PromptFiles.PathFor(PromptKind.System);
public string PlanningPromptPath { get; } = PromptFiles.PathFor(PromptKind.Planning);
public string PlanningInitialPromptPath { get; } = PromptFiles.PathFor(PromptKind.PlanningInitial);
public string RetryPromptPath { get; } = PromptFiles.PathFor(PromptKind.Retry);
public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep);
public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport);
```
(`OpenPromptCommand` already parses the `PromptKind` name from its parameter, so no command change is needed.)
- [ ] **Step 2: Update the view**
Open the Files settings `.axaml`. For the existing System/Planning/Agent rows: keep System, keep Planning, **remove the Agent row**, and add four rows mirroring the System row's markup — each binding its label/path to the new property and passing the matching `PromptKind` name as the `OpenPromptCommand` parameter:
- `Planning` (system) → "Planning system prompt", `PlanningPromptPath`, parameter `Planning`
- `PlanningInitial` → "Planning kickoff prompt", `PlanningInitialPromptPath`, parameter `PlanningInitial`
- `Retry` → "Retry prompt", `RetryPromptPath`, parameter `Retry`
- `DailyPrep` → "Daily-prep prompt", `DailyPrepPromptPath`, parameter `DailyPrep`
- `WeeklyReport` → "Weekly-report prompt", `WeeklyReportPromptPath`, parameter `WeeklyReport`
Use the exact same control template as the existing System row (same button + `CommandParameter` shape); only the bound property, label text, and parameter string differ.
- [ ] **Step 3: Build the UI project**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: PASS.
- [ ] **Step 4: Visual check (manual — flag for user)**
Start the app, open Settings → Files tab. Confirm six "Open" prompt buttons appear (System, Planning system, Planning kickoff, Retry, Daily-prep, Weekly-report), no Agent row, and each opens/seeds the right file under `~/.todo-app/prompts/`. **This step cannot be verified by the agent — ask the user to confirm visually.**
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs src/ClaudeDo.Ui/Views/**/*Files*.axaml
git commit -m "feat(ui): expose all editable prompt files, drop agent prompt"
```
---
## Task 11: Full build + test sweep
- [ ] **Step 1: Build worker + app**
Run:
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
```
Expected: PASS.
- [ ] **Step 2: Run all affected test projects**
Run:
```bash
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
Expected: PASS.
- [ ] **Step 3: Update docs**
Update `docs/prompts-inventory.md` to note the externalized files and that `agent.md`/`planning.md` are retired in favor of `system.md`/`planning-system.md`. Note `CLAUDEDO_BLOCKED:` in the inventory.
```bash
git add docs/prompts-inventory.md
git commit -m "docs: refresh prompt inventory for externalized prompts + roadblock marker"
```
---
## Self-review notes
- **Spec coverage:** system.md collapse (T2), planning prompts (T4), retry (T3), daily-prep English (T5), weekly-report + data pointer (T6), templating/`Render` (T1), roadblock detect/strip/route (T7T9), file layout + migration via `EnsureExists`/new `PathFor` (T1), UI surface (T10). The "Out-of-scope improvements" system.md section is intentionally **deferred to the child-tasks plan** (it depends on the `SuggestImprovement` tool).
- **Migration:** old `planning.md`/`agent.md` go inert automatically — `TaskRunner` no longer reads agent (T2), planning now reads `planning-system.md` (T1 PathFor). No code deletes the old files; harmless.
- **Determinism:** content tests target `DefaultFor`/`RenderTemplate` (pure, no disk). Consumers fall back to the same default when no user file exists.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,725 @@
# Debug Logging & Frontend↔Backend Traceability Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build-configuration-driven logging — verbose in Debug builds (Rider run button), minimal `Warning`+ in Release (installed app) — with both processes writing one shared `claudedo-.log` and a `TaskId` correlation key threading UI→Worker→UI.
**Architecture:** A new `ClaudeDo.Logging` library owns all Serilog setup: a `BuildConfig.IsDebug` runtime check (via the entry assembly's `DebuggableAttribute`, no `#if DEBUG`), a default-`TaskId` enricher, and a `LoggingSetup.Configure` method that branches sinks/levels on `IsDebug`. Worker and App both call it. `TaskId` rides Serilog `LogContext`, pushed at the per-task entry points on each side.
**Tech Stack:** .NET 8, Serilog (core + File + Console sinks), Serilog.Extensions.Logging (App bridge), Serilog.AspNetCore (Worker, already present), xUnit.
---
### Task 1: Create the `ClaudeDo.Logging` project
**Files:**
- Create: `src/ClaudeDo.Logging/ClaudeDo.Logging.csproj`
- Create: `src/ClaudeDo.Logging/Placeholder.cs` (temporary, removed in Task 2)
- Modify: `ClaudeDo.slnx`
- [ ] **Step 1: Create the csproj**
Create `src/ClaudeDo.Logging/ClaudeDo.Logging.csproj`:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.1.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
</ItemGroup>
</Project>
```
> If NuGet reports a version conflict between `Serilog 4.1.0` and the `Serilog` core pulled transitively by `Serilog.AspNetCore 8.0.3` (Worker), align this `Serilog` version to whatever `Serilog.AspNetCore 8.0.3` resolves (check `dotnet list package --include-transitive`) and rebuild.
- [ ] **Step 2: Add a temporary placeholder so the project compiles**
Create `src/ClaudeDo.Logging/Placeholder.cs`:
```csharp
namespace ClaudeDo.Logging;
internal static class Placeholder;
```
- [ ] **Step 3: Register the project in the solution**
Edit `ClaudeDo.slnx` — add inside the `/src/` folder, after the `ClaudeDo.Localization` line:
```xml
<Project Path="src/ClaudeDo.Logging/ClaudeDo.Logging.csproj" />
```
- [ ] **Step 4: Build the new project**
Run: `dotnet build src/ClaudeDo.Logging/ClaudeDo.Logging.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Logging/ClaudeDo.Logging.csproj src/ClaudeDo.Logging/Placeholder.cs ClaudeDo.slnx
git commit -m "build(logging): scaffold ClaudeDo.Logging project"
```
---
### Task 2: `DefaultTaskIdEnricher` (TDD)
Adds `TaskId = "-"` to any log event that doesn't already carry a `TaskId` property, so the `[{TaskId}]` column never renders the raw token. A pushed `LogContext` value takes precedence (because `Enrich.FromLogContext()` runs first and the property is then already present).
**Files:**
- Create: `src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs`
- Delete: `src/ClaudeDo.Logging/Placeholder.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Logging/DefaultTaskIdEnricherTests.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` (add project reference)
- [ ] **Step 1: Reference `ClaudeDo.Logging` from the test project**
Edit `tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` — add to the existing `ProjectReference` ItemGroup:
```xml
<ProjectReference Include="..\..\src\ClaudeDo.Logging\ClaudeDo.Logging.csproj" />
```
- [ ] **Step 2: Write the failing test**
Create `tests/ClaudeDo.Worker.Tests/Logging/DefaultTaskIdEnricherTests.cs`:
```csharp
using ClaudeDo.Logging;
using Serilog;
using Serilog.Context;
using Serilog.Core;
using Serilog.Events;
namespace ClaudeDo.Worker.Tests.Logging;
public sealed class DefaultTaskIdEnricherTests
{
private sealed class CollectingSink : ILogEventSink
{
public List<LogEvent> Events { get; } = new();
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
}
[Fact]
public void AddsDash_WhenNoTaskIdInScope()
{
var sink = new CollectingSink();
using var logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.Enrich.With(new DefaultTaskIdEnricher())
.WriteTo.Sink(sink)
.CreateLogger();
logger.Information("hello");
var prop = Assert.Single(sink.Events).Properties["TaskId"];
Assert.Equal("\"-\"", prop.ToString());
}
[Fact]
public void KeepsPushedTaskId_WhenInScope()
{
var sink = new CollectingSink();
using var logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.Enrich.With(new DefaultTaskIdEnricher())
.WriteTo.Sink(sink)
.CreateLogger();
using (LogContext.PushProperty("TaskId", "task-42"))
logger.Information("hello");
var prop = Assert.Single(sink.Events).Properties["TaskId"];
Assert.Equal("\"task-42\"", prop.ToString());
}
}
```
- [ ] **Step 3: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter DefaultTaskIdEnricherTests`
Expected: FAIL — `DefaultTaskIdEnricher` does not exist (compile error).
- [ ] **Step 4: Implement the enricher and remove the placeholder**
Delete `src/ClaudeDo.Logging/Placeholder.cs`.
Create `src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs`:
```csharp
using Serilog.Core;
using Serilog.Events;
namespace ClaudeDo.Logging;
/// <summary>Ensures every log event carries a TaskId property (defaulting to "-")
/// so the output template's [{TaskId}] column never renders the raw token.</summary>
public sealed class DefaultTaskIdEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
if (!logEvent.Properties.ContainsKey("TaskId"))
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TaskId", "-"));
}
}
```
- [ ] **Step 5: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter DefaultTaskIdEnricherTests`
Expected: PASS (2 tests).
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs tests/ClaudeDo.Worker.Tests/Logging/DefaultTaskIdEnricherTests.cs tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
git rm src/ClaudeDo.Logging/Placeholder.cs
git commit -m "feat(logging): default TaskId enricher with passing tests"
```
---
### Task 3: `BuildConfig.IsDebug`
Detects whether the entry assembly was compiled in the Debug configuration (JIT optimizer disabled) — the runtime replacement for `#if DEBUG`.
**Files:**
- Create: `src/ClaudeDo.Logging/BuildConfig.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Logging/BuildConfigTests.cs`
- [ ] **Step 1: Write the failing test**
The test asserts the property returns *some* bool without throwing, and that the underlying detection logic agrees with the test assembly's own `DebuggableAttribute` (the test runs under whatever config `dotnet test` used). We assert the helper's result equals a locally-computed expectation so it passes under both Debug and Release test runs.
Create `tests/ClaudeDo.Worker.Tests/Logging/BuildConfigTests.cs`:
```csharp
using System.Diagnostics;
using System.Reflection;
using ClaudeDo.Logging;
namespace ClaudeDo.Worker.Tests.Logging;
public sealed class BuildConfigTests
{
[Fact]
public void IsDebug_MatchesEntryAssemblyDebuggableAttribute()
{
var entry = Assembly.GetEntryAssembly();
var expected = entry?
.GetCustomAttribute<DebuggableAttribute>()
?.IsJITOptimizerDisabled ?? false;
Assert.Equal(expected, BuildConfig.IsDebug);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter BuildConfigTests`
Expected: FAIL — `BuildConfig` does not exist (compile error).
- [ ] **Step 3: Implement `BuildConfig`**
Create `src/ClaudeDo.Logging/BuildConfig.cs`:
```csharp
using System.Diagnostics;
using System.Reflection;
namespace ClaudeDo.Logging;
/// <summary>Runtime build-configuration detection — the replacement for #if DEBUG.
/// Debug builds compile with the JIT optimizer disabled; Release builds enable it.</summary>
public static class BuildConfig
{
public static bool IsDebug { get; } =
Assembly.GetEntryAssembly()
?.GetCustomAttribute<DebuggableAttribute>()
?.IsJITOptimizerDisabled ?? false;
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter BuildConfigTests`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Logging/BuildConfig.cs tests/ClaudeDo.Worker.Tests/Logging/BuildConfigTests.cs
git commit -m "feat(logging): runtime Debug-build detection via DebuggableAttribute"
```
---
### Task 4: `LoggingSetup.Configure`
The single shared configuration entry point. Applies enrichers, the output template, and branches sinks/levels on `BuildConfig.IsDebug`.
**Files:**
- Create: `src/ClaudeDo.Logging/LoggingSetup.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Logging/LoggingSetupTests.cs`
- [ ] **Step 1: Write the failing test**
Verifies a configured logger actually writes a `Warning` (emitted in both build configs) to a `claudedo-*.log` file under the given log root.
Create `tests/ClaudeDo.Worker.Tests/Logging/LoggingSetupTests.cs`:
```csharp
using ClaudeDo.Logging;
using Serilog;
namespace ClaudeDo.Worker.Tests.Logging;
public sealed class LoggingSetupTests
{
[Fact]
public void Configure_WritesSharedLogFile()
{
var logRoot = Path.Combine(Path.GetTempPath(), "claudedo-logtest-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(logRoot);
try
{
var logger = LoggingSetup.Configure(new LoggerConfiguration(), "test", logRoot).CreateLogger();
logger.Warning("marker-{Marker}", "xyz");
logger.Dispose(); // flush + release the file handle
var files = Directory.GetFiles(logRoot, "claudedo-*.log");
var file = Assert.Single(files);
var contents = File.ReadAllText(file);
Assert.Contains("marker-", contents);
Assert.Contains("test/", contents); // {Process} tag in the template
}
finally
{
try { Directory.Delete(logRoot, recursive: true); } catch { /* best effort */ }
}
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter LoggingSetupTests`
Expected: FAIL — `LoggingSetup` does not exist (compile error).
- [ ] **Step 3: Implement `LoggingSetup`**
Create `src/ClaudeDo.Logging/LoggingSetup.cs`:
```csharp
using Serilog;
using Serilog.Events;
namespace ClaudeDo.Logging;
public static class LoggingSetup
{
private const string OutputTemplate =
"[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Process}/{SourceContext} [{TaskId}] {Message:lj}{NewLine}{Exception}";
/// <summary>Apply the shared ClaudeDo logging configuration.
/// Debug builds: Debug level, console + shared file. Release builds: Warning level, shared file only.</summary>
/// <param name="processTag">"worker" or "app" — tags every line so the interleaved file is readable.</param>
/// <param name="logRoot">Directory for the shared claudedo-.log (created if missing).</param>
public static LoggerConfiguration Configure(LoggerConfiguration cfg, string processTag, string logRoot)
{
Directory.CreateDirectory(logRoot);
var logFile = Path.Combine(logRoot, "claudedo-.log");
cfg.Enrich.FromLogContext()
.Enrich.WithProperty("Process", processTag)
.Enrich.With(new DefaultTaskIdEnricher());
if (BuildConfig.IsDebug)
{
cfg.MinimumLevel.Debug()
.WriteTo.Console(outputTemplate: OutputTemplate)
.WriteTo.File(
logFile,
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 2,
shared: true,
outputTemplate: OutputTemplate);
}
else
{
cfg.MinimumLevel.Warning()
.WriteTo.File(
logFile,
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 2,
shared: true,
outputTemplate: OutputTemplate);
}
return cfg;
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter LoggingSetupTests`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Logging/LoggingSetup.cs tests/ClaudeDo.Worker.Tests/Logging/LoggingSetupTests.cs
git commit -m "feat(logging): shared LoggingSetup with build-config sink branching"
```
---
### Task 5: Wire the Worker to the shared setup
**Files:**
- Modify: `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
- Modify: `src/ClaudeDo.Worker/Program.cs:34-40`
- [ ] **Step 1: Add the project reference**
Edit `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — add to the existing `ProjectReference` ItemGroup (the one with `ClaudeDo.Data`):
```xml
<ProjectReference Include="..\ClaudeDo.Logging\ClaudeDo.Logging.csproj" />
```
- [ ] **Step 2: Replace the inline Serilog config**
In `src/ClaudeDo.Worker/Program.cs`, replace lines 34-40:
```csharp
builder.Host.UseSerilog((ctx, lc) => lc
.MinimumLevel.Information()
.WriteTo.File(
System.IO.Path.Combine(logRoot, "worker-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
shared: true));
```
with:
```csharp
builder.Host.UseSerilog((ctx, lc) =>
ClaudeDo.Logging.LoggingSetup.Configure(lc, "worker", logRoot));
```
- [ ] **Step 3: Build the Worker**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: Build succeeded. (If the Worker is running and locks the Debug output, this Release build is unaffected.)
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/ClaudeDo.Worker.csproj src/ClaudeDo.Worker/Program.cs
git commit -m "feat(logging): route Worker logging through shared LoggingSetup"
```
---
### Task 6: Wire the App/Ui (currently log-silent) to the shared setup
The App uses a plain `ServiceCollection` with **no** logging registered. Add the Serilog→`ILogger` bridge so all `ILogger<T>` injections across App/Ui flow to the shared sinks, and flush on shutdown.
**Files:**
- Modify: `src/ClaudeDo.App/ClaudeDo.App.csproj`
- Modify: `src/ClaudeDo.App/Program.cs`
- [ ] **Step 1: Add packages and the project reference**
Edit `src/ClaudeDo.App/ClaudeDo.App.csproj` — add to the package `ItemGroup`:
```xml
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
```
and to the `ProjectReference` ItemGroup:
```xml
<ProjectReference Include="..\ClaudeDo.Logging\ClaudeDo.Logging.csproj" />
```
- [ ] **Step 2: Add the logging registration in `BuildServices`**
In `src/ClaudeDo.App/Program.cs`, inside `BuildServices()`, immediately after the `var sc = new ServiceCollection();` line (currently line 78), insert:
```csharp
var logRoot = Path.Combine(Path.GetDirectoryName(dbPath)!, "logs");
var serilogLogger = ClaudeDo.Logging.LoggingSetup
.Configure(new Serilog.LoggerConfiguration(), "app", logRoot)
.CreateLogger();
sc.AddLogging(b => b.AddSerilog(serilogLogger, dispose: true));
```
Add these usings to the top of `Program.cs` (the `AddSerilog` `ILoggingBuilder` extension lives in the `Serilog` namespace; `AddLogging` lives in `Microsoft.Extensions.DependencyInjection`, already imported):
```csharp
using Serilog;
using Microsoft.Extensions.Logging;
```
> `dbPath` is already computed just above (`var dbPath = Paths.Expand(settings.DbPath);`). Its parent directory is `~/.todo-app`, so `logs` sits beside the Worker's log root.
- [ ] **Step 3: Build the App**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded (pulls in Ui + Data + Logging).
- [ ] **Step 4: Verify manually from Rider (visual-verification gap)**
This is a Debug-build behavior that cannot be asserted in a Release test run. Launch the App from Rider's run button and confirm:
- A `claudedo-*.log` appears in `~/.todo-app/logs/`.
- Console output (Rider run window) shows `Debug`-level lines tagged `app/...`.
Flag to the user that this step needs their eyes.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.App/ClaudeDo.App.csproj src/ClaudeDo.App/Program.cs
git commit -m "feat(logging): wire App/Ui logging to shared LoggingSetup"
```
---
### Task 7: Push `TaskId` into `LogContext` in the Worker
Wraps the two per-task entry points so every nested log line (runner, state service, worktree, planning) carries the task's id automatically.
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:47` (`RunAsync`) and `:171` (`ContinueAsync`)
- [ ] **Step 1: Add the using directive**
In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, add to the top usings:
```csharp
using Serilog.Context;
```
- [ ] **Step 2: Push TaskId at the top of `RunAsync`**
In `RunAsync` (line 47), insert as the very first statement of the method body (before `string? mcpToken = null;`):
```csharp
using var _taskScope = LogContext.PushProperty("TaskId", task.Id);
```
- [ ] **Step 3: Push TaskId at the top of `ContinueAsync`**
In `ContinueAsync` (line 171), insert as the very first statement of the method body (before `TaskEntity task;`). The parameter is `taskId`:
```csharp
using var _taskScope = LogContext.PushProperty("TaskId", taskId);
```
- [ ] **Step 4: Build the Worker**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
git commit -m "feat(logging): tag Worker task execution with TaskId for traceability"
```
---
### Task 8: Push `TaskId` and add trace lines on the App side
`WorkerClient` currently logs nothing. Inject `ILogger<WorkerClient>`, add a small helper that pushes `TaskId` + emits a `Debug` trace line, and route the fire-and-forget task-targeted hub calls through it. This produces the UI half of the UI→Worker→UI trace under a shared `TaskId`.
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify: `src/ClaudeDo.App/Program.cs:101` (registration)
- [ ] **Step 1: Add usings and the logger field/ctor param**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add to the usings:
```csharp
using Microsoft.Extensions.Logging;
using Serilog.Context;
```
Add a field beside `private readonly HubConnection _hub;` (line 32):
```csharp
private readonly ILogger<WorkerClient> _logger;
```
Change the constructor signature (line 68) from:
```csharp
public WorkerClient(string signalRUrl)
{
```
to:
```csharp
public WorkerClient(string signalRUrl, ILogger<WorkerClient> logger)
{
_logger = logger;
```
- [ ] **Step 2: Add the task-scoped invoke helper**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add this private method next to `TryInvokeAsync` (after line 241):
```csharp
/// <summary>Invoke a task-targeted hub method under a TaskId log scope, emitting a debug trace line.</summary>
private async Task InvokeForTaskAsync(string taskId, string method, params object?[] args)
{
using (LogContext.PushProperty("TaskId", taskId))
{
_logger.LogDebug("UI invoking {Method} for task {TaskId}", method, taskId);
await _hub.InvokeCoreAsync(method, args);
}
}
```
- [ ] **Step 3: Route the fire-and-forget task actions through the helper**
In the same file, replace each of these method bodies:
`RunNowAsync` (line 243):
```csharp
public Task RunNowAsync(string taskId)
=> InvokeForTaskAsync(taskId, "RunNow", taskId);
```
`ContinueTaskAsync` (line 248):
```csharp
public Task ContinueTaskAsync(string taskId, string followUpPrompt)
=> InvokeForTaskAsync(taskId, "ContinueTask", taskId, followUpPrompt);
```
`ResetTaskAsync` (line 253):
```csharp
public Task ResetTaskAsync(string taskId)
=> InvokeForTaskAsync(taskId, "ResetTask", taskId);
```
`CancelTaskAsync` (line 267):
```csharp
public Task CancelTaskAsync(string taskId)
=> InvokeForTaskAsync(taskId, "CancelTask", taskId);
```
`ApproveReviewAsync` (line 389):
```csharp
public Task ApproveReviewAsync(string taskId)
=> InvokeForTaskAsync(taskId, "ApproveReview", taskId);
```
`RejectReviewToQueueAsync` (line 394):
```csharp
public Task RejectReviewToQueueAsync(string taskId, string feedback)
=> InvokeForTaskAsync(taskId, "RejectReviewToQueue", taskId, feedback);
```
`RejectReviewToIdleAsync` (line 399):
```csharp
public Task RejectReviewToIdleAsync(string taskId)
=> InvokeForTaskAsync(taskId, "RejectReviewToIdle", taskId);
```
`CancelReviewAsync` (line 404):
```csharp
public Task CancelReviewAsync(string taskId)
=> InvokeForTaskAsync(taskId, "CancelReview", taskId);
```
> These all previously did `await _hub.InvokeAsync(method, ...)` with no return value, so converting them to expression-bodied delegations preserves behavior. Do **not** touch methods that return DTOs (e.g. `MergeTaskAsync`) or the planning methods — keep this change scoped to the void task actions above.
- [ ] **Step 4: Update the DI registration to pass the logger**
In `src/ClaudeDo.App/Program.cs`, replace line 101:
```csharp
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
```
with:
```csharp
sc.AddSingleton(sp => new WorkerClient(
sp.GetRequiredService<AppSettings>().SignalRUrl,
sp.GetRequiredService<ILogger<WorkerClient>>()));
```
Add `using Microsoft.Extensions.Logging;` to the top of `Program.cs` if not already present.
- [ ] **Step 5: Build the App**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded.
> Note: `WorkerClient` is faked in tests via the `IWorkerClient` *interface* (hand-rolled fakes implement the interface, they do not subclass `WorkerClient`). This change adds a ctor parameter to the concrete class only and does not alter `IWorkerClient`, so the fakes are unaffected. Confirm by building the test projects in the next step.
- [ ] **Step 6: Build the test projects to confirm fakes still compile**
Run: `dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release && dotnet build tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Expected: Build succeeded for both.
- [ ] **Step 7: Run the full Worker.Tests suite**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
Expected: PASS (all existing tests + the 4 new logging tests).
- [ ] **Step 8: Commit**
```bash
git add src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.App/Program.cs
git commit -m "feat(logging): tag UI task actions with TaskId + debug trace lines"
```
---
## Final verification
- [ ] **Build the whole desktop + worker stack in Release:**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
```
- [ ] **Run the logging tests:**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~Logging"
```
Expected: PASS (DefaultTaskIdEnricher × 2, BuildConfig × 1, LoggingSetup × 1).
- [ ] **Manual smoke test (visual-verification gap — needs the user):**
1. Run the Worker and App from Rider (Debug build). Confirm both write to one `~/.todo-app/logs/claudedo-*.log` with `app/...` and `worker/...` lines.
2. Run a task; grep that file for the task's id — confirm UI (`UI invoking RunNow…`) and Worker lines share the same `[<taskId>]`.
3. Build/install the Release app; confirm the log is near-silent (no `Debug`/`Information` noise, `Warning`+ only) and no console window logging.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
# MyDay Icon Buttons + Terminal Reuse + Sort Icon Fix — Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Move the "Clear day" and "Prep log" actions into the MyDay header icon row as icon buttons (broom + list), render the prep log in the real `SessionTerminalView` ("cool terminal") by making that control reusable, and fix the invisible Sort icon.
**Approved design (chat):**
- Header icon row (`TasksIslandView.axaml`, the Sort/Eye/Settings `icon-btn` StackPanel) gets two more `icon-btn`, both `IsVisible="{Binding IsMyDayList}"`, inserted after the Eye button: **broom** (`Icon.Broom`) → `ClearDayCommand`, **list** (`Icon.List`) → `ShowPrepLogCommand`. The two full-width text buttons "Prep log" and "Clear day" are removed. "Tag vorbereiten" stays as the full-width button (already opens the prep view via `PrepRequested`).
- `SessionTerminalView` becomes reusable via StyledProperties so it renders both the task `Log` and the prep `PrepLog` with the same terminal look. The prep panel in `DetailsIslandView` embeds it instead of the copied `ItemsControl`.
- **Sort icon bug:** `PathIcon` fills geometry; `Icon.Sort` is an open-line path (no enclosed area) → invisible. Replace with a filled geometry. New icons (Broom, List) are authored as filled geometries too.
**Tech:** Avalonia (PathIcon/StreamGeometry, StyledProperty), CommunityToolkit.Mvvm, xUnit.
## Build/test
`.slnx` needs .NET 9 — build the csproj. Use `-c Release` if a Worker locks Debug.
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
GUI cannot be smoke-tested headlessly — note it; the human verifies visuals.
---
## Task A: Icons + reusable SessionTerminalView
**Files:**
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (icon geometries)
- Modify: `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml` + `SessionTerminalView.axaml.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` (both embeds)
- [ ] **Step 1: Fix `Icon.Sort` + add `Icon.Broom`, `Icon.List`** as filled geometries in `IslandStyles.axaml` (in the `Styles.Resources` icon block). Replace the existing `Icon.Sort` line and add the two new ones:
```xml
<!-- Icon.Sort (filled bars, decreasing width) -->
<StreamGeometry x:Key="Icon.Sort">M4 6 H20 V8 H4 Z M4 11 H16 V13 H4 Z M4 16 H11 V18 H4 Z</StreamGeometry>
<!-- Icon.Broom (filled: handle + binding band + flared bristles) -->
<StreamGeometry x:Key="Icon.Broom">M11 3 H13 V10 H11 Z M8.5 10 H15.5 V12 H8.5 Z M9 12 H15 L17 21 H7 Z</StreamGeometry>
<!-- Icon.List (filled: square bullets + lines) -->
<StreamGeometry x:Key="Icon.List">M4 5 H6 V7 H4 Z M8 5 H20 V7 H8 Z M4 11 H6 V13 H4 Z M8 11 H20 V13 H8 Z M4 17 H6 V19 H4 Z M8 17 H20 V19 H8 Z</StreamGeometry>
```
- [ ] **Step 2: Add StyledProperties to `SessionTerminalView`** (code-behind `SessionTerminalView.axaml.cs`). Add public StyledProperties and CLR wrappers:
```csharp
public static readonly StyledProperty<System.Collections.IEnumerable?> EntriesProperty =
AvaloniaProperty.Register<SessionTerminalView, System.Collections.IEnumerable?>(nameof(Entries));
public static readonly StyledProperty<string?> LabelProperty =
AvaloniaProperty.Register<SessionTerminalView, string?>(nameof(Label));
public static readonly StyledProperty<bool> IsRunningProperty =
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(IsRunning));
public static readonly StyledProperty<bool> IsDoneProperty =
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(IsDone));
public static readonly StyledProperty<bool> IsFailedProperty =
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(IsFailed));
public System.Collections.IEnumerable? Entries { get => GetValue(EntriesProperty); set => SetValue(EntriesProperty, value); }
public string? Label { get => GetValue(LabelProperty); set => SetValue(LabelProperty, value); }
public bool IsRunning { get => GetValue(IsRunningProperty); set => SetValue(IsRunningProperty, value); }
public bool IsDone { get => GetValue(IsDoneProperty); set => SetValue(IsDoneProperty, value); }
public bool IsFailed { get => GetValue(IsFailedProperty); set => SetValue(IsFailedProperty, value); }
```
Replace the existing auto-scroll hook (which cast `DataContext as DetailsIslandViewModel` and watched `.Log.CollectionChanged`) with one that watches whichever collection `Entries` points at: in `OnPropertyChanged`, when `change.Property == EntriesProperty`, detach the old `INotifyCollectionChanged.CollectionChanged` handler and attach to the new value (if it implements `INotifyCollectionChanged`); the handler scrolls the existing ScrollViewer to the end (reuse the existing scroll logic / named ScrollViewer). Keep the named ScrollViewer's `x:Name`.
- [ ] **Step 3: Repoint `SessionTerminalView.axaml` internal bindings to the control's own properties.** Give the root `UserControl` `x:Name="Root"`. Change:
- the `ItemsControl ItemsSource="{Binding Log}"``ItemsSource="{Binding #Root.Entries}"`
- the label `TextBlock` `Text="{Binding BranchLine, StringFormat='claude-session · {0}'}"` (or whatever it is) → `Text="{Binding #Root.Label}"`
- the LIVE chip `IsVisible="{Binding IsRunning}"``{Binding #Root.IsRunning}`; DONE → `#Root.IsDone`; FAILED → `#Root.IsFailed`.
Keep the `LogLineViewModel` item template as-is (it binds the item, not the VM). The `x:DataType` can stay `DetailsIslandViewModel` (element-name bindings to `#Root` don't depend on it) or be removed if it causes compile issues — verify the build.
- [ ] **Step 4: Update both embeds in `DetailsIslandView.axaml`.**
- Task embed (currently `<islands:SessionTerminalView MaxHeight="420"/>`):
```xml
<islands:SessionTerminalView MaxHeight="420"
Entries="{Binding Log}"
Label="{Binding BranchLine, StringFormat='claude-session · {0}'}"
IsRunning="{Binding IsRunning}" IsDone="{Binding IsDone}" IsFailed="{Binding IsFailed}"/>
```
(Use the exact label binding the old internal header used — match the prior `StringFormat` text precisely so the task view is visually unchanged.)
- Prep panel: replace the whole copied `ItemsControl` (and its surrounding `ScrollViewer`/title) with:
```xml
<islands:SessionTerminalView
Entries="{Binding PrepLog}" Label="daily-prep"
IsRunning="{Binding IsPrepRunning}"/>
```
Keep the panel wrapper `<Panel IsVisible="{Binding IsPrepMode}">`. Drop the now-redundant `details.prepTitle` title TextBlock (the terminal header shows the `daily-prep` label). Leave the `details.prepTitle` locale key in place (harmless) OR remove it from both en/de if you prefer — if removing, run the localization test.
- [ ] **Step 5: Build the App; confirm no binding/compile errors.**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
```
(The existing DetailsIsland prep tests must still pass — `PrepLog`/`IsPrepMode`/`ShowPrep` are unchanged.)
- [ ] **Step 6: Commit** (stage only Task A files; do NOT `git add -A`):
```bash
git commit -m "feat(daily-prep): reuse SessionTerminal for prep log; fix invisible Sort icon; add Broom/List icons"
```
---
## Task B: MyDay header icon buttons
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
- Modify: `src/ClaudeDo.Localization/locales/en.json`, `de.json`
Depends on Task A (uses `Icon.Broom` / `Icon.List`).
- [ ] **Step 1: Add two `icon-btn` to the header icon StackPanel** (the one with Sort/Eye/Settings), inserted right after the Eye button and before Settings, both MyDay-only:
```xml
<Button Classes="icon-btn" IsVisible="{Binding IsMyDayList}"
Command="{Binding ClearDayCommand}" ToolTip.Tip="{loc:Tr tasks.clearDayTip}">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Broom}"/>
</Button>
<Button Classes="icon-btn" IsVisible="{Binding IsMyDayList}"
Command="{Binding ShowPrepLogCommand}" ToolTip.Tip="{loc:Tr tasks.prepLogTip}">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.List}"/>
</Button>
```
- [ ] **Step 2: Remove the two full-width buttons** "Prep log" (`ShowPrepLogCommand`) and "Clear day" (`ClearDayCommand`) from the DockPanel button stack. Keep the "Prepare day" (`PrepareDayCommand`) full-width button and the Notes pinned-row button.
- [ ] **Step 3: Locales.** Add `tasks.clearDayTip` (en "Clear day", de "Tag leeren") and `tasks.prepLogTip` (en "Prep log", de "Vorbereitungs-Log") to both json files. Remove the now-unused `tasks.clearDay` and `tasks.prepLog` keys from both (keep en/de in parity).
- [ ] **Step 4: Build + test.**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
- [ ] **Step 5: Manual smoke (human):** on MyDay the header shows Sort (now visible) + Eye + Broom + List + Settings; broom clears the day; list opens the prep terminal; "Tag vorbereiten" opens the prep terminal and streams; the three MyDay-only controls hide on other lists; the task session terminal still renders normally.
- [ ] **Step 6: Commit** (stage only Task B files):
```bash
git commit -m "feat(daily-prep): move Clear-day and Prep-log into MyDay header icon row"
```
## Notes / risks
- Element-name bindings (`#Root.*`) require the `UserControl` to have `x:Name="Root"`; verify compiled bindings accept them (they do in Avalonia).
- The auto-scroll hook must re-subscribe when `Entries` changes; without it the prep log won't auto-scroll.
- `ClearDayCommand` / `ShowPrepLogCommand` already exist on `TasksIslandViewModel` — no VM changes; existing VM tests remain valid.

View File

@@ -0,0 +1,120 @@
# Move "Plan day" into the Prep-Log Window — Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Guard daily-prep planning behind a second click. The MyDay header's full-width "Tag vorbereiten" button is removed; instead the user opens the prep-log window (list icon), sees the last run or an empty-state hint, and clicks a **"Plan day"** button inside that window to run the prep.
**Approved flow:** Header list-icon (`ShowPrepLogCommand`) opens the prep window → if empty, an empty-state hint shows → "Plan day" button in the window runs `RunDailyPrepNowAsync()`.
**Tech:** Avalonia + CommunityToolkit.Mvvm, xUnit.
## Build/test
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
GUI not headlessly verifiable — note it; human verifies visuals.
---
## Task: relocate planning trigger + empty-state
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (remove PrepareDay)
- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` (remove header button)
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` (PlanDayCommand + empty-state)
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` (prep panel toolbar + empty hint)
- Modify: `src/ClaudeDo.Localization/locales/en.json`, `de.json`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPrepModeTests.cs`, and the existing `TasksIslandDailyPrepTests.cs` (remove the obsolete prepare test)
- [ ] **Step 1: Write/adjust tests first.**
- In `DetailsIslandPrepModeTests.cs` add:
```csharp
[Fact]
public async Task PlanDayCommand_calls_worker()
{
var stub = new StubWorkerClient();
var vm = NewDetailsVm(stub);
await vm.PlanDayCommand.ExecuteAsync(null);
Assert.Equal(1, stub.RunDailyPrepNowCalls);
}
[Fact]
public void ShowPrepEmptyState_true_when_empty_and_not_running()
{
var vm = NewDetailsVm(new StubWorkerClient());
Assert.True(vm.ShowPrepEmptyState);
}
```
`StubWorkerClient` needs a `RunDailyPrepNowCalls` counter incremented in `RunDailyPrepNowAsync` (add if missing; it currently likely returns `Task.FromResult(true)` — keep that and bump a counter).
- In `TasksIslandDailyPrepTests.cs` **remove** `PrepareDayCommand_raises_PrepRequested` (the command is being deleted). Keep `ClearDayCommand_calls_worker`.
- [ ] **Step 2: Run — expect FAIL/compile error.**
- [ ] **Step 3: `TasksIslandViewModel` — remove planning trigger.**
- Delete the `PrepareDayAsync` `[RelayCommand]` entirely.
- Keep the `PrepRequested` event and `ShowPrepLog` command (the list icon still raises `PrepRequested` to open the window).
- Grep the VM for any remaining `PrepareDay` references and remove them.
- [ ] **Step 4: `TasksIslandView.axaml` — remove the header button.** Delete the full-width "Prepare day" `<Button … Command="{Binding PrepareDayCommand}" …>`. Leave the Notes pinned-row button, and the header icon buttons (broom = ClearDay, list = ShowPrepLog) untouched.
- [ ] **Step 5: `DetailsIslandViewModel` — add PlanDayCommand + empty-state.**
- Add:
```csharp
[RelayCommand]
private async Task PlanDayAsync()
{
if (_worker is null) return;
try { await _worker.RunDailyPrepNowAsync(); }
catch { /* worker offline; PrepStarted/PrepLine will reconcile */ }
}
public bool ShowPrepEmptyState => !IsPrepRunning && PrepLog.Count == 0;
```
- Notify `ShowPrepEmptyState`: in the constructor add `PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));`, and add `partial void OnIsPrepRunningChanged(bool value) => OnPropertyChanged(nameof(ShowPrepEmptyState));`.
- [ ] **Step 6: `DetailsIslandView.axaml` — prep panel toolbar + empty hint.** In the `<Panel IsVisible="{Binding IsPrepMode}">`, wrap the existing `SessionTerminalView` in a `DockPanel`; dock a top toolbar row with the Plan-day button, and overlay/stack an empty-state hint:
```xml
<Panel IsVisible="{Binding IsPrepMode}">
<DockPanel>
<Border DockPanel.Dock="Top" Padding="12,8">
<Button Classes="btn primary"
Command="{Binding PlanDayCommand}"
IsEnabled="{Binding !IsPrepRunning}"
Content="{loc:Tr details.planDay}"/>
</Border>
<Panel>
<islands:SessionTerminalView
Entries="{Binding PrepLog}" Label="daily-prep"
IsRunning="{Binding IsPrepRunning}"/>
<TextBlock IsVisible="{Binding ShowPrepEmptyState}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource TextMuteBrush}"
Text="{loc:Tr details.prepEmpty}"/>
</Panel>
</DockPanel>
</Panel>
```
(Match the surrounding view's class names/brushes; use the existing button class style seen elsewhere, e.g. `Classes="btn"` — verify `primary` exists, else plain `btn`.)
- [ ] **Step 7: Locales.** Add `details.planDay` (en "Plan day", de "Tag planen") and `details.prepEmpty` (en "No prep run today yet — click Plan day", de "Heute noch keine Vorbereitung — klick Tag planen") to both json files. Remove the now-unused `tasks.prepareDay` key from both (grep first to confirm no other reference). Keep en/de key parity.
- [ ] **Step 8: Build + tests.**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
- [ ] **Step 9: Manual smoke (human):** on MyDay there is no "Tag vorbereiten" button; the list icon opens the prep window showing the empty hint; "Plan day" runs the prep and streams; the hint disappears while running; after restart the persisted last run shows and "Plan day" is available to re-run.
- [ ] **Step 10: Commit:**
```bash
git commit -m "feat(daily-prep): trigger planning from inside the prep-log window with an empty-state hint"
```
## Notes / risks
- `PrepRequested` and `ShowPrepLogCommand` stay — only `PrepareDayCommand` and its header button are removed.
- `ShowPrepEmptyState` must re-notify on both `PrepLog` changes and `IsPrepRunning` changes, else the hint won't hide when a run starts or lines arrive.
- Removing `tasks.prepareDay`: confirm via grep it has no remaining references before deleting (keep locale parity or the Localization.Tests parity check fails).

View File

@@ -0,0 +1,208 @@
# Persist Daily-Prep Log Across Restarts — Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** The prep log currently lives only in memory (`DetailsIslandViewModel.PrepLog`), so after an app restart the prep terminal is empty. Persist the last prep run's output to a file in the worker and load it into the prep terminal when opened.
**Root cause (confirmed):** `PrimeRunner.FireAsync` streams stdout lines via `_broadcaster.PrepLineAsync(line)` only — it writes no file and stores no record. `PrepLog` is an in-memory `ObservableCollection` populated only by live `PrepLine` events. Nothing persists → empty after restart.
**Approach:** Worker writes each streamed line to `<appdata>/logs/daily-prep.log` (truncated at run start = last run only) using the existing `LogWriter`. A new hub method `GetLastPrepLog()` returns the file (tail-capped, like `get_task_log`). The UI loads it into `PrepLog` when the prep view opens, but only when `PrepLog` is empty and no run is in progress.
**Tech:** ASP.NET Core SignalR, Avalonia + CommunityToolkit.Mvvm, xUnit.
## Build/test
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
```
GUI not headlessly verifiable — note it; human verifies visuals.
## Shared constant
The prep-log path must be identical in `PrimeRunner` (writer) and `WorkerHub` (reader). Define it once and reference from both:
`Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "logs", "daily-prep.log")`.
Add a small static helper so both sides agree, e.g. in `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs` (already the prep "home"):
```csharp
public static string LogPath() =>
System.IO.Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "logs", "daily-prep.log");
```
---
## Task 1: Worker — write the prep log + serve it
**Files:**
- Modify: `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs` (add `LogPath()` helper)
- Modify: `src/ClaudeDo.Worker/Prime/PrimeRunner.cs`
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs`
- [ ] **Step 1: Add `DailyPrepPrompt.LogPath()`** (code above).
- [ ] **Step 2: Write the failing test.** Extend the existing streaming test (or add one) asserting that after `FireAsync` with emitted stdout lines, the file at `DailyPrepPrompt.LogPath()` contains those lines, and that a prior run's content is replaced (truncate-on-start). Since the path is the real app-data logs dir, the test should delete the file first and clean up after; assert exact line content.
```csharp
[Fact]
public async Task FireAsync_writes_last_run_to_prep_log_file()
{
var path = DailyPrepPrompt.LogPath();
if (File.Exists(path)) File.Delete(path);
var claude = new FakeClaudeProcess(emitLines: new[] { "lineA", "lineB" }, exitCode: 0, result: "ok");
var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
await runner.FireAsync(new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null), CancellationToken.None);
var contents = await File.ReadAllTextAsync(path);
Assert.Contains("lineA", contents);
Assert.Contains("lineB", contents);
// Truncation: a second run with different lines replaces the file.
var claude2 = new FakeClaudeProcess(emitLines: new[] { "lineC" }, exitCode: 0, result: "ok");
var runner2 = NewRunner(claude2, new RecordingPrimeBroadcaster());
await runner2.FireAsync(new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null), CancellationToken.None);
var after = await File.ReadAllTextAsync(path);
Assert.DoesNotContain("lineA", after);
Assert.Contains("lineC", after);
}
```
- [ ] **Step 3: Run — expect FAIL.**
- [ ] **Step 4: Write the file in `PrimeRunner.FireAsync`.** After the gate is acquired and before `RunAsync`: compute `var logPath = DailyPrepPrompt.LogPath();`, delete it if present (truncate → last run only), then create `await using var logWriter = new LogWriter(logPath);`. Change the stream callback to write AND broadcast:
```csharp
var logPath = DailyPrepPrompt.LogPath();
try { if (File.Exists(logPath)) File.Delete(logPath); } catch { /* best effort */ }
await using var logWriter = new LogWriter(logPath);
await _broadcaster.PrepStartedAsync();
// ... build prompt/args/timeoutCts ...
var result = await _claude.RunAsync(
arguments: args, prompt: prompt, workingDirectory: cwd,
onStdoutLine: async line =>
{
await logWriter.WriteLineAsync(line);
await _broadcaster.PrepLineAsync(line);
},
ct: timeoutCts.Token);
```
Keep the existing `success`/`finally`/`PrepFinishedAsync`/gate logic. `using ClaudeDo.Worker.Runner;` is already present (LogWriter lives there). The `await using` LogWriter disposes (flushes) before the method returns.
- [ ] **Step 5: Run — expect PASS.** Build the Worker.
- [ ] **Step 6: Add `WorkerHub.GetLastPrepLog()`** (no ctor change — reads the static path):
```csharp
public Task<string> GetLastPrepLog()
{
var path = DailyPrepPrompt.LogPath();
if (!File.Exists(path)) return Task.FromResult(string.Empty);
const int maxBytes = 256 * 1024;
var bytes = File.ReadAllBytes(path);
var text = bytes.Length <= maxBytes
? System.Text.Encoding.UTF8.GetString(bytes)
: System.Text.Encoding.UTF8.GetString(bytes, bytes.Length - maxBytes, maxBytes);
return Task.FromResult(text);
}
```
Add `using ClaudeDo.Worker.Prime;` to `WorkerHub.cs` if not present.
- [ ] **Step 7: Build Worker; run the full Worker.Tests project.**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
- [ ] **Step 8: Commit** (stage only Task 1 files):
```bash
git commit -m "feat(daily-prep): persist last prep run to a log file and serve it via GetLastPrepLog"
```
---
## Task 2: UI — load the persisted prep log when opening
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
- Modify fakes: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (FakeWorkerClient)
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPrepModeTests.cs`
- [ ] **Step 1: Declare on `IWorkerClient`:** `Task<string> GetLastPrepLogAsync();`
- [ ] **Step 2: Implement in `WorkerClient`:** `public Task<string> GetLastPrepLogAsync() => _hub.InvokeAsync<string>("GetLastPrepLog");` (match neighbouring call style; if there is a `TryInvokeAsync` helper for resilience, mirror `GetWeekReportAsync` and return `?? string.Empty`).
- [ ] **Step 3: Update fakes.** Add `public Task<string> GetLastPrepLogAsync() => Task.FromResult(string.Empty);` to both fakes. In `StubWorkerClient`, make it return a settable backing field, e.g. `public string LastPrepLog = ""; public Task<string> GetLastPrepLogAsync() => Task.FromResult(LastPrepLog);`.
- [ ] **Step 4: Write the failing test.**
```csharp
[Fact]
public async Task ShowPrep_loads_persisted_log_when_empty()
{
var stub = new StubWorkerClient { LastPrepLog = "{\"type\":\"assistant\",\"text\":\"restored\"}" };
var vm = NewDetailsVm(stub);
vm.ShowPrep();
await Task.Delay(50); // allow the async load to run; or expose the load task to await deterministically
Assert.NotEmpty(vm.PrepLog);
}
```
Prefer determinism over `Task.Delay`: have `ShowPrep` start the load and expose the in-flight `Task` (e.g. a `LoadLastPrepLogAsync()` method the test can call/await directly), then assert. Use whichever the existing test style favors.
- [ ] **Step 5: Implement load in `DetailsIslandViewModel`.** Add a method and call it from `ShowPrep`:
```csharp
public void ShowPrep()
{
Bind(null);
IsNotesMode = false;
IsPrepMode = true;
_ = LoadLastPrepLogIfEmptyAsync();
}
private async Task LoadLastPrepLogIfEmptyAsync()
{
if (_worker is null || IsPrepRunning || PrepLog.Count > 0) return;
string text;
try { text = await _worker.GetLastPrepLogAsync(); }
catch { return; }
if (IsPrepRunning || PrepLog.Count > 0) return; // a live run may have started meanwhile
foreach (var line in text.Split('\n'))
{
var trimmed = line.TrimEnd('\r');
if (trimmed.Length > 0) AppendStdoutLine(PrepLog, trimmed);
}
}
```
This reuses the existing `AppendStdoutLine(PrepLog, line)` formatter path, so persisted NDJSON renders identically to the live stream. The guards ensure it never overwrites a live run (`PrepStarted` clears `PrepLog` and sets `IsPrepRunning`) or an already-loaded log.
- [ ] **Step 6: Build App + run UI tests.**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
- [ ] **Step 7: Manual smoke (human):** run a prep, restart the app, open the prep log on MyDay → the last run's output is shown.
- [ ] **Step 8: Commit** (stage only Task 2 files):
```bash
git commit -m "feat(daily-prep): load persisted prep log into the terminal on open"
```
## Notes / risks
- `PrimeRunner` writes via the same `LogWriter` pattern `TaskRunner` uses; concurrency behavior matches existing code (no new locking introduced).
- Path is shared via `DailyPrepPrompt.LogPath()` so writer and reader never diverge.
- Load is guarded (`PrepLog empty && !IsPrepRunning`) to avoid clobbering a live stream — the order of `ShowPrep`'s flag set vs. the async load matters; re-check the guard after the await.
- Last run only (file truncated each run); history is out of scope.

View File

@@ -0,0 +1,74 @@
# Review & Roadblock UX Implementation Plan
> **For agentic workers:** execute task-by-task (subagent-driven-development). Steps use `- [ ]`.
**Goal:** Move the task-row review actions into the Details panel, give the Details panel a real `WaitingForReview` state + a populated diff meter, and add a glanceable yellow roadblock indicator on the task card.
**Architecture:** Persist a `RoadblockCount` on `TaskEntity` (set by the runner when it folds in `CLAUDEDO_BLOCKED` markers). The row shows a warning badge when count > 0; review controls relocate to `DetailsIslandView`.
**Tech Stack:** .NET 8, Avalonia, EF Core (one migration), xUnit.
**Coordination:** A second session (`claudedo-childloop`) is building the child-tasks/improvement-loop in a worktree and will rebase onto main *after* these commits. It also touches `DetailsIslandViewModel`, `TaskRowView.axaml`, `TaskStateService`, `TaskStatus`. This plan deliberately stays OUT of `TaskStateService` and the `TaskStatus` enum (persisting `RoadblockCount` from the runner via the repository instead).
Build/test (per-project, .NET 8):
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
---
## Task A — Persist RoadblockCount (Data + Worker, no UI)
**Files:** `TaskEntity.cs`, `TaskEntityConfiguration.cs`, new migration, `TaskRepository.cs`, `TaskRunner.cs`; test in `tests/ClaudeDo.Data.Tests`.
- Add `public int RoadblockCount { get; set; }` to `TaskEntity` (default 0).
- Map it in `TaskEntityConfiguration` to column `roadblock_count` (default 0). Mirror the pattern used by an existing scalar column (e.g. how `DailyPrepMaxTasks`/other ints are configured).
- Create EF migration `AddRoadblockCount` (run `dotnet ef migrations add AddRoadblockCount` against `src/ClaudeDo.Data`; if EF tooling is unavailable, hand-author the migration + Designer + snapshot edit mirroring the most recent migration). One column, default 0, no backfill needed.
- Add `TaskRepository.SetRoadblockCountAsync(string taskId, int count, CancellationToken ct)` using `ExecuteUpdateAsync` on `RoadblockCount`.
- In `TaskRunner.HandleSuccess`, BEFORE the terminal state write (`SubmitForReviewAsync`/`CompleteAsync`), call `SetRoadblockCountAsync(task.Id, result.Blocks.Count, CancellationToken.None)` so the `TaskUpdated` broadcast reflects it. (Do NOT route this through `TaskStateService`.)
- Test: a `TaskRepository` test that sets a count and reads it back.
- Commit: `feat(roadblock): persist roadblock count on the task`.
**Acceptance:** a finished run with N roadblocks leaves `tasks.roadblock_count = N`; a clean run leaves 0.
---
## Task B — Detail panel: host review actions + real WaitingForReview state + diff meter
**Files:** `DetailsIslandViewModel.cs`, `DetailsIslandView.axaml` (+ `.axaml.cs` if needed), locales if new keys; reuse `IWorkerClient.ApproveReview/RejectReviewToQueue/RejectReviewToIdle/CancelReview` (already exist).
1. **WaitingForReview state:**
- In `StatusToStateKey` map `WaitingForReview => "review"` (was `"running"`); in `FinishedStatusToStateKey` map `"waiting_for_review" => "review"`.
- Add `public bool IsWaitingForReview => AgentState == "review";` and raise it in `OnAgentStateChanged`.
- Add a `vm.agentStatus.review` locale key (en + de, parity) for the status label.
- Confirm `IsAgentSectionEnabled => !IsRunning` still holds (review is no longer "running", so the agent settings section re-enables in review — correct).
2. **Review actions (moved from the row):** add commands to `DetailsIslandViewModel` that call the worker for the selected task: `ApproveReviewCommand`, `RejectReviewCommand` (takes feedback text → `RejectReviewToQueueAsync`), `ParkReviewCommand` (`RejectReviewToIdleAsync`), `CancelReviewCommand` (`CancelReviewAsync`). Add a `ReviewFeedback` string property for the rejection comment. Mirror how the row's code-behind currently invokes these (see `TaskRowView.axaml.cs`).
- In `DetailsIslandView.axaml`, add a review section (visible when `IsWaitingForReview` and `IsTaskDetailVisible`) with Approve / Reject(+feedback box) / Park / Cancel, reusing the existing `tasks.approve/reject/park/cancel` + `tasks.feedback*` locale keys.
3. **Diff meter:** in `RefreshWorktreeAsync`, after setting `row.DiffStat`, parse the `--stat` summary into additions/deletions and assign `DiffAdditions`/`DiffDeletions` (drives `DiffMeterRatio`). Add a small static parser `ParseDiffStat(string?) -> (int add, int del)` reading the "N insertions(+), M deletions(-)" tail; unit-test it.
- Commit: `feat(ui): host review actions in the details panel; show review state and diff meter`.
**Acceptance:** selecting a `WaitingForReview` task shows a "review" status (not "running"), the four review actions work from the detail panel, and the diff meter reflects real additions/deletions.
---
## Task C — Task row: remove review buttons, add roadblock badge
**Files:** `TaskRowView.axaml`, `TaskRowView.axaml.cs`, `TaskRowViewModel.cs`; warning icon resource if missing.
- Remove the review-actions `StackPanel` (lines ~142157) and the now-unused `RejectAnchor` flyout (~250279) from `TaskRowView.axaml`, and the corresponding click handlers (`OnApproveReviewClick`, `OnRejectReviewClick`, `OnParkReviewClick`, `OnCancelReviewClick`, reject-flyout handlers) from the code-behind. (Review now lives in the detail panel — Task B.)
- `TaskRowViewModel`: add `int RoadblockCount` + `bool HasRoadblock => RoadblockCount > 0` + `string RoadblockTooltip` (e.g. `"{n} roadblock(s) reported — see details"`); map `RoadblockCount` in `FromEntity`.
- `TaskRowView.axaml`: add a yellow warning `PathIcon` immediately left of the action area (in the chip row, before the status chip or before the star — pick the spot that reads as "left of the Done/action button"), `IsVisible="{Binding HasRoadblock}"`, `ToolTip.Tip="{Binding RoadblockTooltip}"`. Use a filled-geometry warning icon (PathIcon fills geometry — a stroke path renders invisible); if no `Icon.Warning` resource exists, add one (filled triangle + exclamation) to the icon resources, colored with a yellow/amber brush.
- Commit: `feat(ui): roadblock badge on the task card; relocate review actions`.
**Acceptance:** rows no longer show the four review buttons; a task with `RoadblockCount > 0` shows a yellow ⚠ left of the action button with a tooltip; review still fully works via the detail panel.
---
## Task D — Build + visual-check
- Full build (`App` + `Worker`) and run Data + Worker test suites; all green.
- **Manual (flag for user):** start the app, take a `WaitingForReview` task (the deploy roadblock task qualifies), confirm: row shows the ⚠ badge + no row review buttons; detail panel shows "review" state, working review actions, and a non-zero diff meter for the farewell/README tasks. The agent cannot verify GUI — ask the user.
- Then ping `claudedo-childloop` via mailbox with the exact shared-file diffs so it can rebase.

View File

@@ -0,0 +1,316 @@
# Installer: Download-Mode + Gitea Releases
Date: 2026-04-15
Status: Design — awaiting implementation plan
## Goal
Turn `ClaudeDo.Installer` into a self-contained tool that any user can run on
any Windows machine to install, update, reconfigure, repair, or uninstall
ClaudeDo. The installer pulls prebuilt binaries from a Gitea release on
`git.kuns.dev` instead of building from source.
End-user experience:
1. Download `ClaudeDo.Installer-<version>.exe` from the releases page.
2. Run it.
3. Done — no .NET SDK, no source checkout, no manual steps.
## Non-Goals
- Code signing the installer or the app binaries (future concern).
- Cross-platform installs (Windows-only, same as today).
- In-app update notifications (the installer handles updates when run; the app
does not self-update).
- Arbitrary-version selection UI. Installer always targets "latest" release.
- A package-manager listing (winget/Chocolatey/Scoop). Future, separate spec.
## Current State (2026-04-15)
The existing installer (`src/ClaudeDo.Installer/`) is a WPF wizard that only
works from inside a source checkout on a machine with the .NET SDK installed:
- `PublishAppStep` runs `dotnet publish src/ClaudeDo.App/...`
- `PublishWorkerStep` runs `dotnet publish src/ClaudeDo.Worker/...`
- `DeployBinariesStep` copies `bin/Release/.../publish` into the install dir
- Subsequent steps (`WriteConfigStep`, `InitDatabaseStep`,
`CreateShortcutsStep`, `RegisterServiceStep`) are fine to keep.
The installer also contains a partial "Settings" window
(`Views/SettingsWindow.xaml`, `Views/SettingsViewModel.cs`) — that wiring is
reused for the Config view shown on subsequent launches (see Mode detection
below).
## High-Level Design
Two pieces, each small:
**1) A Gitea Actions workflow** that, on every `v*` tag push, builds the App,
Worker, and Installer; packages them; and creates a Gitea Release on the
public repo at `git.kuns.dev/releases/ClaudeDo`.
The `releases/` org on the Gitea instance is world-readable without auth;
private work (including the source repo, if you want) lives under `kuns/*`
which is never public. The installer only needs to hit `releases/ClaudeDo`.
**2) An installer rewrite** that replaces the three publish/deploy steps with
a single `DownloadAndExtractStep`, detects existing installs via a marker
file, and on subsequent launches checks the Gitea API for updates before
deciding whether to show the Update flow or jump straight to the Config view.
## Release Artifacts
Each `v*` tag produces a Gitea Release with three assets:
```
ClaudeDo-<version>-win-x64.zip # contains /app and /worker subdirs
ClaudeDo.Installer-<version>.exe # self-contained installer (no .NET needed)
checksums.txt # SHA256 of the above
```
Decisions:
- **One combined app+worker zip** (not two separate). Reasons: one download,
one extract, guaranteed version-locked pair.
- **Self-contained installer exe** — user does not need .NET installed.
- **App + Worker: self-contained** (`--self-contained true`, `-r win-x64`).
Zero runtime dependency on the target machine, at the cost of a larger
download (~100 MB combined zip). Decision: acceptable trade-off — the
installer is one-click, not per-user-problem-to-debug.
- **Checksums file** — plain text, one line per asset (`<sha256> <filename>`),
verified by installer before extract.
The "latest installer exe" URL is stable:
```
https://git.kuns.dev/releases/ClaudeDo/releases/latest/download/ClaudeDo.Installer-<version>.exe
```
(Gitea also exposes `/releases/download/<tag>/<filename>` for specific
versions.)
## Gitea Actions Workflow
File: `.gitea/workflows/release.yml`
- **Trigger:** `push` on tags matching `v*`
- **Runner:** Linux container with .NET 8 SDK (`dotnet publish -r win-x64`
works cross-platform). The installer itself requires Windows to run, but
`dotnet publish` can target `win-x64` from Linux.
- **Steps:**
1. Checkout
2. Setup .NET 8 SDK
3. Derive version from tag (`${{ gitea.ref_name }}` without the `v` prefix)
4. `dotnet publish src/ClaudeDo.App -c Release -r win-x64 --self-contained true /p:Version=$VERSION -o out/app`
5. `dotnet publish src/ClaudeDo.Worker -c Release -r win-x64 --self-contained true /p:Version=$VERSION -o out/worker`
6. `dotnet publish src/ClaudeDo.Installer -c Release -r win-x64 --self-contained true /p:Version=$VERSION /p:PublishSingleFile=true -o out/installer`
7. Zip `out/app` + `out/worker` as `ClaudeDo-<version>-win-x64.zip` with
`app/` and `worker/` as top-level dirs
8. Copy `out/installer/ClaudeDo.Installer.exe` to
`ClaudeDo.Installer-<version>.exe`
9. Generate `checksums.txt` (`sha256sum` both files)
10. Create release via Gitea API using the built-in `${{ gitea.token }}`
(this token has repo write scope automatically on Actions runs). Release
name = tag name. Release notes = `git log` summary between previous tag
and this one (nice-to-have).
The workflow needs **no custom secrets**`gitea.token` is sufficient for
creating releases on its own repo.
## Installer Changes
### New: `install.json` marker file
Written at the end of every successful install or update to
`{InstallDir}/install.json`:
```json
{
"version": "0.2.0",
"installDir": "C:\\Program Files\\ClaudeDo",
"workerDir": "C:\\Program Files\\ClaudeDo\\worker",
"installedAt": "2026-04-15T12:34:56Z"
}
```
The installer reads this on startup (from the default install dir, or a
path supplied via CLI arg) to decide which mode to run in.
### Launch flow (`InstallModeDetector`)
On every launch, the installer checks for `install.json` first:
```
install.json absent?
-> Install mode: Welcome -> Paths -> UiSettings -> Service -> Install
(writes install.json at the end)
install.json present?
-> Query https://git.kuns.dev/api/v1/repos/releases/ClaudeDo/releases/latest
(short timeout; if offline, treat as "no update available")
latest.tag_name > installed.version
-> Update mode: Welcome ("Update v0.1.0 -> v0.2.0, Update / Later")
If user accepts -> Install steps (download + swap service)
If user declines -> fall through to Config view
latest.tag_name <= installed.version (or API unreachable)
-> Config view: directly open Paths/UiSettings/Service tabs,
prefilled from existing ~/.todo-app/*.json.
Action buttons: Save · Repair · Uninstall.
```
Key properties:
- **First run = wizard**, as today — no behavior change for new users.
- **Every subsequent run = update check first**, then either offer update or
drop straight into Config. No "Manage page" with a menu of actions — the
Config view *is* the default, and Repair/Uninstall are buttons on it.
- **Offline / API error = not fatal**: if the release endpoint can't be
reached, the installer silently skips the update check and opens Config.
The user is never blocked from managing an existing install by a network
issue.
- **Downgrade** (installed version > latest) is treated the same as "no
update available" — we don't ever offer a downgrade.
The installer's own version (shown for reference in Config) comes from its
assembly (`AssemblyInformationalVersion`), set by the workflow's
`/p:Version=$VERSION`. The *installed* version comes from `install.json`.
### New step: `DownloadAndExtractStep`
Replaces `PublishAppStep`, `PublishWorkerStep`, `DeployBinariesStep`.
```
1. GET https://git.kuns.dev/api/v1/repos/releases/ClaudeDo/releases/latest
Parse tag_name and asset URLs for:
- ClaudeDo-<ver>-win-x64.zip
- checksums.txt
2. Download both to %TEMP%\ClaudeDo-install-<guid>\
3. Parse checksums.txt, verify SHA256 of the zip. Fail hard if mismatch.
4. (Update mode only) Stop Worker service via sc.exe stop ClaudeDoWorker.
Wait up to 30s for it to actually stop. If it won't stop, fail.
5. (Update mode only) Delete contents of {InstallDir}/app and
{InstallDir}/worker, but leave the directories and install.json in place.
6. Extract zip: /app -> {InstallDir}/app, /worker -> {InstallDir}/worker.
7. (Update mode only) Start service again via sc.exe start ClaudeDoWorker.
8. Progress is reported via IProgress<string> — the UI already shows it.
```
Config files (`~/.todo-app/*.json`) and DB (`~/.todo-app/todo.db`) live
outside `InstallDir` and are never touched by this step — updates are
naturally non-destructive.
### Update mode — which steps run
- **Yes:** `DownloadAndExtractStep`
- **No:** `WriteConfigStep` (user already has config — we don't overwrite)
- **No:** `InitDatabaseStep` (DB exists)
- **No:** `CreateShortcutsStep` (already there; Repair can re-run this)
- **Conditional:** `RegisterServiceStep` only if service is not currently
registered (covers someone who unregistered it manually)
### Config view — actions
- **Save** (primary): writes the Paths / UiSettings / Service fields to
`~/.todo-app/*.json`. If worker config changed, prompts "Restart service?"
and calls `sc stop` / `sc start` if accepted. No download.
- **Repair:** re-download + extract (same as Update flow), re-create
shortcuts, re-register service. Leaves config/DB alone. Confirmation
dialog before starting.
- **Uninstall:** confirmation dialog ("This removes ClaudeDo *and* all of
your tasks, config, and database. Type UNINSTALL to confirm."). On
confirm:
1. Stop + unregister service (`sc stop`, `sc delete ClaudeDoWorker`)
2. Remove Start Menu / Desktop shortcuts
3. Delete `{InstallDir}` (including `install.json`)
4. Delete `~/.todo-app` in full (config + DB + logs)
5. Exit
Everything is removed. No "keep my data" option — that was explicitly
declined in the design discussion.
### Files to add
```
src/ClaudeDo.Installer/Core/InstallModeDetector.cs
src/ClaudeDo.Installer/Core/ReleaseClient.cs // Gitea API + downloads
src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
src/ClaudeDo.Installer/Core/InstallManifest.cs // read/write install.json
src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs
src/ClaudeDo.Installer/Steps/StopServiceStep.cs // used in Update+Uninstall
src/ClaudeDo.Installer/Steps/StartServiceStep.cs // used in Update+Repair
.gitea/workflows/release.yml
```
### Files to remove
```
src/ClaudeDo.Installer/Steps/PublishAppStep.cs
src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs
src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs
```
### Files to update
- `Core/InstallerService.cs` — mode-aware step list
- `Core/InstallContext.cs` — add `Version`, `Mode`, `IsFirstInstall` fields
- `Pages/WelcomePage/*` — content + buttons depend on mode
- `Views/WizardViewModel.cs` — route pages based on mode
- `Core/PageResolver.cs` — register new/renamed pages
- `ClaudeDo.Installer.csproj` — add `PublishSingleFile`, `SelfContained`
properties (only active when published)
## Failure Modes & Recovery
| Failure | Behavior |
|---------------------------------------|-------------------------------------------------------|
| No network / Gitea unreachable | Step fails with clear message + retry button |
| API returns no releases yet | "No release available — publish a tag first" |
| Checksum mismatch | Step fails, temp files deleted, user prompted retry |
| Zip extraction fails mid-way (update) | InstallDir is left partially empty — user re-runs |
| Service won't stop | Fail before extract; nothing destructive has happened |
| User cancels mid-download | Temp dir cleaned up; install state unchanged |
For safety, the `DownloadAndExtractStep` always downloads + verifies
**before** it deletes the old binaries. An aborted download cannot leave
an install in a half-deleted state.
## Security
- All downloads over HTTPS from a pinned host (`git.kuns.dev`).
- SHA256 verification before extract (protects against partial downloads and
tampered caches on the wire; not a substitute for code signing).
- No tokens shipped in the installer — repo is public.
- Worker service runs under the same account as today (no change).
## Decisions to Revisit
1. **Release notes content.** Auto-generated `git log` summary vs manual
notes in the tag message vs empty. Start empty; revisit when there are
enough releases to care.
2. **Signed installer.** Out of scope for v1. Users will see a SmartScreen
warning the first time. Note this in the README.
3. **Installer distribution page.** A simple `README.md` badge or a pinned
"Latest release" link on the Gitea repo home is enough for v1.
## Success Criteria
- On a fresh Windows VM with **no source checkout, no .NET runtime, and no
.NET SDK**:
1. Download `ClaudeDo.Installer-<ver>.exe`.
2. Run it.
3. Complete the wizard.
4. ClaudeDo App launches, Worker service is running, a task can be created
and picked up.
- Running the same installer a second time, with no new release published,
opens directly in the Config view after a quick update check.
- Publishing a new tag, then running the installer on the existing install,
offers the update; accepting performs it without touching `~/.todo-app/todo.db`
or the config JSONs.
- Uninstall leaves no trace: `{InstallDir}` gone, `~/.todo-app` gone, service
unregistered, shortcuts removed.
- The entire release pipeline runs on `git.kuns.dev` with no manual steps
beyond `git tag vX.Y.Z && git push --tags`.

View File

@@ -0,0 +1,253 @@
# EF Core Migration Design
Replace the raw ADO.NET / Microsoft.Data.Sqlite data layer with Entity Framework Core and LINQ queries.
## Motivation
- Developer ergonomics: raw SQL is tedious to write and maintain; LINQ enables faster iteration.
- Maintainability: the ad-hoc migration approach (ALTER TABLE with error-code catching) and manual DBNull/enum mapping are a liability as the schema grows. EF Core provides proper migration versioning, value converters, and change tracking.
## Decision Summary
| Decision | Choice |
|---|---|
| Approach | Big bang — rewrite all 6 repositories at once |
| Migration strategy | Fresh start — EF Core owns the schema, drop schema.sql |
| DbContext sharing | Single shared `ClaudeDoDbContext` in ClaudeDo.Data |
| Configuration style | Fluent API only, clean POCO models |
| Atomic queue claim | Kept as `FromSqlRaw` — not expressible in LINQ |
---
## 1. DbContext and Entity Configuration
### ClaudeDoDbContext
A single `ClaudeDoDbContext` in `ClaudeDo.Data` with DbSets for all entities:
```csharp
public class ClaudeDoDbContext : DbContext
{
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<TagEntity> Tags => Set<TagEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
}
```
### Entity-to-Table Mapping
| Entity | Table | Key | Notes |
|---|---|---|---|
| `TaskEntity` | `tasks` | `Id` (TEXT) | Nav to List, Tags, Worktree, Runs, Subtasks |
| `ListEntity` | `lists` | `Id` (TEXT) | Nav to Tasks, Tags, Config |
| `TagEntity` | `tags` | `Id` (INTEGER auto) | Nav to Lists, Tasks (both M:N) |
| `ListConfigEntity` | `list_config` | `ListId` (TEXT) | 1:1 owned by List |
| `WorktreeEntity` | `worktrees` | `TaskId` (TEXT) | 1:1 owned by Task |
| `TaskRunEntity` | `task_runs` | `Id` (TEXT) | FK to Task |
| `SubtaskEntity` | `subtasks` | `Id` (TEXT) | FK to Task |
### Navigation Properties Added to Models
```csharp
// TaskEntity gains:
public ListEntity List { get; set; }
public WorktreeEntity? Worktree { get; set; }
public ICollection<TagEntity> Tags { get; set; }
public ICollection<TaskRunEntity> Runs { get; set; }
public ICollection<SubtaskEntity> Subtasks { get; set; }
// ListEntity gains:
public ListConfigEntity? Config { get; set; }
public ICollection<TaskEntity> Tasks { get; set; }
public ICollection<TagEntity> Tags { get; set; }
// TagEntity gains:
public ICollection<ListEntity> Lists { get; set; }
public ICollection<TaskEntity> Tasks { get; set; }
```
### Enum Handling
EF Core `ValueConverter<TEnum, string>` for `TaskStatus` and `WorktreeState`, storing the same lowercase strings (`"manual"`, `"active"`, etc.) for database compatibility. The `ToDb`/`FromDb` methods in repositories are removed.
### Junction Tables
`list_tags` and `task_tags` are configured as implicit join tables via `.UsingEntity()` in Fluent API — no explicit junction entity classes needed.
### Fluent Configuration
Each entity gets its own `IEntityTypeConfiguration<T>` class in a `Configuration/` folder within `ClaudeDo.Data`.
---
## 2. Migration Strategy
### Fresh Start
- `schema.sql` and `SchemaInitializer` are deleted.
- An initial EF Core migration (`InitialCreate`) is generated from the DbContext model, producing the full schema (all 8 tables, indexes, foreign keys, check constraints).
- EF's `__EFMigrationsHistory` table tracks applied migrations.
### Startup
Both App and Worker call `context.Database.Migrate()` at startup instead of `SchemaInitializer.Apply()`. This is idempotent.
### Existing Database Compatibility
For users who already have a database created by `schema.sql`, the initial migration must handle the schema already existing. On startup, if the `lists` table exists but `__EFMigrationsHistory` does not, insert the initial migration record into `__EFMigrationsHistory` so EF skips it.
### Seed Data
The `"agent"` and `"manual"` tags move into `OnModelCreating` via `HasData()`:
```csharp
modelBuilder.Entity<TagEntity>().HasData(
new TagEntity { Id = 1, Name = "agent" },
new TagEntity { Id = 2, Name = "manual" });
```
### Ad-hoc Migrations Removed
The 3 manual `ALTER TABLE` statements (model, system_prompt, agent_path on tasks) become part of the initial migration since they're already in the model. The manual `ApplyMigrations()` method is deleted.
---
## 3. Repository Rewrite
All 6 repositories are rewritten to use `ClaudeDoDbContext` and LINQ.
### Per-Repository Changes
| Repository | After EF Core |
|---|---|
| `TagRepository` | LINQ queries. `GetOrCreateAsync` uses `FirstOrDefaultAsync` + `Add` + `SaveChangesAsync`. Static `SqliteConnection` overload removed. |
| `SubtaskRepository` | Straightforward LINQ CRUD, `.OrderBy(s => s.OrderNum)`. |
| `WorktreeRepository` | LINQ CRUD. State update becomes property set + `SaveChangesAsync`. |
| `ListRepository` | LINQ CRUD. Tag management via `.Tags` navigation property. Config upsert via `List.Config` navigation. |
| `TaskRunRepository` | LINQ CRUD. Latest = `.OrderByDescending(r => r.RunNumber).FirstOrDefaultAsync()`. |
| `TaskRepository` | See special cases below. |
### TaskRepository Special Cases
**Atomic queue claim** (`GetNextQueuedAgentTaskAsync`): kept as `FromSqlRaw` / `ExecuteSqlRawAsync`. The `UPDATE ... WHERE id = (SELECT ...) RETURNING` is not expressible in LINQ and the atomicity guarantee matters.
**Effective tags** (`GetEffectiveTagsAsync`): LINQ via navigation properties:
```csharp
var taskTags = context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags);
var listTags = context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.List.Tags);
return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
```
**FlipAllRunningToFailed**: EF Core 7+ bulk update:
```csharp
await context.Tasks
.Where(t => t.Status == TaskStatus.Running)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Failed), ct);
```
**Status transitions** (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`): property updates + `SaveChangesAsync`.
### Removed Code
- `SqliteConnectionFactory.cs`
- `SchemaInitializer.cs`
- `schema/schema.sql`
- All `ToDb`/`FromDb` enum mapping methods
- All manual `DBNull.Value` handling
- `BindTask` helper methods
---
## 4. Package Changes and DI Registration
### ClaudeDo.Data.csproj
- Remove: `Microsoft.Data.Sqlite`
- Remove: embedded resource for `schema.sql`
- Add: `Microsoft.EntityFrameworkCore.Sqlite`
- Add: `Microsoft.EntityFrameworkCore.Design` (`PrivateAssets="all"`)
### ClaudeDo.Worker.Tests.csproj
- Remove: `Microsoft.Data.Sqlite`
- Add: `Microsoft.EntityFrameworkCore.Sqlite`
### App DI (Program.cs)
```csharp
// Replace SqliteConnectionFactory + singleton repos with:
sc.AddDbContextFactory<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={dbPath}"));
sc.AddScoped<ClaudeDoDbContext>(sp =>
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
sc.AddScoped<ListRepository>();
sc.AddScoped<TaskRepository>();
sc.AddScoped<SubtaskRepository>();
sc.AddScoped<TagRepository>();
sc.AddScoped<WorktreeRepository>();
sc.AddScoped<TaskRunRepository>();
// Migrate at startup:
using var initScope = services.CreateScope();
initScope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>().Database.Migrate();
```
ViewModels are singletons that currently take repositories as constructor parameters. Since repositories become scoped, ViewModels switch to taking `IDbContextFactory<ClaudeDoDbContext>` and create a fresh context (+ repositories) per operation. Each ViewModel method that touches data does: `using var context = _factory.CreateDbContext();` then constructs or resolves the needed repository with that context. This mirrors the current connection-per-call pattern.
### Worker DI (Program.cs)
```csharp
builder.Services.AddDbContext<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={cfg.DbPath}"));
builder.Services.AddScoped<ListRepository>();
builder.Services.AddScoped<TaskRepository>();
builder.Services.AddScoped<SubtaskRepository>();
builder.Services.AddScoped<TagRepository>();
builder.Services.AddScoped<WorktreeRepository>();
builder.Services.AddScoped<TaskRunRepository>();
// Migrate at startup after build:
using var scope = app.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>().Database.Migrate();
```
Worker has request scopes via SignalR hub invocations, so scoped registration works naturally.
---
## 5. Test Infrastructure
### DbFixture
`DbFixture` is rewritten as an EF Core fixture:
- Creates a temp SQLite file per test class.
- Builds `DbContextOptions<ClaudeDoDbContext>` with `UseSqlite`.
- Calls `context.Database.Migrate()` to apply the schema (also tests that migrations work).
- Exposes a `CreateContext()` method so each test gets a fresh context instance (avoids change-tracker bleed).
Tests construct repositories by passing in a fresh context from the fixture.
No mocking — tests keep hitting real SQLite, same philosophy as today.
---
## 6. Risk and Mitigation
| Risk | Mitigation |
|---|---|
| Big-bang rewrite touches nearly every file in ClaudeDo.Data | Existing tests are the safety net — all must pass after migration |
| Existing databases with schema from schema.sql | Compatibility shim: detect existing tables, mark initial migration as applied |
| Atomic queue claim semantics change | Kept as raw SQL via `FromSqlRaw` |
| Scoped lifetime vs. singleton ViewModels | `IDbContextFactory` provides on-demand contexts |
| EF change tracker overhead vs. raw ADO.NET | Negligible for this workload size; use `AsNoTracking()` for read-only queries |

View File

@@ -0,0 +1,118 @@
# Subtask Tree View in Task List
## Problem
Subtasks are invisible in the task list — users only see them after opening the detail pane or editor modal. This makes it hard to get an overview of task progress without clicking into each task individually.
## Solution
Show subtasks indented below their parent task in the task list, with expand/collapse. Tasks start collapsed with a visual indicator when subtasks exist.
## Scope
Pure UI/ViewModel change. No data model changes, no new migrations, no repository schema changes.
## Design
### ViewModel Changes
**TaskItemViewModel** — add:
- `ObservableCollection<SubtaskItemViewModel> Subtasks` — populated on first expand
- `bool IsExpanded` — observable, default `false`; toggles subtask visibility
- `bool HasSubtasks` — observable, set during initial load from a count query
- `int SubtaskCount` — observable, used for the indicator
- `ToggleExpandedCommand` — flips `IsExpanded`; on first expand, loads subtasks from `SubtaskRepository.GetByTaskIdAsync`
- `ToggleSubtaskDoneCommand(string subtaskId)` — toggles a subtask's `Completed` and persists via `SubtaskRepository.UpdateAsync`
Constructor gains `SubtaskRepository` and initial `subtaskCount` parameter.
**TaskListViewModel.LoadAsync** — after fetching tasks, run a single batch query to get subtask counts per task. Pass counts into each `TaskItemViewModel`. This avoids N+1 queries on load.
**TaskListViewModel.RefreshSingleAsync** — if the refreshed task's `IsExpanded` is true, also reload its subtasks from DB and update the collection.
### Repository Changes
**SubtaskRepository** — add one method:
```csharp
Task<Dictionary<string, int>> GetCountsByTaskIdsAsync(IEnumerable<string> taskIds, CancellationToken ct = default)
```
Single query: `SELECT task_id, COUNT(*) FROM subtasks WHERE task_id IN (...) GROUP BY task_id`. Returns a map of taskId -> count. Tasks with no subtasks won't appear in the result (count defaults to 0).
### XAML Changes
**TaskListView.axaml** — the `DataTemplate` for `TaskItemViewModel` becomes a 2-row grid:
```
Row 0: [ExpandChevron] [StatusCircle] [Title + Tags/Status subtitle]
Row 1: [SubtaskItemsControl, margin-left ~40px, visible when IsExpanded]
```
**Row 0 — Expand chevron:**
- Column 0 gets a small chevron button (12x12 `Path` data) before the status circle
- Right-pointing when collapsed, down-pointing when expanded
- Bound to `ToggleExpandedCommand`
- Only visible when `HasSubtasks` is true (via `IsVisible` binding)
- When `HasSubtasks` is false, the space is empty but reserved (fixed-width column) so all titles align
**Row 1 — Subtask list:**
- `ItemsControl` bound to `Subtasks`
- `IsVisible` bound to `IsExpanded`
- Left margin ~40px for visual indentation
- Each subtask item: `CheckBox` (bound to `Completed`) + `TextBlock` (bound to `Title`)
- Subtask row has its own context menu flyout with "Edit Task" (opens parent task's editor modal via `EditTaskCommand` on root `TaskListViewModel`)
- Checkbox toggle calls `ToggleSubtaskDoneCommand` on the parent `TaskItemViewModel`
**Column layout change:** The existing 2-column `Grid` (`Auto, *`) gets a third column prepended: `Auto, Auto, *`. The chevron goes in column 0, status circle in column 1, title stack in column 2. Row 1 spans all 3 columns.
### Subtask Checkbox Interaction
When a subtask checkbox is toggled in the list:
1. Update the `SubtaskItemViewModel.Completed` property
2. Call `SubtaskRepository.UpdateAsync` with the updated entity (same auto-save pattern as `TaskDetailView`)
3. No need to refresh the parent task — subtask completion doesn't affect task status
### Subtask Context Menu
Right-click on a subtask row shows:
- "Edit Task" — opens the parent task's editor modal (same flow as `EditTaskCommand`)
This reuses the existing editor which already has full subtask editing (add/remove/reorder/rename).
### Real-time Updates
When `RefreshSingleAsync` fires (via SignalR `TaskUpdatedEvent`):
1. Reload subtask count, update `HasSubtasks` and `SubtaskCount`
2. If `IsExpanded`, reload subtask list from DB and reconcile with the observable collection
### Detail Pane Sync
When the user edits subtasks in `TaskDetailView` (auto-save) or `TaskEditorView` (batch-save), the list view's subtask state may become stale. Two options:
**Chosen approach:** The detail pane and editor already trigger `TaskUpdatedEvent` (or the editor's save path calls `RefreshSingleAsync` via `SelectedTask.Refresh`). Extend `Refresh` on `TaskItemViewModel` to also reload subtasks if expanded, and update `HasSubtasks`/`SubtaskCount`.
### Visual Style
- Chevron: 10x10 path, `TextDimBrush` color, no background, cursor=Hand
- Subtask rows: smaller font (12px), `TextDimBrush` for unchecked title, strikethrough + dimmed for completed
- Subtask checkbox: standard Avalonia `CheckBox` (no custom circular border), small size
- Subtask row vertical padding: 2px (compact)
- Indent: 40px left margin on the subtask `ItemsControl`
## Files to Modify
1. `src/ClaudeDo.Data/Repositories/SubtaskRepository.cs` — add `GetCountsByTaskIdsAsync`
2. `src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs` — add subtask collection, expand/collapse, toggle done
3. `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — batch-load counts, pass SubtaskRepository, extend refresh
4. `src/ClaudeDo.Ui/Views/TaskListView.axaml` — restructure item template with chevron + nested ItemsControl
5. `src/ClaudeDo.Ui/Views/TaskListView.axaml.cs` — handle subtask context menu pointer-pressed if needed
6. `src/ClaudeDo.App/Program.cs` — pass SubtaskRepository to TaskListViewModel (if not already available via DI)
## Out of Scope
- Drag-to-reorder subtasks in the list view
- Add subtask directly from the list view
- Subtask progress indicator (e.g., "2/5 done") on collapsed tasks
- Recursive task nesting (tasks containing tasks)

View File

@@ -0,0 +1,130 @@
# Continue & Reset Buttons for Failed Tasks
## Problem
When a task ends in `Failed` status (Claude exited without marking the work done, cancelled mid-run, crashed, etc.), the user has no way to act on it from the UI:
- **Nudging the agent** is only possible via the hub method `ContinueTask`, which is not wired into the UI.
- **Rolling back** the worktree requires shelling into git manually to remove the branch and folder, then editing the task in the DB. In practice the worktree is just abandoned.
We want two explicit actions in the details pane for a failed task: **Continue** (resume the Claude session with a follow-up prompt) and **Reset** (discard the worktree and return the task to an editable `Manual` state).
## Scope
- Actions are shown **only when the selected task has `Status == Failed`**.
- `Continue` is the multi-turn mechanism already implemented in `TaskRunner.ContinueAsync` — this spec only wires it into the UI.
- `Reset` is new end-to-end (hub method, worktree discard, task status reset).
- Run history (`task_runs` rows) is **preserved** across a Reset for audit.
- Out of scope: Continue/Reset on `Done` tasks, undo of Reset, modifying the follow-up prompt before sending.
## UX
Both buttons live in `DetailsIslandView`, inside a new horizontal button row that is visible only when the currently selected task is `Failed`.
### Continue
- One-click. Sends the canned prompt `"Continue working on this task."` via `WorkerHub.ContinueTask(taskId, prompt)`.
- Enabled **only if** the task's latest `TaskRunEntity` has a non-null `SessionId`.
- When disabled, a tooltip reads `No session to resume`.
- No confirmation dialog.
### Reset
- Always enabled when the task is `Failed`.
- Opens a confirmation dialog:
> Discard worktree and reset task?
> This deletes branch `claudedo/<id>` and all uncommitted changes.
- On confirm, calls `WorkerHub.ResetTask(taskId)`.
## Backend
### New hub method — `WorkerHub.ResetTask(string taskId)`
Preconditions:
- Task exists.
- Task status is **not** `Running`. If it is, throw — resetting a task that is actively executing would race with the runner.
Steps:
1. Load the task and its worktree (if any).
2. If a worktree exists and its `State == Active`, call `WorktreeManager.DiscardAsync(worktree, ct)` (see below).
3. Call `TaskRepository.ResetToManualAsync(taskId, ct)` to clear the result fields and flip the status.
4. Broadcast `TaskUpdated(taskId)`; broadcast `WorktreeUpdated(taskId)` if the worktree state changed.
If `WorktreeManager.DiscardAsync` throws (e.g. folder locked, branch checked out elsewhere), the hub method surfaces the error to the caller and leaves the task as `Failed` with the worktree still `Active`, so the user can retry. `TaskRepository.ResetToManualAsync` is **not** called in the failure path.
### New — `WorktreeManager.DiscardAsync(WorktreeEntity wt, CancellationToken ct)`
Shape mirrors the existing `CommitIfChangedAsync`. Steps:
1. `git worktree remove --force <wt.Path>` via `GitService`. The `--force` flag drops any uncommitted changes — expected, since the user already confirmed.
2. `git branch -D <wt.BranchName>` via `GitService`.
3. Update `WorktreeRepository`: set `State = Discarded`.
`GitService` gains two thin wrappers if they do not already exist: `WorktreeRemoveAsync(path, force: true)` and `BranchDeleteForceAsync(branch)`.
### New — `TaskRepository.ResetToManualAsync(string taskId, CancellationToken ct)`
Single UPDATE that sets:
- `Status = Manual`
- `Result = null`
- `StartedAt = null`
- `FinishedAt = null`
`LogPath` and the `task_runs` rows are left intact — they are the audit trail.
### Continue wiring
No backend changes. The UI calls `WorkerHub.ContinueTask(taskId, prompt)` and `TaskRunner.ContinueAsync` handles the rest.
## UI
### `DetailsIslandViewModel`
New members:
- `[ObservableProperty] bool showFailedActions` — true when the selected task's status is `Failed`.
- `[ObservableProperty] bool canContinue` — true when `showFailedActions` **and** the latest run of the selected task has a non-null `SessionId`.
- `[RelayCommand(CanExecute = nameof(CanContinue))] Task ContinueAsync()` — calls `HubClient.ContinueTask(task.Id, "Continue working on this task.")`.
- `[RelayCommand(CanExecute = nameof(ShowFailedActions))] Task ResetAsync()` — opens confirmation; on confirm, calls `HubClient.ResetTask(task.Id)`.
`ShowFailedActions` and `CanContinue` recompute whenever the selected task or its runs change (subscribe to the existing selection / task-updated signals).
### `DetailsIslandView.axaml`
A single `StackPanel` (orientation horizontal) inside the existing details layout, bound to `ShowFailedActions` for visibility, with two `Button`s wired to the commands.
### Confirmation dialog
Reuse the existing modal pattern (see `WorktreeModalView` for the shape). A minimal `ConfirmDialog` with title, body, `Cancel` + `Confirm` buttons is acceptable and reusable; if a simpler inline approach is idiomatic in this codebase, use that instead.
### `HubClient`
Add `Task ResetTask(string taskId)` alongside the existing `ContinueTask` wrapper.
## Error handling
| Failure | Behaviour |
|---|---|
| `ResetTask` called on a `Running` task | Hub throws; UI shows the error. The Reset button is CanExecute-gated anyway, so this is a defensive check. |
| `git worktree remove` fails | Hub throws; task stays `Failed`, worktree stays `Active`, user can retry or clean up manually. |
| `git branch -D` fails after worktree removal succeeded | Worktree state still gets set to `Discarded` (the folder is gone; leaving the branch dangling is less bad than leaving the DB out of sync). Log a warning. |
| `Continue` with no session_id | Button is disabled — the call cannot happen from the UI. Hub still guards with the existing `InvalidOperationException` in `ContinueAsync` for safety. |
## Testing
Integration tests (real SQLite, real git) in `ClaudeDo.Worker.Tests`:
1. **`WorktreeManager_DiscardAsync_removes_worktree_and_branch`** — create a worktree, call Discard, assert branch is gone from `git branch --list`, folder is gone, DB state is `Discarded`.
2. **`TaskRepository_ResetToManualAsync_clears_result_fields`** — seed a Failed task with Result/FinishedAt/StartedAt, call Reset, assert all cleared and status is Manual.
3. **`ResetTask_full_flow`** — seed a Failed task with an Active worktree and run history; invoke the hub method; assert status=Manual, worktree=Discarded, `task_runs` rows still present.
4. **`ResetTask_rejects_running_task`** — seed a Running task, assert the hub method throws and nothing is modified.
5. **`ResetTask_worktree_remove_failure_leaves_task_failed`** — simulate a git failure (e.g. lock the folder), assert task stays Failed and worktree stays Active.
No new UI tests — the commands are thin forwarders and are exercised manually.
## Open questions
None.

View File

@@ -0,0 +1,132 @@
# Settings Modal — Design
**Date:** 2026-04-21
**Status:** Approved for planning
## Goal
Add a general-settings modal reachable from the **⋯** button in the user footer of the Lists island (`ListsIslandView.axaml:68`). The modal exposes app-wide defaults for Claude runs, worktree behavior, and maintenance actions (cleanup / force-remove worktrees), plus a read-only "About" section with paths.
## Scope
**In scope**
- Claude defaults: instructions, model, max turns, permission mode
- Worktree defaults: strategy, central root, auto-cleanup toggle + days
- Worktree maintenance actions: cleanup finished, force-remove all
- About section: version, data/logs/config paths with "Open in Explorer"
- Single settings row persisted in SQLite
- SignalR surface for read/update + maintenance
- `ClaudeArgsBuilder` merge behavior for per-task overrides
**Out of scope**
- Worker-side infrastructure settings (hub URL, auto-start worker) — stays in `worker.config.json`
- Per-task "inherit defaults" toggle (always inherit; task values override per rule below)
- Any UI-layer tests (project has none today)
## Architecture
### Persistence
New single-row entity `AppSettingsEntity` (Id = 1) in SQLite. Access via new `AppSettingsRepository` with `GetAsync` / `UpdateAsync`. Seeded by a new EF migration `AddAppSettings`.
Fields:
| Column | Type | Seed default |
|---|---|---|
| `DefaultClaudeInstructions` | text | `""` |
| `DefaultModel` | string | `sonnet` |
| `DefaultMaxTurns` | int | `100` |
| `DefaultPermissionMode` | string | `acceptEdits` |
| `WorktreeStrategy` | string | `sibling` |
| `CentralWorktreeRoot` | string? | `null` |
| `WorktreeAutoCleanupEnabled` | bool | `false` |
| `WorktreeAutoCleanupDays` | int | `7` |
Rationale for DB over `worker.config.json`: transactional writes from the UI, no file-watcher dance, and the Worker already uses `IDbContextFactory<ClaudeDoDbContext>`.
### Merge rules for per-task overrides
`ClaudeArgsBuilder` gains a dependency on `AppSettingsRepository` and merges at build time:
- **Instructions:** `global + "\n\n" + task` (skip separator if either side empty)
- **Model / MaxTurns / PermissionMode:** `task ?? global` (task value wins when set)
No `TaskEntity` schema change.
### SignalR surface (new hub methods)
| Method | Returns | Notes |
|---|---|---|
| `GetAppSettings()` | `AppSettingsDto` | Single row |
| `UpdateAppSettings(dto)` | void | Full-row replace |
| `CleanupFinishedWorktrees()` | `int removedCount` | Skips Active |
| `ResetAllWorktrees()` | `{ removed, tasksAffected }` | Fails if any task is Running |
Maintenance logic lives in a new `WorktreeMaintenanceService` in the Worker; the hub stays thin. Service uses existing `GitService` + `WorktreeRepository`.
**Running-task guard:** `ResetAllWorktrees()` checks for any `Running` tasks before touching anything. If present, returns an error — the modal surfaces *"Cannot force-remove: N task(s) still running. Cancel them first."*
Affected worktrees after force-remove are marked `Discarded` in `WorktreeRepository`.
## UI
### Entry point
`ListsIslandView.axaml:68` ⋯ button binds to a new `OpenSettingsCommand` on `IslandsShellViewModel`. Command resolves `SettingsModalViewModel` and shows `SettingsModalView` via the existing modal pattern (`TaskCompletionSource<bool>` on save/cancel — same as `WorktreeModalView` / `DiffModalView`).
### Layout
Single scrollable modal, ~560 px wide, matches existing modal chrome (header eyebrow, monospace labels, close affordance). No tabs.
**Sections (top to bottom):**
1. **CLAUDE DEFAULTS** — instructions textarea (6 lines), model picker, max-turns numeric, permission-mode picker
2. **WORKTREES** — strategy picker, central-root folder picker, auto-cleanup toggle + days, then the two maintenance buttons
3. **ABOUT** — version (read-only), data folder / logs folder / worker.config path, each with "Open in Explorer" icon button
Footer: `[ Cancel ]` `[ Save ]`, right-aligned. Save button disabled while the form is invalid.
### Destructive action UX
- **Cleanup finished** — single click, inline result line under the button (`"Removed 3 worktree(s)."`), auto-clears ~4 s
- **Force-remove all** — click reveals an inline confirm row: *"Remove ALL N worktrees? Uncommitted work will be lost."* with red `Remove All` and neutral `Cancel`. Two-click confirm, no typed string (matches the delete-task confirm already in the app)
Both run against the worker over SignalR and leave the modal open on completion.
### Open-in-Explorer
Uses `Process.Start("explorer.exe", path)`. Windows-only is acceptable (app ships Windows-only via WPF installer).
### Validation
Modal-side only, block `Save`:
- Max turns: integer 1200
- Auto-cleanup days: integer 1365 (required only when toggle is on)
- Central root: required when Strategy = Central; must be an existing directory
- Instructions: no length cap
Invalid fields get a red eyebrow with the specific error text.
## Testing (ClaudeDo.Worker.Tests)
- **`AppSettingsRepositoryTests`** — round-trip `Get` / `Update` on real SQLite
- **`ClaudeArgsBuilderTests`** — four merge cases: both empty, only global, only task, both set (prepend + separator behavior)
- **`WorktreeMaintenanceServiceTests`** — real git worktree fixtures:
- cleanup skips `Active`, removes `Merged` / `Discarded` / `Kept`
- force-remove fails while any task is `Running`
- force-remove succeeds otherwise and flips affected worktrees to `Discarded`
No UI-layer tests (project has none today).
## Build order (high level)
1. Data: entity + configuration + migration + repository
2. Worker: `WorktreeMaintenanceService` + `ClaudeArgsBuilder` wiring + hub methods + DTO
3. UI: SignalR client methods on `WorkerClient`, `SettingsModalViewModel`, `SettingsModalView`
4. Wire ⋯ button on `ListsIslandView``IslandsShellViewModel.OpenSettingsCommand`
5. Tests (Worker.Tests)
Detailed step-by-step sequencing belongs in the implementation plan, not here.

View File

@@ -0,0 +1,141 @@
# Stream Formatter Rewrite — Design
**Date:** 2026-04-21
**Scope:** `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
## Problem
`StreamLineFormatter` converts Claude CLI stream-json lines into human-readable
text for the Details pane. The current implementation only recognizes:
- `type=stream_event` — dead code (requires `--include-partial-messages`, which
the Worker does not pass)
- `type=result` — shown as `--- Result ---` block
- `type=system` with `subtype=api_retry`
Everything else — notably `assistant` and `user` messages that carry the actual
conversation and tool activity — falls through to `default: return null` and is
silently dropped. The Details pane is therefore mostly empty during a run,
while the raw `.log` file retains the full JSON.
## Goal
Rewrite the formatter so every meaningful message type is rendered as one or
more compact text lines suitable for the live log in the Details pane. The
public API (`FormatLine(string)` / `FormatFile(string)`) and the existing
buffer/trim behavior in `DetailsIslandViewModel` stay the same.
## Input format
The Worker invokes the Claude CLI with:
```
claude -p --output-format stream-json --verbose --dangerously-skip-permissions ...
```
Each stdout line is one complete SDK message. Top-level shapes relevant to the
formatter:
```jsonc
// Session start
{"type":"system","subtype":"init","session_id":"…","model":"claude-…", }
// API retry notification
{"type":"system","subtype":"api_retry", }
// Assistant reply (text + tool calls)
{"type":"assistant","message":{"role":"assistant","content":[
{"type":"text","text":"…"},
{"type":"tool_use","id":"toolu_…","name":"Read","input":{"file_path":"…"}}
]}, }
// Tool result fed back to the model
{"type":"user","message":{"role":"user","content":[
{"tool_use_id":"toolu_…","type":"tool_result","content":"… or [ {type,text} ] …","is_error":false}
]}, "tool_use_result":{optional rich payload}, }
// Final result
{"type":"result","result":"…", }
```
Notes on quirks already observed in captured output:
- `tool_result.content` is sometimes a plain string, sometimes an array of
`{type:"text", text:"…"}` blocks. Handle both.
- The envelope may include `tool_use_result.file.numLines` / `file.filePath`
for Read-style results.
- Assistant messages may contain `thinking` blocks (filtered, not displayed).
## Output format
One line per logical event. A trailing `\n` ends each line so the
`DetailsIslandViewModel` buffer splits cleanly.
| Input | Output |
|---|---|
| `system` / `init` | `[session <id8> · <model>]\n` |
| `system` / `api_retry` | `[Retrying API call...]\n` |
| `system` / other | `null` (filtered) |
| `assistant` text block | `<text>\n` (raw) |
| `assistant` tool_use block | `[<ToolLabel>] <arg>\n` (see below) |
| `assistant` thinking block | `null` (filtered) |
| `user` tool_result block | `→ <summary>\n` (see below) |
| `result` | `\n--- Result ---\n<text>\n` |
| unrecognized / parse failure | raw line (existing behavior for non-JSON) |
A single `assistant` message with N content blocks produces N output lines,
concatenated into one return string.
### Tool label + arg
Pick the most identifying input field per tool:
| Tool name | Display |
|---|---|
| `Read`, `Write`, `Edit`, `NotebookEdit` | `[<Tool>] <basename(file_path)>` |
| `Bash`, `PowerShell` | `[Bash] $ <command>` — truncate command at 120 chars, append `…` |
| `Grep` | `[Grep] "<pattern>"` |
| `Glob` | `[Glob] <pattern>` |
| `Task`, `Agent` | `[Task: <subagent_type>] <description>` (description truncated to 120) |
| `WebFetch` | `[WebFetch] <url>` |
| `WebSearch` | `[WebSearch] "<query>"` |
| `TodoWrite` | `[TodoWrite]` (no arg) |
| fallback | `[<name>]` |
Missing or empty input fields → emit the label only, no trailing text.
### tool_result summary
For each `tool_result` block in a `user` message, in priority order:
1. `is_error == true``→ error: <first non-empty line, trimmed, ≤120 chars>`
2. Envelope has `tool_use_result.file.numLines``→ <N> lines`
3. Content resolves to empty/whitespace string → `→ ok`
4. Otherwise → `→ <first non-empty line, ≤120 chars>` (append `…` if truncated)
Content resolution: if `content` is a string, use it; if it's an array, join
the `text` fields of `{type:"text"}` entries.
## Non-goals
- No changes to `DetailsIslandViewModel` or the Worker pipeline.
- No collapsible/rich rendering — tool results stay one-liners.
- No persistence changes — the raw `.log` file still contains full JSON for
debugging.
- No unit tests in this change (separate workload).
## Out of scope
- Partial-token streaming (`--include-partial-messages`). The existing
`stream_event` branch is removed as dead code.
- Structured output / `--json-schema` rendering beyond the final `result`.
## Risks / edge cases
- **Unknown tool names** — fallback label `[<name>]` keeps output readable.
- **Malformed JSON inside a valid envelope** (e.g. missing `message.content`)
— skip the broken block, emit what we can; never throw.
- **Very long Bash commands or search queries** — 120-char truncation with `…`
keeps lines reasonable while preserving the prefix.
- **Binary or huge tool_result content** — summary rules 24 cap output at a
single line; full content stays in the raw log.

View File

@@ -0,0 +1,162 @@
# Design: Agent settings per list and per task (UI reimplementation)
Date: 2026-04-22
Status: Approved by user, implementation pending
## Problem
During the recent UI rework, the editors for per-list and per-task agent settings were lost. The data layer and Worker still support them (`TaskEntity.Model/SystemPrompt/AgentPath`, `ListConfigEntity`, `TaskRunner` + `ClaudeArgsBuilder`), but the UI has zero references to these fields. Users currently cannot set model, custom system prompt, or agent file from the app.
## Goal
Restore the ability to configure, per **list** and per **task**:
- `Model``opus` / `sonnet` / `haiku` / inherit
- `SystemPrompt` — free-text, appended to Claude's system prompt
- `AgentPath` — selection from agent files discovered by the Worker under `~/.todo-app/agents/*.md`
Per-task values override per-list values. Per-list values override global defaults from `worker.config.json`. This cascade is already implemented in `TaskRunner`.
## Non-goals
- Agent file CRUD in the UI (read-only picker only)
- `--allowedTools`, `--bare`, permission modes (deferred, matches existing worker design notes)
- Any schema migration — the DB already has the required columns/tables
## Approach
**Update-and-broadcast over SignalR.** UI never touches the DB directly; all writes go through new `WorkerHub` methods. Worker persists via repositories, then broadcasts `ListUpdated` / `TaskUpdated` so connected clients refresh.
## Sections
### 1. Data layer additions
New repository `src/ClaudeDo.Data/Repositories/ListConfigRepository.cs`:
- `GetByListIdAsync(string listId, CancellationToken) -> ListConfigEntity?`
- `UpsertAsync(ListConfigEntity, CancellationToken) -> ListConfigEntity`
- `DeleteAsync(string listId, CancellationToken) -> bool`
New method on `ListRepository`:
- `UpdateAsync(ListEntity, CancellationToken)` — updates `Name`, `WorkingDir`, `DefaultCommitType`. Included because the consolidated list-settings modal edits these alongside agent fields.
New method on `TaskRepository`:
- `UpdateAgentSettingsAsync(string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken) -> bool`
- `null` values mean "inherit" (column is nulled out in DB).
- Kept as a narrow method to avoid widening `UpdateAsync`.
DI: register `ListConfigRepository` alongside other repos.
No migration — all columns/tables already exist.
### 2. SignalR hub surface
New DTOs in `src/ClaudeDo.Data/Dtos/` (project existing DTO pattern):
- `UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType)`
- `UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath)`
- `UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath)`
New methods on `WorkerHub`:
- `UpdateList(UpdateListDto dto)` — calls `ListRepository.UpdateAsync`, then broadcasts `ListUpdated(listId)`.
- `UpdateListConfig(UpdateListConfigDto dto)` — upserts via `ListConfigRepository.UpsertAsync`, broadcasts `ListUpdated(listId)`. If all three fields are null, calls `DeleteAsync` instead so the row doesn't linger empty.
- `UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)` — calls `TaskRepository.UpdateAgentSettingsAsync`, broadcasts `TaskUpdated(taskId)` (existing event).
New broadcast method on `HubBroadcaster`:
- `Task ListUpdatedAsync(string listId) => _hub.Clients.All.SendAsync("ListUpdated", listId);`
Loader endpoint to add:
- `GetListConfig(string listId)` — returns `(string? Model, string? SystemPrompt, string? AgentPath)` record, or `null` if no row. Used by `ListSettingsModal` and by `DetailsIslandViewModel` for effective-value inheritance display. Existing `GetLists` / `GetTasks` already cover the rest.
### 3. UI — ListSettingsModal
New files:
- `src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml` + `.axaml.cs`
- `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs`
Entry points:
- **Right-click** on a list row in `ListsIslandView``ContextMenu` with "Settings…" item
- **Gear button** on the list row (visible on hover/selected)
Layout: vertical stack with two grouped sections.
**General**
- `Name` — TextBox (required, non-empty)
- `Working directory` — TextBox + "Browse…" button (folder picker)
- `Default commit type` — ComboBox populated with `chore, feat, fix, refactor, docs, test, ci, perf, style, build`
**Agent**
- `Model` — ComboBox: `(default)`, `sonnet`, `opus`, `haiku` (selecting `(default)` sends `null`)
- `System prompt` — multi-line TextBox with 4-row min height; empty = `null`
- `Agent file` — ComboBox populated via `WorkerClient.GetAgentsAsync()`, first item `(none)`; tooltip shows each agent's `Description`. Empty selection = `null`.
- `Reset agent settings` button — clears Model/SystemPrompt/AgentPath in the form (save then sends null triple → backend `DeleteAsync`).
Commands:
- `SaveCommand` — validates, calls `UpdateList` then `UpdateListConfig`, closes modal on success.
- `CancelCommand` — closes without saving.
Loading: on open, ViewModel calls `GetListConfig` and populates fields; missing row means all three agent fields start empty.
ViewModel uses `[ObservableProperty]` / `[RelayCommand]` per project convention.
### 4. UI — DetailsIsland per-task agent section
Modify `DetailsIslandView.axaml` + `DetailsIslandViewModel.cs`.
Add an `Expander` titled **"Agent settings (overrides)"**, collapsed by default, below the existing task detail content.
Fields (same control types as ListSettingsModal's Agent section):
- `Model` — ComboBox prepended with `(inherit: <effective>)` option as the unset state
- `System prompt` — TextBox with watermark showing effective inherited value when empty
- `Agent file` — ComboBox prepended with `(inherit: <effective>)`
Effective-value computation:
- Server-side would be more accurate but requires a new hub call. For v1, UI computes locally: if task field is null, show the list's config value; if that's also null, show `(global default)`.
- `DetailsIslandViewModel` already has access to the selected `TaskDto` + list; add list-config loading when task selection changes.
Persistence: auto-save on field change (debounced 300ms) calling `UpdateTaskAgentSettings`. No separate Save button — matches "settings" feel.
If the task is currently `Running`, fields are **read-only** (disabling controls). Agent settings only apply to the next invocation.
### 5. Testing
xUnit integration tests in `tests/ClaudeDo.Worker.Tests` against a real SQLite temp DB:
- `ListConfigRepositoryTests`
- `UpsertAsync_InsertsWhenAbsent`
- `UpsertAsync_UpdatesWhenPresent`
- `DeleteAsync_RemovesRow`
- `GetByListIdAsync_ReturnsNullWhenAbsent`
- `ListRepositoryTests.UpdateAsync_UpdatesMutableFields`
- `TaskRepositoryTests.UpdateAgentSettingsAsync_NullsClearColumns`
- `WorkerHubTests` (if present pattern; otherwise via direct service call):
- `UpdateListConfig_AllNull_DeletesRow`
- `UpdateTaskAgentSettings_PersistsAndBroadcasts`
No UI tests — project has no UI test project. Build-time compile check is the only UI gate.
## Manual verification checklist
1. Open app, right-click a list → "Settings…" opens modal with correct current values.
2. Change model to `opus`, save, reopen → model persists.
3. Set system prompt on list, create task in list, run it → log confirms `--append-system-prompt` was passed.
4. Select task, set per-task Model = `haiku`, run → log confirms `--model haiku` overrides list value.
5. Unset per-task Model → effective falls back to list's model.
6. Click "Reset agent settings" on list → row removed, tasks fall back to global defaults.
7. Running task: DetailsIsland agent fields disabled.
## Risks / open questions
- **Refresh propagation**: `ListUpdated` is a new event; `IslandsShellViewModel` must subscribe and re-fetch. Any missed subscriber means stale UI. Mitigated by following the existing `TaskUpdated` pattern exactly.
- **Working-dir browser**: Avalonia folder picker API needs a `TopLevel`; pass via `StorageProvider`. Standard pattern in Avalonia 12.
- **Conventional-commit-type list**: hardcoded in ComboBox — acceptable, matches existing `CommitType` defaults.

View File

@@ -0,0 +1,208 @@
# Worktree merge into target branch — design
Date: 2026-04-22
Status: Approved (pending user review)
## Problem
`WorktreeState.Merged` exists but nothing sets it. `GitService.MergeFfOnlyAsync` exists but is unused. `DetailsIslandViewModel.ApproveMergeAsync` is a stub (`// TODO: call worker merge hub method when available`). Users have no way to merge a task's worktree back into a target branch; the only post-task options today are Discard (via Reset) or leave it Active.
## Goals
- Allow merging a task worktree's `claudedo/{id}` branch into a chosen local branch of the list's `WorkingDir`.
- Preserve merge history via a real merge commit.
- Never leave the target branch in a broken state.
- Reuse existing patterns: `TaskResetService`, maintenance sweeper, dialog factory.
## Non-goals
- Remote push after merge (user does this manually).
- Pull/fetch before merge.
- Rebasing the task branch onto a moved target (done via Continue prompt or manually).
- Merging across repos or handling submodules.
- Automated UI tests (project has none).
## Decisions
| Decision | Choice |
| --- | --- |
| Target branch | Default to current `HEAD` branch of `WorkingDir`; user may override via dropdown. |
| Merge strategy | Always `git merge --no-ff -m <msg> claudedo/{id}` — explicit merge commit. |
| Post-merge cleanup | Per-merge checkbox in the dialog, default on: remove worktree dir + delete branch. |
| Conflicts | Pre-flight guard (worktree/branch state checks); on conflict during merge, `git merge --abort` and return conflicted files to UI. |
| UI entry points | Details island agent strip (wires existing stub) **and** DiffModal "Merge" button — both open the same modal. |
## Architecture
### New backend service
`src/ClaudeDo.Worker/Services/TaskMergeService.cs` — mirrors `TaskResetService`.
```
public sealed class TaskMergeService
{
Task<MergeResult> MergeAsync(
string taskId,
string targetBranch,
bool removeWorktree,
string commitMessage,
CancellationToken ct);
Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct);
}
public sealed record MergeResult(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
// Status ∈ "merged" | "conflict" | "blocked"
public sealed record MergeTargets(string DefaultBranch, IReadOnlyList<string> LocalBranches);
```
Pre-flight checks (all must pass):
1. Task exists, status not `Running`.
2. Worktree exists, state == `Active`.
3. `list.WorkingDir` is set and is a git repo.
4. Target working tree is clean (`HasChangesAsync == false`).
5. Target repo is not mid-merge (`IsMidMergeAsync == false`).
Failures short-circuit to `MergeResult("blocked", [], reason)` before any git write.
Success path:
1. `GitService.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct)`.
2. If `removeWorktree`:
- `WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: false, ct)` — reuse existing method.
- `BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: false, ct)`.
3. `WorktreeRepository.SetStateAsync(taskId, WorktreeState.Merged, ct)`.
4. `HubBroadcaster.BroadcastWorktreeUpdated(taskId)`.
5. Log info; return `MergeResult("merged", [], null)`.
Conflict path (merge invoked, git returns non-zero with `CONFLICT` on stderr/stdout):
1. Collect conflicted files: `git diff --name-only --diff-filter=U`.
2. `GitService.MergeAbortAsync(list.WorkingDir, ct)`.
3. Worktree state stays `Active`; no broadcast (nothing changed).
4. Return `MergeResult("conflict", files, null)`.
### GitService additions
```
Task<string> GetCurrentBranchAsync(string repoDir, CancellationToken ct); // git symbolic-ref --short HEAD
Task<List<string>> ListLocalBranchesAsync(string repoDir, CancellationToken ct); // git branch --format=%(refname:short)
Task MergeNoFfAsync(string repoDir, string sourceBranch, string message, CancellationToken ct); // git merge --no-ff -m <msg> <src>
Task MergeAbortAsync(string repoDir, CancellationToken ct); // git merge --abort
Task<bool> IsMidMergeAsync(string repoDir, CancellationToken ct); // File.Exists($"{gitDir}/MERGE_HEAD")
Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct); // git diff --name-only --diff-filter=U
```
`MergeNoFfAsync` must NOT throw on non-zero — it must return the exit code/stderr so the caller can distinguish conflict from other failures. Two ways:
- Overload to return `(int ExitCode, string Stderr)`; or
- Throw a dedicated `GitMergeConflictException` vs `InvalidOperationException`.
**Pick:** expose a tuple-returning variant for `MergeNoFfAsync` only — keeps other methods consistent, avoids exception-for-control-flow.
### Hub surface
`src/ClaudeDo.Worker/Hub/WorkerHub.cs` gains:
```
Task<MergeResultDto> MergeTask(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
Task<MergeTargetsDto> GetMergeTargets(string taskId);
```
DTOs mirror the service records. Unexpected exceptions are re-wrapped as `HubException` (same pattern as `ResetTask`). Expected conditions (blocked, conflict) travel via the result DTO, not exceptions.
### WorkerClient
`src/ClaudeDo.Ui/Services/WorkerClient.cs`:
```
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
Task<MergeTargetsDto> GetMergeTargetsAsync(string taskId);
```
### UI
**New modal:** `src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml` + `MergeModalViewModel.cs`.
Dialog fields:
- **Target branch** combobox (source: `GetMergeTargetsAsync.LocalBranches`; default: `DefaultBranch`).
- **Remove worktree after merge** checkbox (default: checked).
- **Commit message** text field (default: `Merge task: {Task.Title}`).
- OK / Cancel buttons.
Post-submit UI states (rendered inside the modal, not a second dialog):
- `merged` → brief success line, modal closes after 12s; parent refreshes.
- `conflict` → red inline panel listing files; OK button hidden, only Close remains.
- `blocked` → orange inline panel with the reason; OK button hidden, only Close remains.
**Wiring:**
- `DetailsIslandViewModel.ApproveMergeAsync` opens `MergeModalView` (factory injected through `MainWindowViewModel`'s existing dialog pattern).
- `DiffModalView` gains a Merge button in its command strip; click opens the same modal with the current task's id.
- Both entry points are only visible/enabled when `Task.Worktree?.State == Active` (same predicate as the existing Reset/Continue visibility logic — extend `ShowFailedActions`-style gating with a new flag `CanMerge`).
`MergeModalViewModel` depends only on `WorkerClient`. It does not touch `GitService` directly — all git access stays worker-side.
## Data flow
```
User clicks Merge (Details island or DiffModal)
→ DetailsIslandViewModel / DiffModalViewModel opens MergeModalView
→ MergeModalViewModel.InitializeAsync
→ WorkerClient.GetMergeTargetsAsync(taskId)
→ Hub.GetMergeTargets
→ TaskMergeService.GetTargetsAsync
→ GitService.GetCurrentBranchAsync + ListLocalBranchesAsync
→ Combobox populated, default selected
User edits fields, clicks OK
→ WorkerClient.MergeTaskAsync(taskId, branch, remove, msg)
→ Hub.MergeTask
→ TaskMergeService.MergeAsync
→ pre-flight checks
→ GitService.MergeNoFfAsync → (success | conflict)
→ on success: optional remove + branch delete, SetState(Merged), broadcast
→ on conflict: MergeAbortAsync, return conflict DTO
→ MergeModalViewModel renders result
```
## Error handling
| Case | Surfaced as |
| --- | --- |
| Task running | `MergeResult("blocked", [], "task is running")` |
| Worktree not Active | `("blocked", [], "worktree state is {state}")` |
| Working dir dirty | `("blocked", [], "target branch has uncommitted changes")` |
| Target mid-merge | `("blocked", [], "target branch is mid-merge")` |
| `list.WorkingDir` null | `("blocked", [], "list has no working directory")` |
| Merge conflict | `("conflict", [files], null)` — target auto-restored |
| Unknown git failure | `HubException` with stderr |
| Post-merge cleanup fails | Log a warning; merge already succeeded, state already `Merged`. Return `("merged", [], "cleanup: {reason}")``Status=="merged"` with a non-null `ErrorMessage` means the merge went through but the worktree couldn't be removed. UI surfaces this as a yellow note, not a failure. |
## Testing
`tests/ClaudeDo.Worker.Tests/TaskMergeServiceTests.cs` (real SQLite + real git, matching existing test conventions):
1. Happy path, ff-able history → one merge commit, state Merged, broadcast fired.
2. Happy path, diverged non-conflicting → merge commit created.
3. Conflict path → conflicted files returned, target branch HEAD matches pre-merge, `MERGE_HEAD` absent, worktree state still Active.
4. Pre-flight: worktree Merged/Discarded → blocked.
5. Pre-flight: dirty working tree → blocked.
6. Pre-flight: mid-merge target → blocked.
7. `removeWorktree=true` → worktree dir gone, branch deleted, state Merged.
8. `removeWorktree=false` → worktree + branch survive, state Merged.
9. Task Running → blocked.
`tests/ClaudeDo.Worker.Tests/GitServiceMergeTests.cs` (narrow tests for new GitService methods): `MergeNoFfAsync` success/conflict tuple semantics, `MergeAbortAsync` clears MERGE_HEAD, `IsMidMergeAsync` true/false, `ListLocalBranchesAsync` returns expected set, `GetCurrentBranchAsync` on fresh repo.
Manual UI checklist captured in the implementation plan, not automated.
## Implementation order (sketch)
1. GitService additions + their tests.
2. `TaskMergeService` + its tests (hub/UI not yet wired).
3. Hub methods + `WorkerClient` methods.
4. `MergeModalView` + `MergeModalViewModel`.
5. Wire `DetailsIslandViewModel.ApproveMergeAsync`.
6. Wire DiffModal Merge button.
7. Manual UI walkthrough against the checklist.
## Open items
None.

View File

@@ -0,0 +1,177 @@
# Default Agents — Design
**Date:** 2026-04-23
**Status:** Approved
## Goal
Ship ClaudeDo with a curated set of default agents so that users have useful agents available on first launch, without losing the file-based ownership model (user-editable, user-deletable). Provide a "Restore defaults" action to recover missing defaults on demand.
## Agents to Ship
Six markdown agents covering the common stages of task execution plus one general-purpose agent:
| File | Focus |
|---|---|
| `code-reviewer.md` | Review diff for bugs, logic errors, convention adherence. Flags only high-confidence issues. |
| `test-writer.md` | Generate unit/integration tests for changed code. Follows existing test patterns. |
| `debugger.md` | Systematic root-cause analysis — reproduce, isolate, hypothesize, verify. |
| `security-reviewer.md` | OWASP-style audit focused on auth, SQL injection, input handling, secret exposure. |
| `explorer.md` | Fast codebase navigation and answering "where/how" questions. Terse output. |
| `researcher.md` | General-purpose research, doc summarization, analysis, investigation. Non-code. |
Each file uses Claude Code's standard agent frontmatter:
```markdown
---
name: <agent name>
description: <one-line description>
---
<system prompt body>
```
Content target: ~2040 lines per file. Style matches the existing Claude Code agent conventions.
## Behavior
**Seed on first launch, restore on demand:**
1. Bundled agents live alongside the Worker binary at `<AppContext.BaseDirectory>/DefaultAgents/*.md`.
2. At Worker startup, for each bundled file: if `~/.todo-app/agents/<name>.md` does NOT exist, copy it in. If it exists, leave it alone — the user owns their copy.
3. A "Restore default agents" button in the settings modal re-runs the same check, restoring any that the user has deleted.
The seed path and the restore path are the same code — only the invocation differs (startup vs. hub call).
## Components
### `DefaultAgents/*.md` (bundled content)
Location: `src/ClaudeDo.Worker/DefaultAgents/`
Packaging: `<Content Include="DefaultAgents\*.md" CopyToOutputDirectory="PreserveNewest" />` in `ClaudeDo.Worker.csproj`.
At runtime they land at `<AppContext.BaseDirectory>/DefaultAgents/*.md` next to the executable.
### `DefaultAgentSeeder` (new service)
Location: `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
```
public sealed class DefaultAgentSeeder
{
public DefaultAgentSeeder(string bundleDir, string targetDir);
public Task<SeedResult> SeedMissingAsync(CancellationToken ct = default);
}
public sealed record SeedResult(int Copied, int Skipped);
```
Behavior of `SeedMissingAsync`:
- If `bundleDir` doesn't exist, log warning and return `(0, 0)`.
- Enumerate `*.md` in `bundleDir`.
- For each file, if the target path (`targetDir/<filename>`) is missing, copy it; else increment `Skipped`.
- Create `targetDir` if missing (consistent with existing `AgentFileService.WriteAsync`).
- Per-file exceptions are caught and logged; the seeder continues with the next file. The method itself does not throw for individual file failures.
### `Program.cs` wiring
After `AgentFileService` registration and before `app.Run()`:
```csharp
var bundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
var seeder = new DefaultAgentSeeder(bundleDir, agentsDir);
await seeder.SeedMissingAsync();
builder.Services.AddSingleton(seeder);
```
The seeder is also registered as a singleton so the hub can invoke it for the restore flow.
### `WorkerHub.RestoreDefaultAgents`
Location: add method to existing `src/ClaudeDo.Worker/Hub/WorkerHub.cs`.
Signature: `public async Task<SeedResult> RestoreDefaultAgents()`
Behavior:
- Calls `DefaultAgentSeeder.SeedMissingAsync()`.
- Returns the `SeedResult` to the caller.
- No separate broadcast — the UI will call `GetAgents` after the restore returns, reusing the existing refresh path.
### UI — Settings Modal
Location: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` + `SettingsModalViewModel`.
Add a "Restore default agents" button to the modal. On click:
1. Disable the button, show a spinner label.
2. Call `WorkerClient.RestoreDefaultAgentsAsync()`.
3. Show a brief inline confirmation: `"Restored {Copied} agent(s)"` or `"All defaults already present"`.
4. Trigger the existing agent list refresh so the new files appear immediately in the rest of the UI.
### `WorkerClient` method
Add `Task<SeedResult> RestoreDefaultAgentsAsync(CancellationToken ct = default)` to `WorkerClient` — thin wrapper that invokes the hub method.
## Data Flow
**Startup:**
```
Worker starts → DefaultAgentSeeder.SeedMissingAsync()
→ copies missing files into ~/.todo-app/agents/
AgentFileService.ScanAsync() (on first GetAgents call) → sees the seeded files
```
**User restores:**
```
Settings modal button click
→ WorkerClient.RestoreDefaultAgentsAsync()
→ WorkerHub.RestoreDefaultAgents()
→ DefaultAgentSeeder.SeedMissingAsync()
→ returns SeedResult(copied, skipped)
UI shows confirmation, triggers GetAgents refresh
```
## Error Handling
| Failure | Behavior |
|---|---|
| Missing `DefaultAgents/` bundle dir | Log warning, return `(0, 0)`. Startup proceeds. |
| Individual file copy failure (disk, permissions) | Catch per-file, log, continue with the remaining files. |
| Corrupt bundled markdown (no valid frontmatter) | Copied anyway — the `AgentFileService` frontmatter parser already falls back to filename-as-name. |
| Startup seeder exception (unexpected) | Log as warning, do not crash the Worker. Agents can still be restored via the button. |
| Hub `RestoreDefaultAgents` exception | Propagate to client as SignalR error; UI shows a generic "Restore failed" message. |
## Testing
**Unit:** `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
- Seeds all files when target dir is empty.
- Skips files that already exist.
- Preserves existing user-modified files (file mtime / content unchanged).
- Returns accurate `SeedResult` counts.
- Handles missing bundle dir gracefully (returns `(0, 0)`, no throw).
- Creates target dir if it doesn't exist.
**Integration:** extend `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`
- `RestoreDefaultAgents` invokes the seeder and returns the count.
**No UI tests.** The project has no UI test harness; settings modal behavior is exercised manually.
## Build / Packaging
`src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` gains:
```xml
<ItemGroup>
<Content Include="DefaultAgents\*.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
```
No new NuGet dependencies.
## Out of Scope
- Editing bundled agents in-place (user edits their copy under `~/.todo-app/agents/`; bundle is read-only by convention).
- Versioning / updating user copies when the bundled version changes. If a bundled agent is improved in a later release, the user's copy is not overwritten. A future release may add a "diff / reset to bundled" flow, but not now.
- Packaging as embedded resources. Content files copied to output are simpler, inspectable on disk, and consistent with the file-based agent model.

View File

@@ -0,0 +1,468 @@
# Planning Sessions — Design
**Status:** Approved for implementation
**Date:** 2026-04-23
**Scope:** Feature — "Open planning Session" context menu on tasks that spawns an interactive Windows Terminal with Claude (Sonnet 4.6, medium thinking) and a scoped MCP server, letting the user brainstorm and have Claude break a rough task into concrete executable child-tasks.
---
## 1. Goal
Allow a user to take a vague task (a title plus some TODO-style notes) and convert it — via interactive dialogue with Claude in a terminal — into a structured set of concrete, executable child-tasks that the worker queue can pick up and run.
The interaction is driven by Claude calling MCP tools against a scoped server running inside the existing `ClaudeDo.Worker` process. The parent task becomes a "Planning" container that holds its children as a flat (single-level) hierarchy.
---
## 2. Status Flow
**Parent (new statuses `Planning`, `Planned`):**
```
Manual ──[Open planning Session]──▶ Planning ──[finalize]──▶ Planned
│ │
│ (all children reach terminal state)
│ ▼
│ Done
│ or
│ Failed (if any child Failed)
[Discard] ──▶ Manual
```
**Child (new status `Draft`):**
```
Draft ──[finalize]──▶ Manual | Queued (if "agent" tag) ──▶ Running ──▶ Done | Failed
```
**Rules:**
- Parent with status `Planning` or `Planned` is **never** picked up by the queue.
- Children with status `Draft` are **never** picked up by the queue.
- Hierarchy is strictly **one level deep**: a child task cannot itself become a planning parent (enforced app-side: Plan menu item hidden/disabled if `ParentTaskId IS NOT NULL`).
- One planning session per parent task at a time (`StartPlanningSessionAsync` errors if parent is already `Planning`; use Resume instead).
- Parent auto-status on child completion (evaluated after any child reaches `Done` or `Failed`):
- At least one child `Failed` and no children still in non-terminal states → Parent `Failed`.
- All children `Done` → Parent `Done`.
- Any child still `Manual`/`Queued`/`Running`/`Draft` → Parent stays `Planned`.
- Worktree state (`Merged`/`Discarded`/`Kept`) is orthogonal; only `Task.Status` determines completion.
---
## 3. Data Model
### 3.1 Schema changes to `Tasks` table
| Column | Type | Nullable | Purpose |
|---|---|---|---|
| `ParentTaskId` | `string` (FK → `Tasks.Id`, `DeleteBehavior.Restrict`) | yes | When set, row is a child of a planning parent. NULL = top-level task. |
| `PlanningSessionId` | `string` | yes | Claude CLI session ID captured after first run; used with `--resume`. Only set on planning parents. |
| `PlanningSessionToken` | `string` | yes | Random 32-byte Base64 token generated per session; acts as bearer for MCP calls. NULL when no active session. |
| `PlanningFinalizedAt` | `DateTime` | yes | Timestamp when `finalize` was called. NULL until finalized. |
Index: `(ParentTaskId)` for fast children lookup.
### 3.2 Status enum additions
`ClaudeDo.Data.Models.TaskStatus` gains:
- `Planning` — parent, session active or paused, drafts may exist.
- `Planned` — parent, finalized, children are real tasks (may still be running).
- `Draft` — child, created during session, not yet finalized.
Existing values unchanged: `Manual | Queued | Running | Done | Failed`. Persisted via `ValueConverter` to string (existing convention — confirmed via `TaskEntity.cs`).
### 3.3 Navigation properties
On `TaskEntity`:
```csharp
public string? ParentTaskId { get; set; }
public TaskEntity? Parent { get; set; }
public ICollection<TaskEntity> Children { get; set; } = new List<TaskEntity>();
public string? PlanningSessionId { get; set; }
public string? PlanningSessionToken { get; set; }
public DateTime? PlanningFinalizedAt { get; set; }
```
In `TaskEntityConfiguration`:
```csharp
.HasOne(t => t.Parent)
.WithMany(t => t.Children)
.HasForeignKey(t => t.ParentTaskId)
.OnDelete(DeleteBehavior.Restrict);
```
**Rationale for `Restrict`:** cascade delete would orphan worktrees of in-flight child tasks. UI must handle the `DbUpdateException` and prompt the user to discard children first.
### 3.4 Repository additions
`ITaskRepository` gains:
- `Task<IReadOnlyList<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct)`
- `Task<TaskEntity> CreateChildAsync(string parentId, string title, string? description, IReadOnlyList<string>? tagNames, string? commitType, CancellationToken ct)` — creates with `Status = Draft`, `ParentTaskId = parentId`.
- `Task<int> FinalizePlanningAsync(string parentId, bool queueAgentTasks, CancellationToken ct)` — transactional: all Drafts → `Manual` (or `Queued` if tagged "agent" and `queueAgentTasks=true`), parent → `Planned`, set `PlanningFinalizedAt`, clear `PlanningSessionToken`. Returns count of finalized children.
- `Task<bool> DiscardPlanningAsync(string parentId, CancellationToken ct)` — deletes all Drafts, parent → `Manual`, clears `PlanningSessionId/Token/FinalizedAt`.
- `Task<TaskEntity?> SetPlanningStartedAsync(string taskId, string sessionToken, CancellationToken ct)` — sets parent `Status = Planning`, stores token; returns null if parent not in `Manual` state.
- `Task UpdatePlanningSessionIdAsync(string parentId, string sessionId, CancellationToken ct)` — captures Claude CLI session ID after launch.
- `Task<TaskEntity?> FindByPlanningTokenAsync(string token, CancellationToken ct)` — used by MCP auth handler.
`GetNextQueuedAgentTaskAsync` — verify the existing query filters on `Status = Queued`; no additional filter needed since Planning/Planned/Draft are different statuses. Add explicit regression test.
### 3.5 Auto-status hook
After every `MarkDoneAsync`/`MarkFailedAsync` on a task with `ParentTaskId != null`, check parent children. If all in terminal state:
- Any `Failed` → parent `Failed` with `FinishedAt = now()`.
- All `Done` (or worktrees `Discarded`) → parent `Done` with `FinishedAt = now()`.
Implemented as a private helper `TryCompleteParentAsync(string parentId, CancellationToken ct)` called at the end of the two Mark methods.
### 3.6 Migration
`dotnet ef migrations add AddPlanningSupport` — adds four columns and the `(ParentTaskId)` index. No data migration needed (new columns all nullable).
---
## 4. MCP Server Surface
### 4.1 Transport
**HTTP (streamable) inside the existing Worker Kestrel host.** Mount on `/mcp` alongside the existing SignalR hub at `127.0.0.1:47821`. No separate process, no stdio proxy.
Library: `ModelContextProtocol` (official C# MCP SDK).
### 4.2 Authentication
Per-session bearer token:
1. `StartPlanningSessionAsync` generates a 32-byte random token, persists to `Tasks.PlanningSessionToken`.
2. Token is written into the session's `mcp.json` as `Authorization: Bearer <token>`.
3. Every MCP request passes through an auth filter that looks up the token via `FindByPlanningTokenAsync`. If found, the parent task ID is stored in the request context. If not, 401.
4. Token is invalidated (set NULL) on `finalize` or `discard`.
### 4.3 Tools
All tools are scoped to the parent task resolved from the request's token. `parent_id` is never an argument.
| Tool | Params | Returns | Effect |
|---|---|---|---|
| `create_child_task` | `title: string`, `description?: string`, `tags?: string[]`, `commit_type?: string` | `{ task_id, status: "Draft" }` | Creates a Draft child under this session's parent. |
| `list_child_tasks` | — | `[{ task_id, title, description, status, tags }]` | Lists children of this parent (in session context, always Drafts). |
| `update_child_task` | `task_id: string`, optional: `title`, `description`, `tags`, `commit_type` | `{ task }` | Errors if target is not a Draft or not a child of this parent. |
| `delete_child_task` | `task_id: string` | `{ ok: true }` | Errors if target is not a Draft or not a child of this parent. |
| `update_planning_task` | `title?: string`, `description?: string` | `{ task }` | Only title/description on the parent itself. |
| `finalize` | `queue_agent_tasks?: bool = true` | `{ finalized_count: int }` | Calls `FinalizePlanningAsync`. Token invalidated. |
### 4.4 Real-time UI
After each successful tool call, the MCP handler fires a `TaskUpdated` event on the Worker's SignalR hub. The UI subscribes as it already does; drafts appear/update live in the tasks list while the user chats with Claude in the terminal.
### 4.5 Errors
- 401 for missing/invalid token.
- MCP error `-32602` "task not found or not a child of this planning session" for cross-parent access attempts.
- MCP error `-32602` "cannot modify finalized task" for `update/delete` on non-Draft.
- Token validation short-circuits before tool dispatch.
---
## 5. Terminal Launch & Claude CLI Invocation
### 5.1 Launcher service
New interface `IPlanningTerminalLauncher` in the UI or App layer:
```csharp
Task LaunchAsync(PlanningSessionStart info, CancellationToken ct);
Task LaunchResumeAsync(PlanningSessionResume info, CancellationToken ct);
```
`PlanningSessionStart` contains: `WorkingDir`, `McpConfigPath`, `InitialPromptPath`, `SystemPromptPath`.
`PlanningSessionResume` contains: `WorkingDir`, `McpConfigPath`, `ClaudeSessionId`.
### 5.2 Per-session files
Path: `~/.todo-app/planning-sessions/<parentTaskId>/`
- `mcp.json` — MCP config referencing the HTTP endpoint with bearer token.
- `system-prompt.md` — planning-mode system prompt (append, not replace).
- `initial-prompt.txt` — first user-visible message (title + description + short instructions).
Cleanup:
- `Discard` → remove directory.
- `Finalize` → keep directory (for audit; prune on app start if older than N days, optional).
### 5.3 `mcp.json` format
```json
{
"mcpServers": {
"claudedo": {
"type": "http",
"url": "http://127.0.0.1:47821/mcp",
"headers": { "Authorization": "Bearer <PlanningSessionToken>" }
}
}
}
```
### 5.4 Claude CLI invocation (new session)
```
wt.exe -d "<list.WorkingDir>" cmd /k ^
claude ^
--model claude-sonnet-4-6 ^
--append-system-prompt "<contents of system-prompt.md>" ^
--mcp-config "<mcp.json absolute path>" ^
--allowedTools "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill" ^
"<contents of initial-prompt.txt>"
```
### 5.5 Claude CLI invocation (resume)
```
wt.exe -d "<list.WorkingDir>" cmd /k ^
claude --resume <PlanningSessionId> --mcp-config "<mcp.json>"
```
Resume inherits model, system prompt, and allowed tools from the original session.
### 5.6 System prompt (draft, refined in Plan B)
> You are in a ClaudeDo planning session for a task. Your job is to brainstorm with the user, then break their rough intent into concrete, independently-executable child-tasks. Each child-task should be something a single automated agent can pick up and complete autonomously. Use the `mcp__claudedo__*` tools to create/update/delete drafts in real time. You may read the repository for context (Read/Grep/Glob) but must not modify any files. When the user is satisfied, call `finalize`. Skills you may find useful: `superpowers:writing-plans`, `superpowers:writing-clearly-and-concisely`.
### 5.7 Initial prompt (template)
```
<Parent task title>
<Parent task description, if any>
---
We're planning this task together. Brainstorm with me, then create concrete child-tasks via the MCP tools. I'll call `finalize` when we're done.
```
### 5.8 Unknowns to resolve during Plan B implementation
These are left **open** in this spec; they'll be pinned down during implementation via `mcp__plugin_context7_context7__query-docs` for the Claude Code CLI:
1. Exact flag for thinking budget (`--thinking-budget medium`? model suffix `claude-sonnet-4-6-thinking`? something else?).
2. Exact casing of tool names in `--allowedTools` (`Read`/`read`, `WebFetch`/`web_fetch`, `Skill`).
3. Whether `--append-system-prompt` accepts a file reference (`@path`) or requires inline string.
4. Whether Claude CLI supports a `--session-id` flag for pre-assigning the session ID, or whether we must read it back from `~/.claude/projects/<hash>/sessions/` after the process starts.
If (4) resolves to "read back", strategy:
- Poll `~/.claude/projects/<hash>/sessions/` directory modtimes shortly after launch; newest session file after launch timestamp is ours.
- Cache the result on the parent task via `UpdatePlanningSessionIdAsync`.
- If session ID can't be captured, Resume falls back to `claude --continue` (last session in that project).
### 5.9 Pre-flight checks
On `LaunchAsync`:
- `wt.exe` resolvable in PATH → else throw `PlanningLaunchException("Windows Terminal not found")`, UI shows install hint.
- `claude` resolvable in PATH → else `PlanningLaunchException("Claude CLI not installed")`.
- `list.WorkingDir` exists → else `PlanningLaunchException("Working directory not found: <path>")`.
---
## 6. UI Changes
### 6.1 Context menu (`TaskRowView.axaml`)
New entries, conditional on status:
- `Manual` + `ParentTaskId IS NULL`**"Open planning Session"**
- `Planning`**"Resume planning Session"** and **"Discard planning session"**
- `Planned` / `Done` / `Failed` (parent) → no planning-related entries (7c: no re-planning).
- Children (`ParentTaskId IS NOT NULL`) → never show planning entries.
### 6.2 Hierarchy rendering (`TasksIslandView.axaml`)
Approach: **flat stream with indentation**, not a `TreeView`.
- `TasksIslandViewModel` builds `OpenItems`/`CompletedItems`/etc. as flat `ObservableCollection<TaskRowViewModel>` with parents followed by their children if expanded.
- `TaskRowViewModel` gets `IsChild: bool` and `IsPlanningParent: bool` and `IsExpanded: bool`.
- `TaskRowView` indents 24px when `IsChild`, shows a thin left border in `TextFaintBrush`.
- Parents with `IsPlanningParent` render a chevron (▸/▾) that toggles `IsExpanded`; collapsed parents hide their children from the flat stream.
- Expanded-state map kept in the VM (`Dictionary<string, bool>`, default `true`).
### 6.3 Draft and planning styling (`TaskRowView`)
- `Status = Draft` → row italic, 70% opacity, small left-aligned badge "DRAFT".
- Parent `Status = Planning` → badge "PLANNING" (accent: warning-amber).
- Parent `Status = Planned` → badge "PLANNED" (accent: neutral-blue).
### 6.4 Unfinished-session dialog
Trigger: on app start **and** on any context-menu click against a `Planning` parent.
Modal (built with existing `TaskCompletionSource<T>` dialog pattern):
```
Unfinished planning session
"<Parent title>"
<N> draft tasks waiting to be finalized.
[Resume] [Finalize now] [Discard]
```
- Resume → `ResumePlanningSessionAsync`, opens terminal with `--resume`.
- Finalize now → `FinalizePlanningSessionAsync` (server-side, no terminal). Useful when the user is confident drafts are good.
- Discard → `DiscardPlanningSessionAsync`.
### 6.5 TasksIslandViewModel commands
- `[RelayCommand] OpenPlanningSessionAsync(TaskRowViewModel? row)`
- `[RelayCommand] ResumePlanningSessionAsync(TaskRowViewModel? row)`
- `[RelayCommand] DiscardPlanningSessionAsync(TaskRowViewModel? row)`
- `[RelayCommand] FinalizePlanningSessionAsync(TaskRowViewModel? row)`
- `[RelayCommand] ToggleExpand(TaskRowViewModel parentRow)`
### 6.6 WorkerClient additions (`ClaudeDo.Ui/Services/WorkerClient.cs`)
- `Task<PlanningSessionLaunchInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct)` — returns `{ WorkingDir, McpConfigPath, InitialPromptPath, SystemPromptPath }`.
- `Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct)` — returns `{ WorkingDir, McpConfigPath, ClaudeSessionId }`.
- `Task<int> FinalizePlanningSessionAsync(string taskId, CancellationToken ct)` — returns finalized count.
- `Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct)`.
- `Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct)` — for the unfinished-session dialog.
Existing `TaskUpdated` event covers live draft updates; no new event needed.
### 6.7 Delete handling
When the user tries to delete a parent with children:
- Repository throws `DbUpdateException` (FK Restrict).
- UI catches, shows: "This task has N child tasks. Discard drafts and delete? / Delete all including children? / Cancel."
- "Delete all including children" → UI iterates children and deletes them first, then the parent.
- "Discard drafts" option only appears if parent status is `Planning` (drafts exist to discard).
---
## 7. Lifecycle & Error Handling
### 7.1 Worker queue isolation
`GetNextQueuedAgentTaskAsync` filters on `Status = Queued` — Planning/Planned/Draft are excluded by status. Add explicit regression test to lock this in.
### 7.2 Parent auto-completion (repeat of 2, for implementation reference)
After `MarkDoneAsync`/`MarkFailedAsync`:
```csharp
if (task.ParentTaskId is not null)
await TryCompleteParentAsync(task.ParentTaskId, ct);
```
where `TryCompleteParentAsync` loads children, checks terminal status, sets parent accordingly.
### 7.3 Session-start errors
Table in §5.9 Pre-flight checks. UI receives typed exceptions, shows appropriate dialog.
### 7.4 Session-runtime errors
- Terminal crashes → drafts + token persist. Resume via dialog (§6.4).
- Worker restart → drafts + token persist. Resume rebuilds HTTP connection.
- MCP call fails transiently → Claude CLI retries or the model reports the error to the user in terminal; drafts remain in whatever state the last successful call left them.
- No session timeout — brainstorming may be long.
### 7.5 Concurrency
- Different parents → independent sessions, one token per parent.
- Same parent launched twice → `StartPlanningSessionAsync` throws; UI says "Already planning; use Resume".
- Cleanup on app exit: nothing — planning state is fully persisted in DB and files.
---
## 8. Testing
### 8.1 Automated (in `ClaudeDo.Worker.Tests`)
**Schema & repository:**
- Migration applies cleanly on fresh DB.
- `GetChildrenAsync` returns only direct children, sorted.
- `CreateChildAsync` sets Status=Draft, ParentTaskId correctly.
- `FinalizePlanningAsync` transactionally transitions drafts to Manual/Queued, sets parent to Planned, sets timestamp, clears token. On simulated DB error, rolls back fully.
- `DiscardPlanningAsync` removes drafts, resets parent.
- `GetNextQueuedAgentTaskAsync` ignores Drafts, Planning parents, Planned parents.
- `Restrict` cascade: delete parent with children throws `DbUpdateException`.
**Auto-status hook (§7.2):**
- All children Done → parent Done.
- Mix: some Done, at least one Failed, rest in terminal state → parent Failed.
- Mix with one still Running → parent stays Planned.
- Parent stays Planned while any Draft exists (defensive — finalize should have cleared them).
**MCP handlers (against SQLite + in-process HTTP):**
- Valid token → tool executes.
- Missing/invalid token → 401.
- `create_child_task` → creates Draft, emits TaskUpdated event.
- `update_child_task` on non-Draft → MCP error.
- `delete_child_task` on non-Draft → MCP error.
- `finalize` called twice: first succeeds, second errors because token is invalidated.
- Cross-parent access: tool with `task_id` belonging to another parent's session → MCP error.
**SignalR endpoints (integration with Worker host):**
- Start → token generated, session directory + files created, `mcp.json` contains token.
- Start on already-`Planning` parent → error.
- Resume → no new token, reads `PlanningSessionId` from DB.
- Discard → drafts gone, directory removed, token NULL, parent back to Manual.
### 8.2 Manual (added to `docs/open.md` checklist)
- Windows Terminal spawn with real `wt.exe`.
- Real Claude CLI end-to-end session (requires `ANTHROPIC_API_KEY`).
- Avalonia hierarchy rendering (chevron, indentation, draft styling, badges).
- Session-ID capture from `~/.claude/projects/...` (timing-sensitive, platform-specific).
---
## 9. Phasing
Work is delivered in **three sequential-then-parallel** plans. Plan A must merge before B and C can merge.
### 9.1 Plan A — Foundation
Schema migration, enum additions, repository methods, auto-status hook, delete-Restrict, regression test for queue filter. No UI-visible changes (other than delete-with-children now failing with a generic error until Plan C handles it).
Scope files (approximate):
- `src/ClaudeDo.Data/Models/TaskEntity.cs`
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`
- `src/ClaudeDo.Data/Migrations/<new>_AddPlanningSupport.cs`
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (+ `ITaskRepository`)
- `src/ClaudeDo.Worker/...` auto-status hook call-site updates.
- `tests/ClaudeDo.Worker.Tests/...` new test classes.
### 9.2 Plan B — Worker MCP + SignalR + Launcher (starts after A merges)
MCP service with HTTP transport, token auth, six tools. New SignalR hub endpoints for Start/Resume/Discard/Finalize/GetPendingDraftCount. Session directory management. `IPlanningTerminalLauncher` implementation for `wt.exe`. Resolves the four unknowns from §5.8.
Scope files (approximate):
- `src/ClaudeDo.Worker/Planning/PlanningMcpService.cs` (new)
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (new)
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` (extend)
- `src/ClaudeDo.Worker/Program.cs` (DI + endpoint mapping)
- `src/ClaudeDo.App/` or `src/ClaudeDo.Ui/Services/``IPlanningTerminalLauncher` + `WindowsTerminalPlanningLauncher`.
- `tests/ClaudeDo.Worker.Tests/Planning/...`
### 9.3 Plan C — UI (parallel to B after A merges)
Context menu entries, hierarchy rendering, draft styling, unfinished-session dialog, WorkerClient extensions, delete-with-children handling. During parallel development, mocks the WorkerClient against Plan B's interface contract.
Scope files (approximate):
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (draft/badge styling)
- `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` (new)
### 9.4 Integration points between B and C
Interface contract locked before parallel work begins:
- SignalR method names, parameters, return DTOs (listed in §6.6).
- `TaskUpdated` event payload unchanged; carries the task's new parent-id and status so the UI can re-bucket.
- Session directory path shape: `~/.todo-app/planning-sessions/<parentTaskId>/`.
- `mcp.json` and session-file formats are internal to Plan B; UI never reads them.
---
## 10. Out of scope (for now)
- Nested planning (children of children). Explicitly one level.
- Cross-list planning (parent in list A, children in list B).
- Multi-user collaboration on the same planning session.
- Session timeouts / auto-discard.
- Planning-session history / audit UI. Directory is kept on finalize but not surfaced.
- Re-planning a finalized parent (7c: no).

View File

@@ -0,0 +1,230 @@
# Self-Update for App and Installer — Design
**Date:** 2026-04-23
**Status:** Approved
## Goals
Give ClaudeDo two update paths:
- **A — App-side update check:** the Avalonia UI checks Gitea for a newer release on startup (and via a manual menu action) and surfaces a dismissible banner. Clicking **Update now** launches the locally installed installer in Update mode and closes the UI.
- **B — Installer self-update:** the WPF installer checks for a newer installer binary on launch and offers to replace itself before continuing. After replacement, it proceeds with its normal wizard.
Non-goals:
- No silent/background auto-apply of updates. The user always initiates the final update action.
- No periodic in-app polling (startup + manual only).
- No changes to the release pipeline — release assets (`ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, `checksums.txt`) stay as they are.
## Architecture Overview
A new shared library, `ClaudeDo.Releases`, hosts the release-API client, version comparison, checksum verification, and installer self-update logic. Both `ClaudeDo.Installer` and `ClaudeDo.Ui` reference it. This removes the existing duplication between the installer's release plumbing and what the app needs, and keeps a single asset-matching / version-parsing code path.
```
ClaudeDo.Releases (new, netstandard2.0 or net8.0)
├── ReleaseClient.cs (moved from Installer/Core)
├── IReleaseClient.cs (moved)
├── ChecksumVerifier.cs (moved)
├── VersionComparer.cs (new)
└── SelfUpdater.cs (new — installer self-update mechanism)
ClaudeDo.Installer (WPF, consumes ClaudeDo.Releases)
├── App.xaml.cs (modified — SelfUpdater + --replace-self arg)
└── Core/InstallModeDetector.cs (modified — now uses VersionComparer)
ClaudeDo.Ui (Avalonia, consumes ClaudeDo.Releases)
├── Services/UpdateCheckService.cs (new)
├── Services/InstallerLocator.cs (new)
├── ViewModels/MainViewModel.cs (modified — banner + Help menu state)
└── Views/MainWindow.axaml (modified — banner + Help dropdown)
```
## Part A — App-Side Update Check
### `ClaudeDo.Ui/Services/UpdateCheckService.cs`
Responsibilities:
- Read the current app version from `Assembly.GetExecutingAssembly().GetName().Version`.
- Call `IReleaseClient.GetLatestReleaseAsync` to fetch the Gitea release.
- Use `VersionComparer` to decide whether the latest is newer.
- Expose observable properties: `IsUpdateAvailable`, `LatestVersion`, `CurrentVersion`, `IsChecking`, `LastCheckStatus` (`UpToDate | UpdateAvailable | CheckFailed | NeverChecked`).
- Expose a `CheckNowAsync` method for the manual Help menu action.
Lifecycle:
- Registered as a singleton in DI.
- Startup check is fired from `MainViewModel` once the main window is shown. It runs fire-and-forget on a background `Task`; UI never blocks on it.
- Manual check is awaited by its command and briefly shows a status message (see UI).
Error handling:
- Network / API errors → log to `~/.todo-app/logs/`, set `LastCheckStatus = CheckFailed`, do not surface a banner. Manual check shows a small inline status only.
### `ClaudeDo.Ui/Services/InstallerLocator.cs`
Responsibilities:
- Resolve the path to the installed `ClaudeDo.Installer.exe` so the UI can launch it.
Discovery strategy (first hit wins):
1. Walk up from `AppContext.BaseDirectory` looking for a sibling `install.json`. The installer is at `{installDir}/uninstaller/ClaudeDo.Installer.exe`.
2. Fall back to reading `HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo\InstallLocation` (written by the existing `WriteUninstallRegistryStep`).
If neither yields a valid path, the banner's **Update now** button is disabled with a tooltip explaining the installer could not be located.
### UI changes
**Banner** at the top of `MainView` (above content, below custom chrome):
- Visible when `UpdateCheckService.IsUpdateAvailable == true` and the user has not dismissed this session.
- Text: `Update available: v{CurrentVersion} → v{LatestVersion}`.
- Actions: `Update now` (primary), `Dismiss` (sets a transient `IsBannerDismissed` flag that resets on app restart — intentionally not persisted so the banner returns next launch if still relevant).
- Styled to match existing chrome/accent conventions (compact, dismissible, non-modal).
**Help dropdown** in the custom titlebar:
- New menu: `Help`.
- First item: `Check for updates` → binds to `MainViewModel.CheckForUpdatesCommand`, which calls `UpdateCheckService.CheckNowAsync`.
- When a check completes:
- `UpdateAvailable` → banner appears (no separate dialog).
- `UpToDate` → a brief inline status in the banner area: `You're up to date (v{CurrentVersion})`, auto-hides after ~3 seconds.
- `CheckFailed``Could not check for updates` inline message, auto-hides after ~3 seconds.
- Leaves room for future items (`About`, `Documentation`, etc.).
### Update action flow
1. User clicks **Update now** in the banner.
2. `MainViewModel` resolves the installer path via `InstallerLocator`.
3. UI spawns `ClaudeDo.Installer.exe` with no arguments. The installer's existing `InstallModeDetector` reads `install.json` alongside it, hits Gitea, and enters `Update` mode.
4. UI closes itself immediately after spawning the process.
5. The installer performs its standard update flow: `StopServiceStep``DownloadAndExtractStep``StartServiceStep`.
6. When the user clicks Finish the installer exits. The user re-launches the app via their existing shortcut.
No IPC between UI and installer is needed — the installer is already self-sufficient once launched against an existing install directory.
## Part B — Installer Self-Update
### `ClaudeDo.Releases/SelfUpdater.cs`
Runs from `ClaudeDo.Installer/App.xaml.cs` before any window is shown.
Flow:
1. **Handle `--replace-self` argument first.** If the installer was launched with `--replace-self "<old-path>"`:
- Wait up to 5 seconds for the old process to exit (poll for file lock release).
- Delete `<old-path>`.
- Copy own exe to `<old-path>`.
- Start a new process at `<old-path>` with no args, then exit the current (temp) process. This ensures the user's shortcut or Apps & Features entry now points at the updated binary.
- If any step fails, fall through to the normal wizard (the user still has a working installer, just in a temp location).
2. **Check for a newer installer.** If no `--replace-self` arg:
- Parse own assembly version.
- Fetch latest release.
- Find the installer asset matching `ClaudeDo.Installer-<version>.exe` (regex: `^ClaudeDo\.Installer-(?<version>[\d\.]+)\.exe$`).
- Compare via `VersionComparer`.
- If not newer, or if check fails, proceed to the normal wizard (existing Config-mode fallback behavior).
3. **Prompt if newer.** Show a small modal dialog (plain WPF `Window`, reusing the installer's titlebar/accent styles):
> *"A newer installer is available: v{latest}. Update before continuing?"*
> `[Update] [Continue anyway] [Cancel]`
- **Cancel** → `Application.Current.Shutdown(0)`.
- **Continue anyway** → proceed to normal wizard.
- **Update** → run relaunch sequence.
4. **Relaunch sequence:**
- Download to `%TEMP%\ClaudeDo.Installer-<version>.exe` (show a minimal inline progress UI; no separate window).
- Verify against `checksums.txt` from the release via `ChecksumVerifier`. On failure, show error with `[Continue with current installer]` action → proceed to wizard.
- `Process.Start` new exe with args `--replace-self "<current-exe-path>"`.
- Exit current process.
### Why `--replace-self` rather than a shell script
A child process holding a handle to the new exe is reliable cross-Windows-version. Relying on a `.bat` or `cmd /c` helper leaves a file that we would need to clean up, and behaves badly when the installer was launched from a mounted share or non-ASCII path. The `--replace-self` approach keeps everything in managed code and uses a single exe throughout.
### Edge case: running from `uninstaller/` copy
When the installer runs from `{installDir}/uninstaller/ClaudeDo.Installer.exe` (via the app's **Update now** or from Apps & Features), the self-update flow is identical. It is desirable for the uninstaller copy to be kept current — stale uninstaller binaries would otherwise drift behind and could have bugs the new app release expects fixed.
## Version Comparison (`VersionComparer`)
Centralizes the logic currently in `InstallModeDetector.IsNewer`:
- Parses both versions as `System.Version` after trimming a leading `v` / `V`.
- Returns `(bool isNewer, bool unparseable)`.
- Unparseable (e.g. `0.2.0-beta`) → treated as not newer; callers can surface a hint if desired.
Both `InstallModeDetector` (existing behavior) and `SelfUpdater` / `UpdateCheckService` (new callers) share this logic.
## Error Handling Summary
| Scenario | App behavior | Installer behavior |
|---|---|---|
| Gitea unreachable | Silent; log to file; no banner | Silent; skip self-update; proceed to wizard |
| JSON parse error | Same as unreachable | Same as unreachable |
| Version unparseable | No banner; log a hint | No prompt; proceed |
| Installer exe not found on disk | `Update now` button disabled with tooltip | N/A |
| Download fails | N/A (app delegates to installer) | Error dialog with `[Continue with current installer]` |
| Checksum mismatch | N/A | Error dialog with `[Continue with current installer]` |
| Relaunch fails | N/A | Error dialog; user keeps temp exe and current exe both |
## Testing
**New test project: `tests/ClaudeDo.Releases.Tests`**
- `ReleaseClientTests` — move existing installer tests covering `GetLatestReleaseAsync` and `DownloadAsync`.
- `VersionComparerTests` — boundary cases (equal, newer, older, unparseable, mixed `v`-prefix).
- `SelfUpdaterTests`:
- Asset-name regex correctly isolates version from `ClaudeDo.Installer-0.3.0.exe` and ignores `ClaudeDo-0.3.0-win-x64.zip`.
- Decision logic given mocked `IReleaseClient` responses.
- `--replace-self` handler: given a temp dummy file, the handler waits, deletes, copies — verified with a mock filesystem / temp dir.
**Existing project: `tests/ClaudeDo.Installer.Tests`**
- `SelfUpdateIntegrationTest`: build the installer, invoke it with `--replace-self <dummy>` pointing at a temp file, assert the dummy is replaced by a copy of the test installer, and the process exits cleanly. Run only on Windows CI.
**App tests (`tests/ClaudeDo.Ui.Tests` — add if absent):**
- `UpdateCheckServiceTests` — stubbed `IReleaseClient`, assert state transitions for each status.
- `InstallerLocatorTests` — fake filesystem, verify walk-up and registry-fallback discovery.
**Manual verification** (add to `docs/open.md`):
1. Build `v0.2.x` installer and upload to a test Gitea release.
2. Tag `v0.3.0` with new installer asset.
3. Install `v0.2.x`, run the `v0.2.x` installer again — confirm self-update prompt appears and replaces the binary in place.
4. With `v0.2.x` installed and a v0.3.0 release published, launch the app — confirm banner appears, **Update now** launches installer, update completes, app relaunches at v0.3.0.
5. Pull the network during check in both places — confirm silent fallback, no user-visible errors.
## Files Summary
**New:**
- `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj`
- `src/ClaudeDo.Releases/ReleaseClient.cs` (moved)
- `src/ClaudeDo.Releases/IReleaseClient.cs` (moved)
- `src/ClaudeDo.Releases/ChecksumVerifier.cs` (moved)
- `src/ClaudeDo.Releases/VersionComparer.cs` (new)
- `src/ClaudeDo.Releases/SelfUpdater.cs` (new)
- `src/ClaudeDo.Ui/Services/UpdateCheckService.cs`
- `src/ClaudeDo.Ui/Services/InstallerLocator.cs`
- `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`
**Modified:**
- `src/ClaudeDo.Installer/App.xaml.cs` — self-update run + `--replace-self` handling before wizard.
- `src/ClaudeDo.Installer/Core/InstallModeDetector.cs` — use shared `VersionComparer`; drop now-moved types.
- `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — reference `ClaudeDo.Releases`.
- `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` — reference `ClaudeDo.Releases`.
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` — banner + Help menu dropdown.
- `src/ClaudeDo.Ui/ViewModels/MainViewModel.cs` — banner state, `CheckForUpdatesCommand`, wiring to `UpdateCheckService`.
- `src/ClaudeDo.App/Program.cs` (or existing DI composition root) — register `UpdateCheckService`, `InstallerLocator`, `IReleaseClient`, `HttpClient`.
- `ClaudeDo.slnx` — add new projects.
- `docs/open.md` — add manual verification checklist.
## Open Decisions Deferred to Implementation
- Exact Avalonia styling/layout of the banner is left to implementation to match the existing chrome polish pass from commit `3c420ac`.
- The Help dropdown control type (Avalonia `MenuItem` inside a `Menu`, or a custom flyout) is chosen during implementation based on what fits the current custom titlebar.

View File

@@ -0,0 +1,121 @@
# Worker Log Footer — Design
Date: 2026-04-23
## Goal
Surface important Worker lifecycle events (worktree created, Claude started, merged, etc.) in the UI footer as a single rotating, color-coded line. Gives the user ambient awareness of what the Worker just did without opening task details.
## Non-Goals
- No log history, drawer, or scrollback
- No filtering or user-configurable verbosity
- No persistence across UI restarts
- No replay of events missed while UI was disconnected
## UX
Footer (`MainWindow.axaml`, row 2) layout changes from `StackPanel` to `DockPanel`:
- **Docked left:** existing connection pill (ellipse + `ONLINE/OFFLINE/RECONNECTING` text). The static `· WORKER` label is removed; the rotating log line replaces its purpose.
- **Docked right:** rotating worker-log line.
Line format: `14:32 · <message>`, rendered in the mono font at size 10 (matches existing footer typography). `TextTrimming="CharacterEllipsis"` so long task titles don't push out the connection pill.
The line is hidden when no event has been received within the last 30 seconds. Each new event replaces the current text and resets the 30-second timer. Timestamp is local time, `HH:mm`.
### Color mapping
Level is rendered via a `WorkerLogLevelToBrushConverter` (mirrors existing `StatusColorConverter` pattern):
| Level | Brush / color | Events |
|-----------|------------------------|-----------------------------------------------------|
| `Info` | `TextDimBrush` (dim) | Created worktree, Started Claude, Committed changes |
| `Success` | `#4CAF50` green | Merged, Finished (done) |
| `Warn` | `#FFA726` amber | Discarded worktree, Reset |
| `Error` | `#EF5350` red | Finished (failed) |
## Event Catalog
Seven emit sites. Each is added alongside the existing `_logger.LogInformation(...)` call — no log-sink plumbing, no central event bus.
| Site | Level | Message |
|-------------------------------------------|-----------|-----------------------------------------|
| `WorktreeManager.CreateAsync` | `Info` | `Created worktree for "<title>"` |
| `WorktreeManager.DiscardAsync` | `Warn` | `Discarded worktree for "<title>"` |
| `TaskMergeService.MergeAsync` | `Success` | `Merged "<title>" into <target>` |
| `TaskResetService.ResetAsync` | `Warn` | `Reset "<title>"` |
| `TaskRunner` — Claude launch | `Info` | `Started Claude for "<title>"` |
| `TaskRunner` — auto-commit | `Info` | `Committed changes in "<title>"` |
| `TaskRunner` — task finished | `Success` / `Error` | `Finished "<title>" (<status>)` |
`<title>` is the task's display title; `<target>` is the merge target branch; `<status>` is `done` or `failed`.
## Architecture
### Shared contract (`ClaudeDo.Data`)
New enum:
```csharp
namespace ClaudeDo.Data.Models;
public enum WorkerLogLevel
{
Info,
Success,
Warn,
Error,
}
```
SignalR is configured to serialize enums as strings via `JsonStringEnumConverter` (added to the hub's JSON options in `Program.cs`). The UI client deserializes back to the same enum.
### Server side (`ClaudeDo.Worker`)
`HubBroadcaster` gets a new method:
```csharp
public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) =>
_hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc);
```
`HubBroadcaster` is already injected into `TaskRunner`. For `WorktreeManager`, `TaskMergeService`, and `TaskResetService`, add constructor injection where it isn't already present. Each emit site calls `_broadcaster.WorkerLog(...)` with `DateTime.UtcNow` next to the existing `_logger.LogInformation(...)`.
### Client side (`ClaudeDo.Ui`)
**`WorkerClient`** — register a `HubConnection.On<string, WorkerLogLevel, DateTime>("WorkerLog", ...)` handler and expose a `WorkerLogReceived` event with a small `WorkerLogEntry(string Message, WorkerLogLevel Level, DateTime TimestampUtc)` record.
**Footer VM**`StatusBarViewModel` already exists; extend it (or introduce a small `FooterViewModel` if `StatusBarViewModel` turns out to be scoped elsewhere — confirm during implementation). Add:
- `[ObservableProperty] string? currentEventText`
- `[ObservableProperty] WorkerLogLevel currentEventLevel`
- `[ObservableProperty] bool isEventVisible`
- A `DispatcherTimer` with a 30-second interval. On each `WorkerLogReceived`:
1. Format `HH:mm · <message>` from the event's local time.
2. Set `CurrentEventText`, `CurrentEventLevel`, `IsEventVisible = true`.
3. Stop and restart the timer.
- On timer tick: `IsEventVisible = false`, `CurrentEventText = null`.
**XAML**`MainWindow.axaml` footer `StackPanel` becomes a `DockPanel`. Existing ellipses + connection text dock left in a horizontal `StackPanel`. A new `TextBlock` docks right, bound to `CurrentEventText` with `Foreground="{Binding CurrentEventLevel, Converter={StaticResource WorkerLogLevelToBrush}}"`, `IsVisible="{Binding IsEventVisible}"`, and `TextTrimming="CharacterEllipsis"`. Same mono font / size 10 as the rest of the footer.
**Converter**`WorkerLogLevelToBrushConverter` in `Converters/` returns a brush per enum value, resolving theme brushes via `Application.Current.Resources` for `Info` (to honor theme swaps) and hard-coding the success/warn/error hex values (those are already hard-coded in the current footer).
## Testing
- Unit test `WorkerLogLevelToBrushConverter` with each enum value.
- Unit test the footer VM: receiving an event sets text/level/visibility and schedules a clear; a second event within 30s replaces and resets the timer; after 30s of silence the line hides.
- Manual smoke: run a task end-to-end and confirm each of the seven events surfaces with the expected color and copy.
## Edge Cases
- **UI disconnected during an event:** event is lost. Acceptable — reconnect resumes receiving new events.
- **Burst of events:** each replaces the previous; only the most recent is shown.
- **Long task title:** ellipsized by the TextBlock; connection pill on the left stays fully visible.
- **Clock skew between Worker and UI:** timestamp is formatted in UI's local time from the wire-format `DateTime` (sent as UTC). Minor skew is cosmetic; no correctness impact.
## Out of Scope / Future
- Click-to-expand history drawer
- Per-list or per-task event filtering
- Persisting the most recent N events across restarts

View File

@@ -0,0 +1,197 @@
# Planning Merge-All & Subtask Visibility — Design
**Date:** 2026-04-24
**Status:** Approved design, ready for implementation planning
## Problem
Three concrete issues with the current Planning feature:
1. **Queued subtasks are not visible in the Queue List.** When a planning session finalizes, its subtasks transition to `Queued`, but the Queue List's hierarchy rules only show children when their Planning parent is expanded. A collapsed (or already-`Planned`) parent effectively hides the subtasks.
2. **Completed subtasks vanish from view.** Once a subtask becomes `Done`, the regroup logic moves it to the "Completed" bucket. Users expect subtasks to remain visible under their Planning parent until the Planning task itself is marked Done.
3. **No aggregated view or bulk merge.** Each subtask must be merged individually through its worktree. There is no way to see a combined diff of all changes produced by a Planning session, and no "merge everything" action.
## Goals
- Treat Planning subtasks as belonging to their Planning parent for visibility and lifecycle purposes.
- Provide a single aggregated diff view that shows all changes produced by a Planning session.
- Provide a single "Merge all" action that sequentially merges all subtasks, with a usable conflict-resolution flow.
- Auto-complete the Planning task when all merges succeed.
## Non-goals
- Building a full-featured in-app diff editor. Textual unified diff is acceptable for now; conflict *editing* happens in VS Code.
- Persisting Merge-all progress across worker restarts. Restart clears in-memory orchestration state; user re-starts Merge-all (already-merged subtasks are skipped because their worktrees are `Merged`).
- Modifying how individual subtasks are created, executed, or finalized.
## Design
### 1. Visibility model
Planning subtasks are exclusively children of their Planning parent until the Planning task transitions to `Done`. The Planning parent acts as a roll-up in the Queue List.
- Tasks with a non-null `ParentTaskId` are excluded from all virtual lists (`virtual:queued`, `virtual:running`, `CompletedItems`, etc.) as separate rows.
- A Planning/Planned task is included in `virtual:queued` if **any** child is `Queued`, and in `virtual:running` if any child is `Running`.
- Children are always attached under their parent in the task tree; expansion purely controls visual collapse.
- When Merge-all completes successfully, the Planning task is set to `Done` and the entire subtree moves to Completed together.
- Status badge on the Planning row summarizes children (e.g., `3/5 queued`, `2 running`, `1 failed`).
### 2. Planning detail panel
Extends the existing task detail view. New elements when the selected task is a Planning task:
- **Subtasks list.** Grouped by status badge (Queued / Running / Done / Failed). Each row preserves existing per-subtask actions (view logs, open worktree, individual merge).
- **Merge target dropdown.** Single target branch that applies to all subtasks in Merge-all. Defaults to the branch that was current when the Planning session started.
- **`[Review combined diff]` button.** Opens the Aggregated Diff Viewer. Enabled as soon as any subtask has produced a diff.
- **`[Merge all subtasks]` button.** Orchestrates sequential merge + auto-Done. Disabled until every subtask is `Done` and every worktree is `Active` or `Merged` (no `Discarded` / `Kept`). Tooltip explains why when disabled (e.g., "2 subtasks still running", "1 subtask failed — resolve first", "1 worktree was discarded").
- Existing per-subtask merge action remains available; Merge-all is additive.
### 3. Aggregated diff viewer
New Avalonia view `PlanningDiffView` + `PlanningDiffViewModel`, opened as a modal or dedicated tab.
**Default — grouped by subtask:**
- Left pane: subtask list in creation order with `title • +added deleted • N files`.
- Right pane: selected subtask's diff. Reuse any existing diff-rendering control; if none exists, render unified diff text with basic syntax coloring (monospace, minimal decoration).
- Summary stats come from `WorktreeEntity.DiffStat`. Raw diff comes from `git diff <base>..<head>` executed in each subtask's worktree via `GitService`. Cached in memory per subtask until the subtask's HEAD moves.
**Toggle — "Preview combined diff":**
- Calls `PlanningAggregator.BuildIntegrationBranchAsync(planningTaskId, targetBranch, ct)`:
1. Create/reset branch `planning/<slug>-integration` off the current merge target.
2. Merge each subtask's branch sequentially with `--no-ff`.
3. On conflict during preview: abort the merge, reset the integration branch, surface a warning identifying which two subtasks conflict. Grouped view remains available.
4. On success: compute `git diff <merge-target>..planning/<slug>-integration` and render as a single flat unified diff.
- Toggle flips back to grouped mode.
**Integration-branch lifecycle:** scratch artifact, rebuilt on every preview (deleted + recreated). Cleaned up when the Planning task is marked `Done` or when the Planning session is discarded.
### 4. Merge-all orchestration
**Happy path (`PlanningMergeOrchestrator.StartAsync`):**
1. Pre-flight checks — fail fast with a clear message on any:
- Every subtask is `Done`.
- Every subtask's worktree is `Active` or `Merged` (no `Discarded` / `Kept`). `Merged` worktrees are allowed so that an interrupted Merge-all can be restarted.
- Repo working tree is clean.
- No mid-merge in progress in the target repo.
2. For each subtask in creation order, skip if its worktree is already `Merged` (idempotent restart). Otherwise call `TaskMergeService.MergeAsync` with `removeWorktree: true` and `leaveConflictsInTree: true`. Each success flips the worktree to `Merged`.
3. After the last successful merge:
- Set Planning task `Status = Done`.
- Call `PlanningAggregator.CleanupIntegrationBranchAsync` if the integration branch exists.
- Emit `PlanningCompleted` so the UI removes the row from the Queue List.
**Conflict path:**
1. `MergeAsync` with `leaveConflictsInTree: true` reports a conflict, leaves the repo in a mid-merge state, and returns the conflicted file paths (`git diff --name-only --diff-filter=U`).
2. Orchestrator halts the loop, stores the in-progress state (remaining subtasks, target branch, current subtask id) in memory, and emits `PlanningMergeConflict(planningTaskId, subtaskId, conflictedFiles)`.
3. The UI opens the **Conflict Resolution dialog** — see §5.
4. On `ContinueAsync`: calls `TaskMergeService.ContinueMergeAsync(subtaskId)` which stages the recorded files and runs `git commit --no-edit`. Flips worktree to `Merged`. Loop resumes with remaining subtasks.
5. On `AbortAsync`: calls `TaskMergeService.AbortMergeAsync(subtaskId)` which runs `git merge --abort`. Planning stays in `Planned`. Already-merged earlier subtasks remain `Merged`. Orchestration state cleared.
**Idempotent restart:** if the worker restarts mid Merge-all, in-memory state is lost. A fresh `StartAsync` re-runs pre-flight; already-`Merged` worktrees are skipped by the loop (their status gates them out). User experience: "I clicked Merge all again and it continued from where it left off."
### 5. Conflict Resolution dialog
Avalonia modal (`ConflictResolutionView` + `ConflictResolutionViewModel`).
- **Header:** `Conflicts in subtask: <title> merging into <target-branch>`.
- **File list:** full absolute paths of conflicted files.
- **`[Open all in VS Code]`** — for each file, spawn `code <absolute-path>` via `Process.Start`. If `code` is not on PATH, show an inline error row with the file list so the user can copy paths manually. No popup-on-popup.
- **`[I've resolved — continue]`** — calls `ContinuePlanningMerge(planningTaskId)` hub method, closes dialog. The orchestration loop continues with the remaining subtasks.
- **`[Abort this merge]`** — calls `AbortPlanningMerge(planningTaskId)` hub method, closes dialog. Planning stays `Planned`.
### 6. Data model
**No schema changes.**
- Conflicted files are queried from git on demand (`git diff --name-only --diff-filter=U`) while the merge is in progress.
- Integration branch name is derived from the Planning task slug: `planning/<slug>-integration`.
- Planning completion uses existing `TaskStatus.Done`.
### 7. Services
**New:**
- **`PlanningAggregator`** (`src/ClaudeDo.Worker/Planning/PlanningAggregator.cs`)
- `GetAggregatedDiffAsync(planningTaskId, ct)` — returns per-subtask diff entries.
- `BuildIntegrationBranchAsync(planningTaskId, targetBranch, ct)` — creates/resets the integration branch, merges subtasks sequentially, returns `(success, combinedDiff)` or `(failure, firstConflictSubtaskId, conflictedFiles)`. Always leaves the integration branch in a consistent state (aborts + resets on failure).
- `CleanupIntegrationBranchAsync(planningTaskId, ct)` — deletes the integration branch.
- **`PlanningMergeOrchestrator`** (singleton, `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs`)
- Owns in-memory state per planning task: `{ remainingSubtasks, targetBranch, currentSubtaskId }`.
- `StartAsync(planningTaskId, targetBranch)`, `ContinueAsync(planningTaskId)`, `AbortAsync(planningTaskId)`.
- Emits SignalR events: `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`, `PlanningMergeAborted`, `PlanningCompleted`.
**Modified:**
- **`TaskMergeService`**
- `MergeAsync` gets a `leaveConflictsInTree: bool` parameter (default `false`). When `true`, on conflict records conflicted files on the returned result, does **not** call `git merge --abort`.
- New `ContinueMergeAsync(taskId, ct)` — stages the recorded conflicted files and runs `git commit --no-edit`, flips worktree to `Merged`.
- New `AbortMergeAsync(taskId, ct)` — runs `git merge --abort`, restores pre-merge state.
- Existing callers unaffected by the default.
- **`WorkerHub`** — new methods:
- `GetPlanningAggregate(planningTaskId)`
- `BuildPlanningIntegrationBranch(planningTaskId, targetBranch)`
- `MergeAllPlanning(planningTaskId, targetBranch)`
- `ContinuePlanningMerge(planningTaskId)`
- `AbortPlanningMerge(planningTaskId)`
- **`TasksIslandViewModel.Regroup`**
- Exclude tasks with `ParentTaskId != null` from virtual lists.
- Include Planning parents in `virtual:queued` / `virtual:running` based on children's statuses.
- Keep children attached to parent in the tree at all times until Planning is `Done`.
### 8. UI components (new)
- `PlanningDiffView` + `PlanningDiffViewModel` — aggregated diff viewer (§3).
- `ConflictResolutionView` + `ConflictResolutionViewModel` — conflict dialog (§5).
- Planning Detail section inside the existing task detail pane — subtask list + merge target dropdown + two buttons (§2).
## Error handling
- **Pre-flight failures** — surface as inline errors in the Planning detail panel. No merge work attempted.
- **Preview-build conflict** — keep grouped diff available; show a warning banner identifying the conflicting pair of subtasks.
- **Merge-all conflict** — Conflict Resolution dialog (§5). The failed subtask's worktree stays `Active`; prior successes stay `Merged`.
- **VS Code not on PATH** — inline error row in the Conflict dialog with copyable file paths.
- **Worker restart mid-merge** — in-memory state lost; restarting Merge-all is idempotent because merged worktrees are skipped by status gating.
## Testing
Convention: xUnit integration tests with real SQLite and real git (`tests/ClaudeDo.Worker.Tests`).
**`PlanningAggregatorTests`** — real git fixture
- `GetAggregatedDiffAsync` returns one entry per subtask with correct stats.
- `BuildIntegrationBranchAsync` with non-conflicting subtasks — success, branch contains all changes.
- `BuildIntegrationBranchAsync` with conflicting subtasks — failure, branch reset (not mid-merge), correct subtask id and file list reported.
- Rebuild overwrites a stale integration branch.
- `CleanupIntegrationBranchAsync` removes the branch.
**`PlanningMergeOrchestratorTests`** — real git + real DB
- Happy path: all subtasks merge → worktrees `Merged`, Planning `Done`, `PlanningCompleted` emitted.
- Conflict path: first subtask conflicts → repo left in conflict state, `PlanningMergeConflict` emitted with correct file list, worktree stays `Active`, Planning stays `Planned`.
- `ContinueAsync` after conflict: resolution commits, loop proceeds, final state `Done`.
- `AbortAsync` after conflict: `merge --abort` restores clean state, earlier merged subtasks remain `Merged`, Planning stays `Planned`.
- Pre-flight rejection: running subtask, failed subtask, dirty repo — each returns the expected error with no side effects.
- Idempotent restart: partial merge + fresh `StartAsync` — already-`Merged` worktrees skipped.
**`TaskMergeServiceConflictTests`** (extending existing tests)
- `MergeAsync(leaveConflictsInTree: true)` on conflict: no `merge --abort`, returns conflicted files, worktree state unchanged.
- `ContinueMergeAsync`: completes in-progress merge, flips worktree to `Merged`.
- `AbortMergeAsync`: runs `merge --abort`, restores clean state.
**`TasksIslandRegroupTests`** — ViewModel unit tests, no DB
- Queued subtask with a Planning parent is NOT in `virtual:queued` as its own row.
- Planning parent with any Queued child IS in `virtual:queued`.
- Done subtask stays nested under Planning parent until Planning is `Done`.
- After Planning is marked `Done`, parent + children move to Completed together.
**Manual smoke test** (documented in PR description):
- End-to-end planning session in the app: create plan, finalize, let subtasks run.
- Open aggregated diff, toggle Preview combined.
- Merge-all happy path.
- Merge-all conflict path with VS Code dialog open/continue.
- Merge-all conflict path abort.
## Open questions
None at this stage. All decisions from the brainstorming session are captured above.

View File

@@ -0,0 +1,95 @@
# Planning UX Polish + Sequential Subtask Queue
**Status:** design
**Date:** 2026-04-24
**Scope:** three small UX changes + one feature — sequential execution of planning subtasks triggered from the context menu.
## Goals
1. Collapse the children of a finished planning-parent row in the task list by default.
2. Allow the user to collapse the Description section in the Details pane.
3. Halve the width of the GridSplitters between islands.
4. Let the user queue all subtasks of a planning parent so they run one after another, with a new `Waiting` status for pending siblings.
## 1. Auto-collapse done planning parents
**Rule for "done":** a planning parent is "done" when every one of its children has `Status == Done`.
**Changes:**
- `TaskRowViewModel`: add UI-only `[ObservableProperty] bool _areChildrenExpanded`. Default computed from status — `false` when the row is a done planning parent, else `true`. Not persisted.
- Add `[RelayCommand] void ToggleChildrenExpanded()`.
- `TasksIslandView.axaml` (or `TaskRowView.axaml`): chevron button on the planning-parent row, visible only when `IsPlanningParent && HasPlanningChildren`. Bound to the toggle command.
- `TasksIslandViewModel.Regroup()`: before adding child rows to `OpenItems`/`CompletedItems`, check each child's parent row in `Items`. If the parent's `AreChildrenExpanded == false`, skip the child.
- When a planning parent flips from "not done" → "done" in `OnWorkerTaskUpdated`, call `Regroup()` so the collapse takes effect.
No DB changes.
## 2. Collapsible description in Details pane
**Changes:**
- `DetailsIslandViewModel`: `[ObservableProperty] bool _isDescriptionExpanded = true` + `[RelayCommand] void ToggleDescriptionExpanded()`.
- `DetailsIslandView.axaml`: wrap the existing description `TextBox` in a `StackPanel`; add a thin header row with the label "Description" and a chevron button. Body's `IsVisible` binds to the flag.
- State is per ViewModel instance — reset to `true` whenever a different task is loaded.
No persistence.
## 3. Narrower GridSplitters
`MainWindow.axaml` lines 158 and 170: `Width="5"``Width="3"` on both `GridSplitter` elements.
That's the whole change.
## 4. Sequential subtask queue
### Data
- `ClaudeDo.Data/Models/TaskStatus.cs`: add a new enum value `Waiting` (lowercase serialized form `waiting`, matching existing convention).
- Verify status is stored as string (it should be based on existing patterns). If stored as int, ensure new value gets a stable numeric slot at the end of the enum to avoid breaking existing rows. **No EF migration** beyond what the enum emits automatically.
### Worker
- New SignalR hub method: `QueuePlanningSubtasksAsync(string parentTaskId) : Task`.
- Loads all children of the parent, ordered by `SortOrder`.
- Validates: parent must be a planning parent, children must currently all be in `Manual` or `Planned` (reject if any child is already Queued/Running/Done/Failed, surface a friendly error).
- First child → `Queued`. All other children → `Waiting`. Save.
- Emit `TaskUpdated` for each affected task.
- Chain progression — hook into the existing finish/complete path that already fires `TaskFinished`:
- On a child task finishing with status `Done` **and** its parent has waiting siblings: find the next sibling by `(ParentTaskId == parent.Id && Status == Waiting)` ordered by `SortOrder`, flip to `Queued`, emit `TaskUpdated`, and let the existing queue pickup loop pick it up.
- On `Failed`: do nothing. Remaining `Waiting` siblings stay waiting. (A toast for failed tasks will be added in a later spec.)
This logic lives in a new `PlanningChainCoordinator` service (or similar) in `ClaudeDo.Worker/Planning/`, registered as a singleton and wired into whatever already emits task-finished events.
### UI
- `TaskRowView` — add context menu entry **"Queue subtasks sequentially"**:
- `IsVisible` bound to `IsPlanningParent && HasPlanningChildren`.
- `IsEnabled` when all children are in `Manual` / `Planned` state (new property on `TaskRowViewModel`: `CanQueueSubtasksSequentially`).
- Calls `WorkerClient.QueuePlanningSubtasksAsync(Id)`.
- `TaskRowViewModel`:
- Add `IsWaiting => Status == TaskStatus.Waiting` and extend `StatusChipClass` switch to return a new class `"waiting"`.
- Add `CanQueueSubtasksSequentially` (computed; requires access to children).
- `StatusColorConverter` — add a muted color for `Waiting` (proposed: the existing `TextMuteBrush` or a faint cyan).
- Task list — planning parent continues to appear in virtual:queued because it has a `Queued` child (existing logic). **Extend** the virtual:queued match predicate in `TasksIslandViewModel.TaskMatchesList` so a task matches when `Status == Queued || Status == Waiting`. This ensures all sibling subtasks (the queued one + the waiting ones) render under the parent in that list.
### Client
- `IWorkerClient` / `WorkerClient`: add `QueuePlanningSubtasksAsync(string parentTaskId)` that calls the hub method.
## Out of scope
- Toast notifications on subtask failure (separate follow-up spec).
- Retrying a stopped chain from a failed task (user does it manually via existing actions).
- Persisting the collapse state of planning parents or the Description across sessions.
- Drag-to-reorder of waiting subtasks (execution order = `SortOrder` at the moment the chain starts).
## Validation plan
Manual:
- Plan a task with 3 subtasks. Context-menu → Queue subtasks sequentially. Confirm first = Queued, others = Waiting. Watch the first run to Done, confirm the second flips Queued → Running automatically.
- Force-fail subtask 2 (cancel or make it fail). Confirm subtask 3 stays Waiting; no further dispatch.
- Once all three are Done, confirm the planning parent auto-collapses in the list.
- Toggle the Description chevron in the Details pane on an arbitrary task.
- Eyeball the narrower GridSplitter — still resizable, still hittable.
Automated (minimal — only where cheap):
- Worker-level unit test for `PlanningChainCoordinator`: happy-path chain advance on Done; no advance on Failed; correct ordering by `SortOrder`.

View File

@@ -0,0 +1,172 @@
# Planning Session MCP via Ephemeral Worktree
**Date:** 2026-04-24
**Status:** Design approved, pending implementation plan
**Scope:** `ClaudeDo.Worker` — planning session launch, MCP config delivery
## Problem
When a user starts a planning session, `claude` is spawned in the list's working directory via Windows Terminal and passed `--mcp-config <absolute-path>` pointing at a session-local `mcp.json`. In practice, the spawned `claude` session does **not** pick up the ClaudeDo MCP server: `mcp__claudedo__*` tools are not available, and no trust prompt is shown. The user has to fall back to the built-in `TaskCreate` tool, which writes nothing to ClaudeDo.
The `--mcp-config` flag is documented for headless (`-p`) invocations; in interactive TUI mode it appears to be either ignored or silently dropped on at least some CLI versions. The JSON payload itself is already correct (verified against Claude Code docs — `type: "http"` + `Authorization` header is the documented form).
The reliable path per Claude Code docs is project-root `.mcp.json` auto-discovery plus a one-time trust approval (or `enableAllProjectMcpServers: true`).
## Goal
Spawn planning sessions so that `mcp__claudedo__*` tools are available immediately, without modifying any file in the user's working directory and without requiring a trust prompt.
## Non-goals
- Installer-time MCP registration (rejected — loses per-session token isolation; pollutes every `claude` invocation on the machine).
- Changing how task execution (non-planning) spawns `claude`.
- Supporting planning on a working directory that is not a git repository.
## Approach: ephemeral planning worktree
Each planning session runs inside its own short-lived git worktree, created from `HEAD` of the list's working directory. The worktree is the isolated surface where we write `.mcp.json` and the settings override. The worktree is force-removed on `FinalizeAsync` / `DiscardAsync`.
### Files changed
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`
- `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs` (extend to carry worktree path + branch name)
- `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs` (may drop `McpConfigPath` if no longer used)
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs`
- `src/ClaudeDo.Worker/Runner/WorktreeMaintenanceService.cs` (optional — defensive startup prune)
- DI registration in `src/ClaudeDo.Worker/Program.cs` (inject `GitService`, `WorkerConfig`, `IDbContextFactory<ClaudeDoDbContext>` into `PlanningSessionManager`)
### Data flow on `StartAsync`
1. Resolve `list.WorkingDir`; hard-error if `null`, not a directory, or not a git repo (`GitService.IsGitRepoAsync`).
2. Resolve `HEAD` via `GitService.RevParseHeadAsync`.
3. Resolve worktree strategy from `AppSettingsRepository.GetAsync` (same resolution as `WorktreeManager.CreateAsync`):
- `sibling``<parent-of-WorkingDir>\.claudedo-worktrees\planning\<taskId>`
- `central``<CentralWorktreeRoot>\planning\<taskId>`
Normalize with `Path.GetFullPath`.
4. Branch name: `claudedo/planning/<taskId-stripped-of-dashes>`.
5. `GitService.WorktreeAddAsync(list.WorkingDir, branchName, worktreePath, baseCommit, ct)`. On `"already exists"` failure, run the same self-heal pattern as `WorktreeManager.CreateAsync` (list worktrees for branch → force-remove stale → prune → delete branch → retry once).
6. Write into the worktree:
- `<worktreePath>\.mcp.json` — JSON with env-var expansion for the token (see below).
- `<worktreePath>\.claude\settings.local.json``{ "enableAllProjectMcpServers": true }` (create `.claude` dir if missing).
7. Write session artifacts in the session directory (unchanged from today): `system-prompt.md`, `initial-prompt.txt`. The session-local `mcp.json` is no longer written — drop that write.
8. Return `PlanningSessionStartContext` with `WorkingDir = worktreePath` and a new `WorktreePath` field (redundant with `WorkingDir` for now, but explicit for cleanup). Also carry `BranchName` so finalize/discard can delete it.
### MCP JSON payload
```json
{
"mcpServers": {
"claudedo": {
"type": "http",
"url": "http://127.0.0.1:47821/mcp",
"headers": {
"Authorization": "Bearer ${CLAUDEDO_PLANNING_TOKEN}"
}
}
}
}
```
The token never lives on disk in literal form — `${CLAUDEDO_PLANNING_TOKEN}` is expanded by Claude Code at load time from the spawned process's environment.
### `.claude/settings.local.json` payload
```json
{
"enableAllProjectMcpServers": true
}
```
Since the worktree is always empty of user customizations (fresh checkout), we write this file unconditionally. No merge / backup logic needed.
### Launcher changes (`WindowsTerminalPlanningLauncher`)
- `LaunchStartAsync`:
- Set `psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token` (new field on `PlanningSessionStartContext`).
- `-d` now points at the worktree path (already handled by `ctx.WorkingDir` change).
- **Remove** `--mcp-config` and its path argument.
- Keep `--allowedTools mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill``enableAllProjectMcpServers` only handles trust, not per-tool pre-approval.
- Keep `--append-system-prompt-file` as the "single-value flag buffer" before the positional prompt (the existing arg-order concern is unchanged).
- `LaunchResumeAsync`:
- Same env-var setup.
- Same `-d <worktreePath>`.
- **Remove** `--mcp-config` (the worktree's `.mcp.json` is discovered automatically).
- Keep `--resume <ClaudeSessionId>`.
### Finalize / Discard
`PlanningSessionManager.FinalizeAsync` and `DiscardAsync` gain:
1. Look up the worktree path + branch name (deterministic from `taskId` → reuse the same resolution code as `StartAsync`).
2. `GitService.WorktreeRemoveAsync(list.WorkingDir, worktreePath, force: true, ct)``--force` because claude may have created scratch files.
3. `GitService.BranchDeleteAsync(list.WorkingDir, branchName, force: true, ct)`.
4. Delete the session dir as today.
All three steps are best-effort in `DiscardAsync` (log warnings, don't throw — the user explicitly asked to discard). `FinalizeAsync` should propagate failures, since a failed cleanup leaves resources we care about.
### Resume
Resume already looks up `list.WorkingDir` from the list; the worktree path is deterministic from `taskId`. `ResumeAsync` must:
1. Verify the worktree directory exists; if not, hard-error ("planning session was discarded or lost — cannot resume").
2. Return `PlanningSessionResumeContext` with `WorkingDir = worktreePath` and the token (re-read from session state — see Token persistence below).
### Token persistence
The token today is generated in `StartAsync` and embedded in `mcp.json` at creation time — never read again. With env-var expansion, the token must be available on **resume**. Options:
- **A) Persist token to session dir** (`<sessionDir>\token`) with `FileOptions.WriteAllBytes`, restrict file ACL to current user. Read on resume.
- **B) Store token hash in DB, raw token in memory only** — breaks across Worker restarts → no resume possible.
**Chosen: A.** Token file sits inside the existing session directory (`<PlanningSessionManager._rootDirectory>\<taskId>\token`), restricted to the current user via Windows ACLs (`File.SetAccessControl` with an explicit DACL granting `FullControl` to `WindowsIdentity.GetCurrent()` only). Cleaned up in `DiscardAsync`/`FinalizeAsync` with the rest of the session dir.
### Defensive startup cleanup
`WorktreeMaintenanceService` already prunes worktrees tracked in the DB. Planning worktrees are **not** in the DB (they're purely filesystem-backed, keyed by `taskId` via path convention). Add a lightweight pass:
- Enumerate directories matching `<root>\.claudedo-worktrees\planning\*` (for each strategy / central root we know about).
- For each, check whether a corresponding session dir exists under `~/.todo-app/sessions/<taskId>`.
- If no session dir: `git worktree remove --force` + `git branch -D claudedo/planning/<taskId-stripped>`.
This is a small addition; if scoped too large, defer to a follow-up and accept that a crashed Worker leaves orphaned worktrees until manual cleanup.
## Edge cases
| Case | Behavior |
|------|----------|
| `list.WorkingDir` not a git repo | Hard-error on `StartAsync`. Surface message in UI. |
| Worktree branch already exists from a prior crashed session | Self-heal: force-remove matching worktrees, prune, delete branch, retry once. (Same pattern as `WorktreeManager.CreateAsync`.) |
| User closes Windows Terminal without clicking Finalize/Discard | Session dir + worktree remain. `ResumeAsync` works. Startup cleanup handles abandoned sessions whose session dir the user manually deletes. |
| Claude creates/edits files in the planning worktree | Discarded with the worktree. No impact on user's real working dir. |
| User deletes the session dir out from under the Worker | `ResumeAsync` hard-errors. Startup cleanup GCs the orphaned worktree. |
| Two simultaneous planning sessions on the same task | Already prevented by task status transition (`Planning` is exclusive). No new consideration. |
| `HEAD` is on a detached commit | `git worktree add` handles this fine — base commit is explicit. |
## Testing
Extend `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (or a new file) with integration tests using the real-SQLite + real-git pattern the project already uses:
- **Start happy path:** worktree dir exists after `StartAsync`, contains `.mcp.json` with `${CLAUDEDO_PLANNING_TOKEN}` literal, contains `.claude/settings.local.json` with `enableAllProjectMcpServers: true`.
- **Finalize cleanup:** worktree dir is gone, branch is gone, session dir is gone.
- **Discard cleanup:** same as finalize.
- **Self-heal:** pre-create a stale branch `claudedo/planning/<id>`, then `StartAsync` must succeed.
- **Non-git working dir:** `StartAsync` throws a specific error type.
- **Resume after Worker restart:** seed session dir + token file, recreate `PlanningSessionManager`, `ResumeAsync` returns context pointing at the still-existing worktree.
Mock `IPlanningTerminalLauncher` (already an interface) so tests don't actually spawn `wt.exe`.
## Trade-offs and alternatives considered
1. **Write `.mcp.json` into the user's working dir with backup/restore.** Rejected — clobber risk, file-noise on crash, user's `.gitignore` may not cover it, exposes token alongside source even with env-var expansion (because expansion is on claude's side, the raw `${VAR}` string still lives in the user's repo).
2. **User-scope registration via installer** (`claude mcp add --scope user`). Rejected — requires a static secret baked into the Worker, loses per-session isolation, every `claude` session on the machine sees claudedo tools.
3. **Keep `--mcp-config` and debug why it's not honored.** Rejected — even if it works on the maintainer's machine, the behavior is undocumented for interactive TUI mode, and we'd need a fallback anyway. Fixing to the documented path eliminates the uncertainty.
## Open questions resolved
- **WorkingDir must be a git repo?** Yes — hard-error.
- **Worktree path strategy?** Follow the same `sibling`/`central` setting as task execution.
- **HEAD snapshot vs WIP?** HEAD snapshot is fine — planning proposes subtasks, doesn't edit files.
## Implementation sequencing
A separate implementation plan (via `superpowers:writing-plans`) will break this into test-first steps.

View File

@@ -0,0 +1,174 @@
# External MCP — CRUD Extensions
**Date:** 2026-04-25
**Status:** Approved
## Goal
Give a normal (non-planning) Claude CLI session full control over the ClaudeDo task inbox via the existing always-on `ExternalMcpService`. Primary use case: when a chat session produces scope-creep work, Claude can spin up a fully-formed task — title, description, tags (including the `agent` tag for auto-execution) — without leaving the session.
The work is purely additive: the `ExternalMcpService` endpoint is already wired, authenticated by the optional `X-ClaudeDo-Key` header, and exposes `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTaskStatus`, `RunTaskNow`, `CancelTask`. Missing for "full CRUD" are tag handling, content updates, deletion, and tag discovery.
## Scope
| Tool | Status | Notes |
|---|---|---|
| `ListTaskLists` | exists | unchanged |
| `ListTasks` | exists | unchanged |
| `GetTask` | exists | unchanged |
| `AddTask` | extend | add optional `tags` parameter |
| `UpdateTaskStatus` | exists | unchanged (Manual ↔ Queued) |
| `RunTaskNow` | exists | unchanged |
| `CancelTask` | exists | unchanged |
| `UpdateTask` | new | patch title/description/commitType/tags |
| `DeleteTask` | new | delete a task (cascades) |
| `SetTaskTags` | new | replace the full tag set on a task |
| `ListTags` | new | enumerate all known tag names |
Out of scope:
- List CRUD (creating/renaming/deleting lists) — out of scope for this iteration; UI remains the source of truth for list management.
- ListConfig / agent settings overrides — handled by the UI, not surfaced via MCP here.
- Tag CRUD beyond auto-creation during `AddTask` / `UpdateTask` / `SetTaskTags`. There is no `DeleteTag` tool; tag rows live as long as some task references them.
## Authentication
No change. The endpoint continues to be gated by `ExternalMcpAuthMiddleware` — if `WorkerConfig.ExternalMcpApiKey` is set, callers must include `X-ClaudeDo-Key`; otherwise the loopback-only worker is open to local processes.
## Tool specifications
### `AddTask` (extended)
```
AddTask(
listId: string,
title: string,
description: string?,
createdBy: string,
queueImmediately: bool,
tags: string[]?,
cancellationToken)
-> TaskDto
```
Behavior:
- Existing behavior preserved. New `tags` parameter, when non-null, attaches the named tags to the new task.
- Tag names are matched case-insensitively against existing rows; missing tag rows are auto-created (mirrors `TaskRepository.CreateChildAsync`).
- Empty/whitespace tag names are skipped; duplicates are deduplicated.
- `tags` is the LAST parameter before `CancellationToken` so existing positional callers are unaffected (CancellationToken is bound by name in MCP runtime; defensive — see Migration).
### `UpdateTask` (new)
```
UpdateTask(
taskId: string,
title: string?,
description: string?,
commitType: string?,
tags: string[]?,
cancellationToken)
-> TaskDto
```
Behavior:
- Loads the task; throws `InvalidOperationException` if not found.
- **Refuses if status is `Running`** — protects in-flight worktrees and the streaming log.
- Does NOT change status (use `UpdateTaskStatus`) and does NOT change `createdBy`, `listId`, or `parentTaskId` (audit + structural fields, immutable here).
- For each non-null parameter, applies the update. Null means "leave unchanged".
- `tags` semantics: full replacement of the tag set (same as `SetTaskTags`). Auto-creates missing tag rows.
- Broadcasts `TaskUpdated` on the SignalR hub on success.
### `DeleteTask` (new)
```
DeleteTask(taskId: string, cancellationToken) -> void
```
Behavior:
- Loads the task; throws if not found.
- **Refuses if status is `Running`** — caller must `CancelTask` first.
- Calls `TaskRepository.DeleteAsync` (FK cascades remove `task_tags`, `worktrees`, `task_runs`, `subtasks`).
- Broadcasts `TaskUpdated(taskId)` so UIs drop the row.
### `SetTaskTags` (new)
```
SetTaskTags(taskId: string, tags: string[], cancellationToken) -> TaskDto
```
Behavior:
- Convenience wrapper for "I just want to (re)set tags". Equivalent to `UpdateTask(taskId, null, null, null, tags)`.
- Same validation: refuses if `Running`.
- Returns the updated `TaskDto` (with status; tags are not included in `TaskDto` today — see Open Decisions).
### `ListTags` (new)
```
ListTags(cancellationToken) -> { Id: long, Name: string }[]
```
Behavior:
- Returns every row in the `tags` table. No filter, no pagination — the table is small (seed values + user-defined).
- Lets Claude discover existing tag names (`agent`, `manual`, plus any user-defined) before tagging, avoiding duplicates that differ only by case/whitespace.
## Repository changes
`src/ClaudeDo.Data/Repositories/TaskRepository.cs`:
- Add `public Task SetTagsAsync(string taskId, IReadOnlyList<string> tagNames, CancellationToken ct = default)` — replaces the tag set, auto-creates missing rows. Implementation pattern matches the tag block already inside `CreateChildAsync` and the new `UpdateChildAsync` from the planning-MCP work; consider extracting a private helper `ApplyTagsAsync(TaskEntity, IReadOnlyList<string>, CancellationToken)` shared by both.
`src/ClaudeDo.Data/Repositories/TagRepository.cs`:
- Add `public Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default)` if it does not already exist. (Matches `ListRepository.GetAllAsync` style.)
No new tables, no migrations.
## Service changes
`src/ClaudeDo.Worker/External/ExternalMcpService.cs`:
- Add `TagRepository` to the constructor (DI registration is already in place since the planning service uses it).
- Extend `AddTask` signature with `IReadOnlyList<string>? tags` and apply via the repository.
- Add `UpdateTask`, `DeleteTask`, `SetTaskTags`, `ListTags` methods, each annotated `[McpServerTool, Description("…")]`.
- Each new mutating tool calls `_broadcaster.TaskUpdated(taskId)` on success (matches existing pattern in this file).
DI: `ExternalMcpService` is already registered. If `TagRepository` is not already registered (it is — used by `ListRepository`), no change. If a constructor parameter is added, `Program.cs` does not need changes because services are scoped/transient.
## Error handling
All errors raised as `InvalidOperationException` with a human-readable message — matches the existing pattern in `ExternalMcpService` and `PlanningMcpService`. The MCP SDK serializes these to the JSON-RPC error channel; Claude sees the message text directly.
Specific cases:
- Task not found → `"Task {id} not found."`
- Running-task guard → `"Cannot {update|delete} a running task. Cancel it first."`
- Unknown status (in `UpdateTaskStatus`, unchanged) → `"Unknown status '{x}'."`
## Testing
Add `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` (or extend if it exists) with:
| Test | Asserts |
|---|---|
| `AddTask_WithTags_AttachesTags` | `tags` param creates and attaches tag rows |
| `AddTask_WithUnknownTag_AutoCreatesTagRow` | new tag name produces a row in `tags` table |
| `UpdateTask_PatchesNonNullFields` | only non-null fields change |
| `UpdateTask_OnRunning_Throws` | `InvalidOperationException` |
| `UpdateTask_BroadcastsTaskUpdated` | hub broadcast received |
| `UpdateTask_TagsReplaceFullSet` | passing tags=[…] replaces existing tags wholesale |
| `DeleteTask_RemovesTaskAndTagJoins` | task and `task_tags` rows gone |
| `DeleteTask_OnRunning_Throws` | `InvalidOperationException` |
| `SetTaskTags_ReplacesAndBroadcasts` | replacement semantics + broadcast |
| `ListTags_ReturnsSeedAndCustomTags` | `agent` + `manual` + any user-defined |
Existing test infrastructure (`DbFixture`, `FakeHubContext`) is reused. No new fakes required.
**Caveat:** the test assembly currently fails to compile on `main` because of pre-existing in-progress work on `PlanningChainCoordinator` (missing constructor argument in `WorkerHub`/`TaskRunner` test instantiations). Tests will pass only after that work lands; do not block this design on it.
## Open decisions (defaults chosen, easy to flip)
1. **`TaskDto` does not currently include tags.** For consistency, the spec keeps `TaskDto` as-is and ships a separate `ListTags` tool. If preferred, we could add `Tags: string[]` to `TaskDto` so every tool response includes them — small DB cost (one extra `SelectMany`), one struct field added. Default: leave `TaskDto` alone, defer.
2. **Per-tag `AddTaskTag` / `RemoveTaskTag` micro-tools.** Skipped — `SetTaskTags` covers the use case, and it's idempotent. Add later if granular ops are wanted.
3. **List CRUD via MCP.** Out of scope. UI owns lists.
## Migration / compatibility
`AddTask` gains an optional parameter. The MCP server SDK sends parameters by name in JSON-RPC `params`, so existing clients that omit `tags` continue to work without code changes. No version bump required.

View File

@@ -0,0 +1,297 @@
# Worker State & Queue Consolidation — Design
**Date:** 2026-04-27
**Status:** Approved (brainstorming)
**Scope:** `ClaudeDo.Worker` + `ClaudeDo.Data` (TaskEntity, TaskRepository), EF migration
## Problem
The worker layer has accumulated structural problems that culminate in a concrete bug — the queue does not pick up tasks created by a planning session.
### Concrete bug
`TaskRepository.FinalizePlanningAsync(parentId, queueAgentTasks=true)` only flips a draft child to `Queued` if the child *or* its list carries the `agent` tag:
```csharp
var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag);
```
When neither carries the tag, the child silently becomes `Manual` — the queue ignores it. There is no UI feedback. Users observe "queue never picks up planning tasks".
### Underlying design issues
1. **Status enum mixes orthogonal concerns.** Today's `TaskStatus` carries 10 values: lifecycle (`Manual, Queued, Running, Done, Failed`), planning hierarchy (`Planning, Planned`), chain ordering (`Waiting`), and an unclear `Draft`. Every consumer has to know which subset applies in which context.
2. **Status writes are scattered.** TaskRunner, StaleTaskRecovery, PlanningChainCoordinator, FinalizePlanningAsync, TaskResetService, ExternalMcpService, and PlanningMcpService all mutate `Status` directly. Some go through `TaskRepository.Mark*Async` helpers, some do `task.Status = …` straight on the DbContext (PlanningChainCoordinator).
3. **Guards are duplicated.** `if (Status == Running) throw …` appears in at least four places (delete, retag, merge, reset).
4. **Two competing planning flows.** `FinalizePlanningAsync` (parallel queueing in Repo) and `PlanningChainCoordinator.QueueSubtasksSequentiallyAsync` (sequential chain) make incompatible assumptions about child status.
5. **`WakeQueue()` is manual.** Multiple callers must remember to invoke it after any DB mutation that creates a `Queued` task. `QueueSubtasksSequentiallyAsync` forgets to. The queue only picks up after a backstop tick.
6. **`Worker/Services/` is a grab-bag.** Queue, lifecycle, merge, worktree maintenance, agent files, and recovery sit side-by-side without domain boundaries.
## Goals
- One source of truth for status mutations: `TaskStateService`.
- Status enum reflects only lifecycle. Planning state and chain blocking are separate fields.
- Wake-queue side effects are automatic, not caller-driven.
- Planning finalization has exactly one path.
- `Worker/Services/` is split into domain folders.
## Non-Goals
- No change to UI status-rendering logic beyond adapting to renamed values.
- No change to SignalR/MCP wire formats beyond the necessary status-string updates.
- No change to git/worktree behavior.
## Design
### 1. Status model reform
Replace today's single `TaskStatus` with three orthogonal fields on `TaskEntity`.
#### `TaskStatus` (lifecycle only) — 6 values
| Value | Meaning |
|---|---|
| `Idle` | not in queue, not active. Replaces today's `Manual` and `Draft`. |
| `Queued` | waiting for queue pickup. |
| `Running` | currently executing. |
| `Done` | finished successfully. |
| `Failed` | finished with error. |
| `Cancelled` | aborted by user (today conflated with `Failed`). |
#### `PlanningPhase` (parent-only, new column) — 3 values
| Value | Meaning |
|---|---|
| `None` | no planning session. Default for all tasks. |
| `Active` | planning session is running. Replaces `Status=Planning`. |
| `Finalized` | plan is committed, children exist. Replaces `Status=Planned`. |
A parent task can now be `Status=Idle, PlanningPhase=Finalized` simultaneously, enabling re-runs of finalized plans without losing planning metadata.
#### `BlockedByTaskId` (nullable FK, new column) — replaces `Waiting`
- Today: `Status=Waiting` means "waiting on a predecessor in the chain".
- New: `Status=Queued` AND `BlockedByTaskId=<predecessor>`. Picker filters out any row with `BlockedByTaskId IS NOT NULL`.
- `ON DELETE SET NULL` — if predecessor is deleted, child becomes pickable.
### 2. `TaskStateService` (centralized state machine)
The only component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All other code goes through it.
```csharp
public interface ITaskStateService
{
Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct);
Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct);
Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct);
Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct);
Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct);
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct);
Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct);
}
public sealed record TransitionResult(bool Ok, string? Reason);
```
#### Allowed transitions
```
Idle → Queued | Running (RunNow)
Queued → Running | Cancelled | Idle (ResetToIdle)
Running → Done | Failed | Cancelled
Done → Idle (ResetToIdle, for re-run)
Failed → Idle | Queued (re-queue)
Cancelled → Idle | Queued
```
Anything else returns `TransitionResult(false, "invalid transition X→Y")`. No exceptions for invalid transitions — Result pattern keeps callers tolerant.
#### Invariants
1. **Atomic.** Each transition is a single `ExecuteUpdate` (or short tx) using `WHERE Status = <expected>` to be TOCTOU-free.
2. **Validated.** Source status is verified at the SQL level, not in C#.
3. **Side effects (after successful DB write):**
- On any `→ Queued`: `IQueueWaker.Wake()`.
- On any successful transition: `HubBroadcaster.TaskUpdated(taskId)`.
- On `Done`/`Failed`/`Cancelled` for a child task: `IPlanningChainCoordinator.OnChildFinishedAsync`, which calls `_state.UnblockAsync(nextChild)` and `TryCompleteParent` if applicable.
4. **No caller responsibility for side effects.** A caller only needs to invoke one method.
#### Caller migration
| Today | New |
|---|---|
| `TaskRunner.MarkRunningAsync` | `_state.StartRunningAsync` |
| `TaskRunner.HandleSuccess` (Mark + chain + parent) | `_state.CompleteAsync` (handles all) |
| `TaskRunner.HandleFailure` | `_state.FailAsync` |
| `StaleTaskRecovery.FlipAllRunningToFailedAsync` | `_state.RecoverStaleRunningAsync("worker restart")` |
| `PlanningChainCoordinator.QueueSubtasksSequentiallyAsync` (direct DbContext) | iterates children, calls `_state.EnqueueAsync` for first, `_state.BlockOnAsync` for rest |
| `TaskRepository.FinalizePlanningAsync` | **removed**; `PlanningSessionManager` orchestrates via state-service |
| `TaskResetService` (direct DbContext) | `_state.ResetToIdleAsync` (service only owns worktree-cleanup) |
`Mark*Async` repo helpers stay but become `internal` — used only by `TaskStateService`.
### 3. Queue dispatch & wake mechanics
Three classes, clear responsibilities.
#### `IQueueWaker`
```csharp
public interface IQueueWaker { void Wake(); }
```
- Singleton. Backed by today's `SemaphoreSlim`.
- Called automatically by `TaskStateService` after any `→ Queued` transition.
- Manual `WakeQueue()` calls in app code are removed (Hub `WakeQueue` SignalR endpoint stays for diagnostics but maps directly to `IQueueWaker.Wake`).
#### `IQueuePicker`
```csharp
public interface IQueuePicker
{
Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct);
}
```
- The single place where queue selection happens.
- Filter (all required):
- `Status == Queued`
- `BlockedByTaskId IS NULL`
- `(ScheduledFor IS NULL OR ScheduledFor <= :now)`
- `EXISTS task_tags WHERE name='agent'` OR `EXISTS list_tags WHERE name='agent'`
- Order: `SortOrder ASC, CreatedAt ASC`.
- Atomic claim via `UPDATE … RETURNING` (matching today's pattern), flips `Queued → Running` and writes `StartedAt`.
- Picker is the sole caller of `Queued → Running` transition. `TaskStateService.StartRunningAsync` exists for the override slot path (RunNow / Continue).
#### `QueueService` (BackgroundService) — slimmer
- Wait on wake-signal or backstop timer.
- Call `_picker.ClaimNextAsync`.
- If task: occupy queue slot, run via `_runner.RunAsync`, in `ContinueWith` invoke `_waker.Wake()` for the next pickup.
- No DbContext. No status mutation. No DTO knowledge.
#### `OverrideSlotService` (new)
- Owns `RunNow` and `ContinueTask` (today both in `QueueService`).
- Holds the override slot state.
- Status mutations go through `TaskStateService.StartRunningAsync` (non-atomic claim — caller-driven, fine because override is user-initiated and serialized by slot lock).
### 4. Planning chain integration
Single flow, replaces both `FinalizePlanningAsync` (Repo) and `QueueSubtasksSequentiallyAsync` (Coordinator).
1. `PlanningSessionManager.StartAsync(parentId)``_state.StartPlanningAsync` → parent `PlanningPhase=Active`.
2. User edits children in MCP tool. Children are in `Status=Idle`.
3. `PlanningSessionManager.FinalizeAsync(parentId)`:
- `_state.FinalizePlanningAsync(parentId)` → parent `PlanningPhase=Finalized, Status=Idle`.
- `_chainCoordinator.SetupChainAsync(parentId)`:
- Attaches `agent` tag to all children (automatic — confirmed in brainstorming).
- `_state.EnqueueAsync(children[0])` → wake fires.
- `_state.BlockOnAsync(children[i], children[i-1])` for `i ≥ 1`.
4. When a child finishes, `TaskRunner.HandleSuccess` calls `_state.CompleteAsync(child)`. State-service internally invokes `_chainCoordinator.OnChildFinishedAsync`, which calls `_state.UnblockAsync(nextChild)` (wake fires). Predecessor block goes away because of `ON DELETE SET NULL`-style logic in `UnblockAsync`.
5. When all children are terminal: `_state` runs `TryCompleteParent` and sets parent `Done`/`Failed` based on aggregate.
`TaskRepository.FinalizePlanningAsync` is **deleted**. `QueueSubtasksSequentiallyAsync` is renamed to `SetupChainAsync` and made internal to the coordinator (called only from `PlanningSessionManager.FinalizeAsync`).
### 5. `Worker/Services/` reorganization
```
Worker/
State/
ITaskStateService.cs
TaskStateService.cs
TransitionResult.cs
Queue/
IQueueWaker.cs
IQueuePicker.cs
QueuePicker.cs
QueueService.cs (BackgroundService, slimmer)
OverrideSlotService.cs
QueueSlotState.cs
Lifecycle/
StaleTaskRecovery.cs
TaskResetService.cs
TaskMergeService.cs
Worktrees/
WorktreeMaintenanceService.cs
Agents/
AgentFileService.cs
DefaultAgentSeeder.cs
Runner/ (unchanged)
Planning/ (ChainCoordinator simplified)
External/ (unchanged)
Hub/ (unchanged)
```
`WorkerHub` calls fewer services — typically `_state.X` plus a domain service for non-status work (Merge, Worktree-Cleanup).
### 6. EF migration
```sql
ALTER TABLE tasks ADD COLUMN planning_phase INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tasks ADD COLUMN blocked_by_task_id TEXT NULL REFERENCES tasks(id) ON DELETE SET NULL;
CREATE INDEX ix_tasks_blocked_by ON tasks(blocked_by_task_id);
UPDATE tasks SET status='idle' WHERE status='manual';
UPDATE tasks SET status='idle' WHERE status='draft';
UPDATE tasks SET status='idle', planning_phase=1 WHERE status='planning';
UPDATE tasks SET status='idle', planning_phase=2 WHERE status='planned';
```
`Waiting` migration uses a CTE with `LAG()` to derive `BlockedByTaskId` from `(parent_task_id, sort_order)`:
```sql
WITH ordered AS (
SELECT id,
LAG(id) OVER (PARTITION BY parent_task_id ORDER BY sort_order, created_at) AS prev_id
FROM tasks WHERE status='waiting'
)
UPDATE tasks SET status='queued',
blocked_by_task_id=(SELECT prev_id FROM ordered WHERE ordered.id=tasks.id)
WHERE id IN (SELECT id FROM ordered);
```
Migration runs at worker startup via the existing `MigrateAsync` flow.
`Down()` is best-effort (local-only app). Reverse mapping is lossy: `Cancelled``Failed`, `BlockedByTaskId``Waiting`, planning fields → folded back into status.
### 7. Test strategy
New test fixtures (xUnit, real SQLite, real git where needed):
1. **`TaskStateServiceTests`** — happy path + reject for every transition; mock `IQueueWaker`, `HubBroadcaster`, `IPlanningChainCoordinator` and verify side-effect invocations; concurrency test (two parallel `StartRunningAsync` → exactly one wins).
2. **`QueuePickerTests`** — filter logic (blocked, missing tag, future schedule, wrong status) and ordering (`sort_order, created_at`); two parallel pickers → exactly one claims a row.
3. **`PlanningChainCoordinatorTests`** — `SetupChainAsync` produces correct (`Queued`, `BlockedBy`) layout; `OnChildFinishedAsync` unblocks the next child; child failure leaves remaining blocked, parent transitions to `Failed` after `TryCompleteParent`.
4. **`PlanningEndToEndTests`** — regression for the original bug. `Active` parent + 3 drafts → `Finalize` → assert first child reaches `Running` within 200 ms with no manual `Wake`.
5. **Existing tests** — anything seeding `task.Status = TaskStatus.Manual` or similar gets updated to new enum values or routed through `_state`.
Coverage target: state machine + queue picker at ≥90% branch coverage. Existing coverage levels preserved elsewhere.
### 8. Implementation slices
Each slice is one PR with green tests before the next starts.
1. **Slice 1 — Status model + migration.** New enum values, new columns, EF migration. Existing code mapped to new values mechanically (no behavior change).
2. **Slice 2 — `TaskStateService`.** Service + interface + tests. Migrate TaskRunner, StaleTaskRecovery, ExternalMcp/PlanningMcp guards, TaskResetService. Mark `Mark*Async` repo helpers `internal`.
3. **Slice 3 — `IQueueWaker` + `IQueuePicker`.** Extract from QueueService and Repo. Remove all manual `WakeQueue()` calls in app code.
4. **Slice 4 — Planning flow consolidation.** Delete `FinalizePlanningAsync` from repo. `PlanningSessionManager.FinalizeAsync` orchestrates via state-service + ChainCoordinator. Rename `QueueSubtasksSequentiallyAsync``SetupChainAsync` (internal). E2E test green.
5. **Slice 5 — `OverrideSlotService` + folder reorg.** Extract RunNow / ContinueTask. Move files to new folder structure. Update DI registration.
6. **Slice 6 — Cleanup & docs.** Update `Worker/CLAUDE.md`, `docs/plan.md`. Remove dead helpers.
## Risks & Mitigations
- **EF migration on existing DBs.** Tested via integration tests that load a pre-migration fixture DB. `MigrateAsync` is already in production use, low risk.
- **State-service becomes a god-object.** Mitigated by keeping it narrow: only status/phase/blocked-by writes, no business logic. Worktree, merge, and runner concerns stay in their own services.
- **Two paths to `Running` (picker atomic, state-service for override).** Confirmed acceptable in brainstorming. Picker remains the only atomic-claim path; override slot is serialized by slot lock so non-atomic is safe.
- **Waiting-migration CTE.** SQLite supports `LAG()` since 3.25. .NET 8's bundled SQLite is well above. Tested in migration unit tests.
## Open Questions
None at design time. All knackpunkte resolved during brainstorming.

View File

@@ -0,0 +1,272 @@
# Tabbed Settings + Prime Claude — Design
**Date:** 2026-04-28
**Status:** Draft for review
## Goal
Two related UI changes:
1. Restructure the existing **Settings modal** from a single scrollable stack into a `TabControl` with focused tabs. Move the read-only "About" content out of Settings entirely, into a new modal accessible from the existing Help menu.
2. Add a new **Prime Claude** tab where the user defines date-bounded daily schedules. At each scheduled time, the worker fires a single non-interactive `claude -p "ping" --max-turns 1` call to start Claude's 5-hour usage window early — "priming" the day.
## Scope
### In scope
- Settings tabbed UI with 4 tabs: General, Worktrees, Files, Prime Claude.
- New About modal opened from `MainWindow` Help menu.
- New `PrimeSchedules` table, repository, EF migration.
- New `PrimeScheduler` background service (event-driven, no polling).
- New SignalR hub methods + client wiring.
- Footer notification on prime fire (success/failure) via `StatusBarView`.
- 30-minute catch-up window on app launch / wake.
- Tests: scheduler unit tests, tab VM tests.
### Out of scope
- Auto-start ClaudeDo at OS boot.
- Multiple pings per day per schedule.
- Per-schedule prompt customization (schema reserves the column for future use).
- Holiday / calendar integration.
- Toast notifications, sound, OS-level notifications.
## Settings tab layout
| Tab | Contents (existing sections, no field changes) |
|---|---|
| **General** | Claude Defaults: instructions, model, max turns, permission mode |
| **Worktrees** | Strategy, central root, auto-cleanup, Cleanup button, Force-remove confirm flow |
| **Files** | Agents (Restore default agents) + Prompts (System / Planning / Agent open-in-editor rows) |
| **Prime Claude** | New — schedule list + add button (see below) |
- Window stays 580×760, custom title bar preserved.
- Footer (Save / Cancel) preserved; Save iterates per-tab VMs.
- Status / validation strip stays above the footer.
- Tab strip uses the existing section-label style for headers (mono, 10pt, letter-spacing 1.4) so it visually matches the current aesthetic.
## About modal
New `AboutModalView` + `AboutModalViewModel`:
- Same 4 rows as today's About section: Version, Data folder, Logs folder, Worker config — each with an Open button.
- Compact dialog (~480×280), same chrome as `SettingsModalView`.
- Wired into `MainWindow` Help menu as a new `<MenuItem Header="About…">` next to "Check for updates".
- About content removed from `SettingsModalView` entirely (cleaner: not a setting).
## Prime Claude tab — UI
```
┌────────────────────────────────────────────────────────────────┐
│ Prime your Claude usage window each morning by firing a single │
│ non-interactive `ping` call at a chosen time. Only runs while │
│ ClaudeDo is open. If the app starts within 30 min of the target │
│ time, the ping fires immediately (catch-up window). │
├────────────────────────────────────────────────────────────────┤
│ ☑ May 5, 2026 → Jun 30, 2026 07:00 MonFri last: today ✕│
│ ☐ Jul 1, 2026 → Jul 7, 2026 09:30 All days — ✕│
├────────────────────────────────────────────────────────────────┤
│ [+ Add schedule] │
└────────────────────────────────────────────────────────────────┘
```
Per-row controls:
- Enabled checkbox (`Enabled`)
- Start date picker (`StartDate`)
- End date picker (`EndDate`)
- Time-of-day field (`TimeOfDay`, 24h, e.g. `07:00`)
- Workdays-only checkbox (`WorkdaysOnly`)
- Last run label (`{LastRunAt:g}` or `—` if null)
- Delete button (✕, with inline confirm bar matching the Worktrees pattern)
`+ Add schedule` appends a new row pre-filled with: today, today + 30 days, `07:00`, `WorkdaysOnly = true`, `Enabled = true`.
Validation per row:
- `StartDate <= EndDate`
- `TimeOfDay` parses as `HH:mm`
- `EndDate >= today` (else mark row disabled-looking + tooltip "expired")
Persistence: rows save with the rest of the modal on **Save**. On Save, `PrimeClaudeTabViewModel` diffs in-memory rows against the loaded snapshot and emits one hub call per change: `UpsertPrimeSchedule` for new/edited rows, `DeletePrimeSchedule` for removed rows. Cancel discards in-memory edits. No per-row autosave.
## Data model
New EF Core entity `PrimeScheduleEntity` in `ClaudeDo.Data/Models/`:
```csharp
public class PrimeScheduleEntity
{
public Guid Id { get; set; }
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public TimeSpan TimeOfDay { get; set; } // local clock
public bool WorkdaysOnly { get; set; }
public bool Enabled { get; set; }
public DateTimeOffset? LastRunAt { get; set; }
public string? PromptOverride { get; set; } // reserved, always null today
public DateTimeOffset CreatedAt { get; set; }
}
```
- New `PrimeScheduleConfiguration : IEntityTypeConfiguration<PrimeScheduleEntity>` in `Configuration/`.
- New repository `PrimeScheduleRepository` matching the existing async + CancellationToken pattern. Methods: `ListAsync`, `GetAsync(id)`, `UpsertAsync(entity)`, `DeleteAsync(id)`, `UpdateLastRunAsync(id, when)`.
- EF migration `AddPrimeSchedules` (auto-named per existing migration history).
## Worker scheduler — `PrimeScheduler`
New folder `ClaudeDo.Worker/Prime/`. Class hierarchy:
- `PrimeScheduler : BackgroundService` — event-driven loop.
- `IPrimeRunner` / `PrimeRunner` — fires the actual `claude -p "ping" --max-turns 1` call. Injected so tests can fake it.
- `IPrimeClock` / `PrimeClock``DateTimeOffset Now { get; }`. Faked in tests.
- `PrimeSchedulerOptions``CatchUpWindow = TimeSpan.FromMinutes(30)`. Hardcoded today; typed for swappability.
### Loop
```text
while not cancelled:
next = ComputeNextDue(now) # null if no enabled schedules
if next is null:
await wait-on-signal # blocks until schedules change
continue
delay = max(0, next.At - now)
try:
await Task.Delay(delay, linkedToken) # cancellable by signal
catch OperationCanceledException:
continue # schedules changed → recompute
await Fire(next.Schedule)
```
`ComputeNextDue(now)`:
- For each enabled schedule:
- Determine the next eligible date `d >= today` within `[StartDate, EndDate]`, honoring `WorkdaysOnly`.
- Skip the day if `LastRunAt.LocalDate == today` (already fired today).
- Build `target = d.At(TimeOfDay)` in local time.
- Apply catch-up: if `target < now <= target + 30min` and not already fired today, target = `now` (fire immediately).
- If `target < now` (past catch-up window) and `d == today`, advance `d` to next eligible date.
- Return the schedule with the smallest `target`.
### Signal source
`IPrimeScheduleSignal` — a thin abstraction wrapping a `CancellationTokenSource` reset. The hub calls `Signal()` on:
- App start (initial recompute is implicit — service first-run computes immediately).
- After `UpsertPrimeSchedule` / `DeletePrimeSchedule`.
- After a successful fire (so the next-due is recomputed without polling).
### Fire
`PrimeRunner.FireAsync(schedule, ct)`:
1. Resolve `claude` executable via existing `ClaudeProcess` discovery.
2. Spawn with `cwd = Paths.AppDataRoot()`, args `["-p", "ping", "--max-turns", "1"]`. No worktree, no task entity, no list/tag side effects.
3. Capture stdout/stderr; success = exit 0 within a 60s timeout.
4. On finish: `await PrimeScheduleRepository.UpdateLastRunAsync(id, now)`, append a one-line summary to `~/.todo-app/logs/prime.log`, broadcast `PrimeFired(success, message, timestamp)` via `HubBroadcaster`.
Failure modes (network, auth, executable missing) → broadcast a failure message; `LastRunAt` still stamped so the day doesn't keep retrying.
## SignalR / IPC
### Hub methods (`WorkerHub`)
```csharp
Task<IReadOnlyList<PrimeScheduleDto>> ListPrimeSchedules();
Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto);
Task DeletePrimeSchedule(Guid id);
```
DTO mirrors entity minus `CreatedAt` (server-managed).
### Hub events (broadcast)
```csharp
event PrimeFired(Guid scheduleId, bool success, string message, DateTimeOffset firedAt);
```
The `scheduleId` lets an open Settings modal update the matching row's `LastRunAt` without a full reload. No separate `PrimeSchedulesChanged` event — Settings is the only writer, so the modal's own VM state is authoritative until Save.
`WorkerClient` adds matching async methods + the event handler.
## UI wiring
### ViewModel split
`SettingsModalViewModel` stops holding field properties directly and becomes a coordinator:
```csharp
public sealed partial class SettingsModalViewModel
{
public GeneralSettingsTabViewModel General { get; }
public WorktreesSettingsTabViewModel Worktrees { get; }
public FilesSettingsTabViewModel Files { get; }
public PrimeClaudeTabViewModel Prime { get; }
[RelayCommand] private async Task Save() { ... iterate tabs, call SaveAsync on each ... }
}
```
Each tab VM:
- Owns its observable properties.
- Has `Task LoadAsync()` and `Task SaveAsync()` (or returns a partial DTO the coordinator merges).
- Owns its own validation, surfaces `ValidationError`.
`PrimeClaudeTabViewModel`:
- `ObservableCollection<PrimeScheduleRowViewModel> Rows`
- `[RelayCommand] AddSchedule()` / `RemoveSchedule(id)`
- Subscribes to `WorkerClient.PrimeSchedulesChanged` / `PrimeFired` to keep rows fresh while modal is open.
### Footer notification
`StatusBarViewModel`:
- New `string? PrimeStatus` property.
- Subscribes to `WorkerClient.PrimeFired`.
- On event: set `PrimeStatus`, start a `DispatcherTimer` for 5s, clear on tick.
- `StatusBarView` gets a `TextBlock` bound to `PrimeStatus`, right-aligned, dim-foreground, only visible when non-empty.
Format: `"✓ Primed Claude at 07:01"` or `"⚠ Prime failed: <reason>"`.
### About wiring
- `MainWindowViewModel` adds `[RelayCommand] OpenAbout()` — opens `AboutModalView` via the existing dialog factory pattern.
- `MainWindow.axaml` Help menu gains `<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>`.
## Tests
### `ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs`
Real SQLite, fake `IPrimeClock`, fake `IPrimeRunner`. Cases:
- Fires once at exact target time.
- Fires immediately on startup if within catch-up window.
- Skips firing if past catch-up window (waits for next eligible day).
- Honors `WorkdaysOnly` (no fire on Sat/Sun).
- Honors date range (no fire before StartDate, none after EndDate).
- Idempotent: doesn't double-fire if `LastRunAt` is today.
- Recomputes on signal (upsert mid-wait).
- Disabling a schedule mid-wait recomputes.
### `ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs`
Cases:
- Add row appends with sensible defaults.
- Remove row removes from collection.
- Validation: StartDate > EndDate flags row as invalid.
- Save serializes all rows to repository in one batch.
- `PrimeFired` event updates the matching row's `LastRunAt`.
### `ClaudeDo.Ui.Tests/ViewModels/StatusBarViewModelTests.cs` (extend existing if present, else new)
- `PrimeFired` sets `PrimeStatus` and clears it after 5s (use a fake `IDispatcherTimer` or an injectable delay).
## Migration / rollout
- Single EF migration `AddPrimeSchedules`. Existing DBs upgrade on next launch via the existing migration runner (no manual step).
- No data backfill — table starts empty. Users add schedules manually via the new tab.
- Backwards compatibility for `AppSettingsEntity`: untouched.
## Risks & mitigations
| Risk | Mitigation |
|---|---|
| App is closed at scheduled time | 30 min catch-up on launch; explicit copy in tab explains the limitation. |
| Clock/timezone change while waiting | `Task.Delay` fires on monotonic time; recompute after each fire catches drift on next iteration. Acceptable for a 5h-window primer. |
| Claude CLI hangs | 60s timeout on the spawn; failure stamped + broadcast. |
| Multiple ClaudeDo instances on same machine | Out of scope (existing app already assumes single instance via fixed SignalR port). |
| User edits schedule while scheduler is mid-fire | Fire completes, then signal triggers recompute. No race — `UpdateLastRunAsync` is the last write. |
## Open questions
None at design time. Implementation may surface small details (e.g. exact Avalonia controls for date/time pickers — likely `CalendarDatePicker` + a `TextBox` masked to `HH:mm` since Avalonia 12 has no built-in TimePicker on all platforms).

View File

@@ -0,0 +1,206 @@
# Worktree Overview Modal — Design
**Status:** Approved
**Date:** 2026-05-19
## Problem
Worktree management is becoming hard to oversee. The current UI only exposes per-task worktree actions (merge / keep / discard) from `TaskDetailView`, plus two global maintenance buttons (`CleanupFinishedWorktrees`, `ResetAllWorktrees`). There is no view that shows *all existing worktrees at a glance* with their state, age, branch, and diff stat. Stale or "phantom" worktrees (DB row but missing directory, or vice versa) have no targeted recovery path.
## Goals
- A modal that lists every worktree row from the DB, joined with task + list metadata.
- Two entry points: filtered to one list (List context menu), and global grouped by list (Help menu).
- Quick per-row actions hidden behind a right-click context menu.
- Targeted force-remove for stuck / phantom worktrees.
- Manual refresh only; no live SignalR subscription needed.
## Non-Goals
- No auto-refresh / live updates from SignalR events.
- No UI tests (the project has none for the Ui project).
- No changes to `WorktreeManager`, `TaskRunner`, or the existing per-worktree file-tree modal (`WorktreeModalView`) — it gets reused as the "Show diff" target.
## UI
### New view pair
`WorktreesOverviewModalView` + `WorktreesOverviewModalViewModel`, parallel to existing `WorktreeModalView` (which shows the *file tree inside one* worktree).
### Layout
```
┌─ Worktrees [List "Foo"] or Worktrees (all) ───────────────┐
│ [ Refresh ] [ Cleanup finished ] │
│ │
│ ▼ List Foo (global mode only) │
│ Title Branch State +/- Age │
│ Fix login bug claudedo/ab… Active +42-7 3h ago │
│ Add API … claudedo/cd… Merged +8 -0 1d ago │
│ ▼ List Bar │
│ … │
└──────────────────────────────────────────────────────────────┘
```
- `DataGrid` (or `ItemsControl` with Grid template) for rows.
- List-filtered mode: no group headers, just the table.
- Global mode: `Expander` per list with list name as header (default expanded).
- State as a colored badge — new `WorktreeStateColorConverter` analogous to `StatusColorConverter`:
- Active=Blue, Merged=Green, Discarded=Gray, Kept=Orange.
- Right-click on a row opens a `MenuFlyout` with all actions.
- Phantom rows (`PathExistsOnDisk == false`) get a small warning icon in the Path tooltip area.
### Default sort
State (Active first), then `CreatedAt` descending. Same inside each list group in global mode.
### Per-row context menu
| Item | Enabled when | Behavior |
|---|---|---|
| Show diff | always | Opens existing `WorktreeModalView` with `WorktreePath` set |
| Open in Explorer | `PathExistsOnDisk == true` | `Process.Start("explorer.exe", path)` |
| Jump to task | always | Closes modal, selects list + task in main window |
| Merge | `State == Active` | Calls existing `MergeTask` hub method |
| Discard | `State == Active` | `SetWorktreeState(taskId, Discarded)` |
| Keep | `State == Active` | `SetWorktreeState(taskId, Kept)` |
| Copy branch | always | Clipboard |
| Copy path | always | Clipboard |
| —————— | | (separator) |
| Force remove | `Task.Status != Running` | Confirmation dialog → `ForceRemoveWorktree(taskId)` (red label) |
### Bulk buttons (toolbar)
- **Refresh** — re-runs `GetWorktreesOverview`.
- **Cleanup finished** — `CleanupFinishedWorktrees(listId)`; in list-filtered mode acts on that list, in global mode on all.
### Entry points
- **List context menu** → "Worktrees anzeigen…" → opens modal in filtered mode (`listId` = the list).
- **Help menu** → "Worktrees" → opens modal in global mode (`listId = null`).
`MainWindowViewModel` gets `OpenWorktreesOverviewCommand(listId)` and `OpenWorktreesOverviewGlobalCommand()`, both using a DI `Func<WorktreesOverviewModalViewModel>` factory analogous to existing editor patterns.
## SignalR Contract
### New `WorkerHub` methods
```csharp
Task<IReadOnlyList<WorktreeOverviewDto>> GetWorktreesOverview(string? listId);
Task<bool> SetWorktreeState(string taskId, WorktreeState newState);
Task<ForceRemoveResultDto> ForceRemoveWorktree(string taskId);
```
`CleanupFinishedWorktrees` already exists — extend its signature to accept an optional `listId`:
```csharp
Task<CleanupResult> CleanupFinishedWorktrees(string? listId); // was: ()
```
`MergeTask` is reused unchanged.
### DTOs
```csharp
public sealed record WorktreeOverviewDto(
string TaskId,
string TaskTitle,
TaskStatus TaskStatus,
string ListId,
string ListName,
string Path,
string BranchName,
WorktreeState State,
string? DiffStat,
DateTime CreatedAt,
bool PathExistsOnDisk);
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
```
### Broadcasts
After successful `SetWorktreeState` and `ForceRemoveWorktree`, fire `HubBroadcaster.WorktreeUpdated(taskId)` so `TaskDetailView` (if open) refreshes. `CleanupFinishedWorktrees` already broadcasts; keep behavior, optionally batch.
### `WorkerClient` (UI)
Add wrapper methods for the four new/changed hub calls.
## Backend Changes
### `WorktreeMaintenanceService`
```csharp
public sealed record ForceRemoveResult(bool Removed, string? Reason);
public Task<IReadOnlyList<WorktreeOverviewRow>> GetOverviewAsync(string? listId, CancellationToken ct);
public Task<CleanupResult> CleanupFinishedAsync(string? listId, CancellationToken ct); // signature extended
public Task<ForceRemoveResult> ForceRemoveAsync(string taskId, CancellationToken ct);
```
- `GetOverviewAsync` — joins `worktrees × tasks × lists` (`AsNoTracking`), maps to DTO including `PathExistsOnDisk = Directory.Exists(path)`.
- `CleanupFinishedAsync(listId)` — same join as today but also filters `t.ListId == listId` when not null.
- `ForceRemoveAsync` — refactors existing `TryRemoveAsync(row, force: true, …)` into a single-row entry point shared with `ResetAllAsync`. Refuses when the task is currently `Running`, returning `ForceRemoveResult(false, "task is currently running")`. Otherwise removes the worktree directory, prunes, deletes the branch, deletes the DB row.
### `WorktreeRepository`
`SetStateAsync(string taskId, WorktreeState newState, CancellationToken ct)` already documented in CLAUDE.md. If absent, add it; if present, just expose it via the hub.
### Unchanged
`WorktreeManager`, `TaskRunner`, `WorktreeModalView`, all existing merge / cleanup flows.
## Data Flow
1. User opens modal → `WorkerClient.GetWorktreesOverviewAsync(listId)` → bind rows.
2. Refresh button → same call.
3. Per-row action → corresponding hub call → on success, update the affected row locally (no full reload).
4. Bulk Cleanup → hub call → full reload.
## Force-Remove Semantics
| Initial state | Result |
|---|---|
| Active, task not Running | Worktree dir removed, branch deleted, DB row deleted. Task remains in current status (Done/Failed/Idle). |
| Active, task Running | Refused with reason "task is currently running". |
| Merged / Discarded / Kept | Same removal path. |
| Phantom (dir missing) | DB row deleted, branch best-effort deleted. |
## Testing
New tests in `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs` (real SQLite, real git):
1. `GetOverviewAsync_returns_all_when_listId_null`
2. `GetOverviewAsync_filters_by_listId`
3. `GetOverviewAsync_flags_PathExistsOnDisk_false_for_phantom_row`
4. `CleanupFinishedAsync_filters_by_listId`
5. `ForceRemoveAsync_removes_active_worktree` (happy path incl. branch delete)
6. `ForceRemoveAsync_blocked_when_task_running`
7. `ForceRemoveAsync_removes_phantom_row`
UI verification (manual):
- Open from list context menu → only that list's rows.
- Open from Help menu → all lists grouped, default expanded.
- Force-remove an Active worktree → row vanishes, DB row gone, branch deleted.
- Force-remove while task Running → toast / dialog with reason, row unchanged.
- Cleanup finished in filtered mode → only finished rows of the selected list disappear.
- "Show diff" reuses existing `WorktreeModalView`.
## Files Touched
**New:**
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs`
- `src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs`
- `src/ClaudeDo.Worker/Worktrees/WorktreeOverviewDto.cs` (or extend an existing DTOs file)
**Modified:**
- `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs`
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- `src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs`
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` (Help menu entry, list context menu entry)
- `src/ClaudeDo.App/Program.cs` (DI registration of new VM)
- `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs`

View File

@@ -0,0 +1,118 @@
# Planning: Draft → Planned → Queue gate
**Date:** 2026-05-29
**Status:** Approved (design)
## Problem
When a planning parent is finalized, `PlanningChainCoordinator.SetupChainAsync` immediately
enqueues the entire child chain (child[0] runs, successors wait blocked on their predecessor).
There is no review step: a user cannot hold finalized subtasks in a "ready but not running"
state, and the "DRAFT" label in the UI is only a derived side effect
(`TaskRowViewModel.IsDraft => IsChild && Status == Idle`) with no gate behind it — a draft
child already satisfies `CanSendToQueue` and can be queued directly.
We want an explicit lifecycle for planning children:
- **Draft** — child of a plan still being built (parent `PlanningPhase == Active`). Not queueable.
- **Planned** — child of a finalized plan (parent `PlanningPhase == Finalized`), still `Idle`. Queueable.
Finalizing a plan promotes its children Draft → Planned **without** queuing anything. The user
then explicitly sends the plan to the queue, which builds the sequential chain (today's behavior,
just user-triggered). The gate is enforced in both the UI and the server so no path (UI, MCP,
external agents) can queue or run a Draft child.
## Decisions
- **Q1 — Finalize semantics:** Finalize auto-marks children **Planned** (not Draft); nothing is
queued until the user explicitly sends to queue. Draft exists only while the plan is unfinalized.
- **Q2 — Queue granularity:** A single **parent-level** "Send plan to queue" action queues all
Planned children as a sequential chain (reuses `SetupChainAsync`). No per-child queueing.
- **Q3 — Enforcement:** UI **and** server. The gate is a server invariant in `TaskStateService`,
so MCP / external agents are bound by it too.
- **Data model — Approach 1 (derive, no schema change):** Draft/Planned is a pure function of the
parent's `PlanningPhase`. No new column, no migration, no parent/child drift.
## Core invariant
No schema change. A child task's stage is derived from its parent's `PlanningPhase`:
| Parent `PlanningPhase` | Child (`Status = Idle`) | Queueable? |
|---|---|---|
| `Active` (plan being built) | **DRAFT** | no |
| `Finalized` | **PLANNED** | yes |
**Server invariant:** a child task (`ParentTaskId != null`) may transition `Idle → Queued` or
`Idle → Running` **only if** its parent's `PlanningPhase == Finalized`. Standalone (non-child)
tasks are unaffected.
A failed/cancelled child returning to `Idle` while its parent is still `Finalized` is therefore
"Planned" again and re-queueable — desired.
## Components
### Worker / server
1. **`TaskStateService` transition guard** — the single enforcement point. When a child task is
about to enter `Queued` or `Running`, look up the parent's `PlanningPhase`; if it is not
`Finalized`, return a failed `TransitionResult` (no exception — consistent with the existing
no-throw transition pattern). This covers:
- UI single-task enqueue (`SetTaskStatus → Queued`)
- `RunNow` (`StartRunningAsync`, `Idle → Running`)
- the queue picker's `Queued → Running` claim (defense in depth; a Draft child can't reach
`Queued` in the first place)
- MCP `UpdateTaskStatus(Queued)` / `RunTaskNow`
2. **Finalize stops auto-queuing** — audit every `FinalizeAsync(taskId, queueAgentTasks, ct)`
call site and pass `queueAgentTasks: false`. Known callers to update: the UI finalize command
and the planning-MCP finalize tool. After this, `FinalizeAsync` only flips the parent to
`Finalized` (children become Planned); `SetupChainAsync` is no longer invoked from finalize.
3. **New queue action** — add `WorkerHub.QueuePlan(parentTaskId)`
`PlanningChainCoordinator.SetupChainAsync(parentTaskId)`. Guarded so it only runs when the
parent is `Finalized`; otherwise returns a failure the UI surfaces. This is the user-triggered
replacement for the auto-chain.
### UI
4. **`TaskRowViewModel`**
- Add `ParentFinalized` (`bool`), set by `TasksIslandViewModel`.
- `IsDraft => IsChild && Status == Idle && !ParentFinalized`
- `IsPlanned => IsChild && Status == Idle && ParentFinalized`
- `CanSendToQueue` gains `&& (!IsChild || ParentFinalized)`
- Child badge renders `DRAFT` / `PLANNED` (drive off `IsDraft` / `IsPlanned`).
- Raise `PropertyChanged` for the new derived members from the relevant `On*Changed` hooks
(`OnStatusChanged`, `OnParentTaskIdChanged`, and a new `OnParentFinalizedChanged`).
5. **`TasksIslandViewModel`** — when building/refreshing rows, resolve each child's parent
`PlanningPhase` from the loaded task set and set `ParentFinalized`. If the parent is not in the
loaded set, default to `false` (Draft — the safe, non-queueable default).
6. **`DetailsIslandViewModel`**
- `CanEnqueue` for a selected child additionally requires the parent to be `Finalized`.
- Add a parent-level **"Send plan to queue"** command, enabled when the selected task is a
`Finalized` planning parent with at least one Planned (`Idle`) child and nothing already
queued/running; calls `QueuePlanAsync(parentId)`.
7. **`IWorkerClient` / `WorkerClient`** — add `QueuePlanAsync(string parentId)`. Update the test
fakes (UI + Worker test projects) to implement the new member.
## Testing
- **Worker (`TaskStateService`):** child enqueue/run rejected when parent `Active`; allowed when
parent `Finalized`. Standalone task enqueue still allowed. Picker skips/ignores draft children.
- **Worker (finalize):** `FinalizeAsync(..., queueAgentTasks: false)` flips parent to `Finalized`
and queues nothing; children remain `Idle`.
- **Worker (`QueuePlan`):** on a `Finalized` parent, builds the sequential chain (child[0]
unblocked + queued, successors blocked on predecessor); on a non-`Finalized` parent, fails.
- **UI VM (`TaskRowViewModel`):** Draft vs Planned derivation and `CanSendToQueue` gating across
parent phases; badge text.
- **UI VM (`DetailsIslandViewModel`):** `CanEnqueue` gating for children; "Send plan to queue"
enablement.
## Out of scope
- Per-child manual promotion while a plan is still being built (Draft → Planned without
finalizing). Promotion happens only via finalize.
- Per-child independent queueing (Q2 = parent-level chain only).
- Any database schema / migration change.

View File

@@ -0,0 +1,138 @@
# Repo Import List Helper — Design
**Date:** 2026-05-29
**Status:** Approved (pending spec review)
## Problem
Creating lists is one-at-a-time: click `+ New list`, then open List Settings to set the
working directory. Users with many repos under a few parent folders want to wire them all up
in one pass.
## Goal
A "list helper" that scans one or more parent folders for git repos, presents them as a
checklist, and bulk-creates a list (with `WorkingDir` pre-filled) for each ticked repo.
## Entry Points
1. **Help menu** — the title-bar dropdown in `MainWindow.axaml` that contains `About…`,
`Worktrees…`, etc. Add a new `MenuItem` `Add repos as lists…` wired to a command on
`MainWindowViewModel`.
2. **Lists island** — a small folder icon button beside the existing `+ New list` button in
`ListsIslandView.axaml`, wired to a command on `ListsIslandViewModel`.
Both open the same modal.
## Components
### `RepoScanner` (new, `ClaudeDo.Ui/Services` or `ClaudeDo.Data`)
Pure filesystem helper, no git library. Given a parent folder path, enumerates immediate
subdirectories and returns those that contain a `.git` entry (directory or file). Kept
separate from the VM so it is unit-testable.
```
IReadOnlyList<RepoCandidate> Scan(string parentFolder)
record RepoCandidate(string Name, string FullPath)
```
- Skips the parent itself; only immediate children are considered (non-recursive).
- `.git` may be a directory (normal repo) or a file (worktree/submodule) — both count.
- Returns empty on missing/unreadable folder rather than throwing.
### `RepoImportModalViewModel` (new, `ClaudeDo.Ui/ViewModels/Modals`)
Follows the existing modal-VM pattern (`CloseAction`, resolved from DI).
Dependencies:
- `IDbContextFactory<ClaudeDoDbContext>` — load existing lists' `WorkingDir` values (for the
"already added" check) and create new `ListEntity` rows. Same dependency
`ListsIslandViewModel` already uses.
State:
- `ObservableCollection<RepoImportItemViewModel> Repos` — the combined checklist.
- A set of parent folder paths already scanned (to de-dupe re-adds).
- `CreateCount` — computed count of ticked-and-new rows (drives the confirm button label).
Commands:
- `AddFolderAsync` — invokes the folder picker (via view code-behind callback, see below),
scans each chosen folder with `RepoScanner`, appends new candidates. De-dupes by full path
(case-insensitive) against rows already present.
- `CreateAsync` — for each ticked, non-existing row, create a `ListEntity` via
`ListRepository.AddAsync` (Name = folder name, WorkingDir = full path,
DefaultCommitType = `CommitTypeRegistry.DefaultType`, fresh `Guid` id, `CreatedAt` = now).
Then `CloseAction()`.
- `Cancel``CloseAction()`.
On load, fetch all existing lists once and capture their `WorkingDir`s into a case-insensitive
set; each appended candidate whose path is in that set is marked `AlreadyAdded`.
### `RepoImportItemViewModel` (new)
- `Name`, `FullPath` (display).
- `AlreadyAdded` (bool) — true if a list already points at this path.
- `IsChecked` ([ObservableProperty]) — defaults `true` for new repos. For already-added rows it
is forced `true` and the checkbox is disabled.
- `CanToggle` => `!AlreadyAdded` (binds to checkbox `IsEnabled`).
### `RepoImportModalView` (new, `ClaudeDo.Ui/Views/Modals`)
A `Window` styled like the other modals (header bar, body, footer), shown via
`ShowDialog(owner)`.
- **Header:** title `ADD REPOS AS LISTS` + close button.
- **Top of body:** `Add folder…` button.
- **Body:** scrollable `ItemsControl` over `Repos`. Each row = `CheckBox` (IsChecked two-way,
IsEnabled = `CanToggle`) + repo name + dim full path + `(already added)` label when
`AlreadyAdded`.
- **Footer:** `Create {CreateCount} lists` button (disabled when `CreateCount == 0`) + `Cancel`.
- Folder picker lives in the code-behind (mirrors `ListSettingsModalView.BrowseClicked`):
`OpenFolderPickerAsync` with `AllowMultiple = true`, results handed to the VM's
`AddFolderAsync`.
## Data Flow
1. User opens the modal from either entry point → modal loads existing lists' `WorkingDir`s.
2. User clicks `Add folder…` → picks one or more parent folders → `RepoScanner` finds repos →
rows appended (de-duped), already-added rows shown ticked+disabled.
3. User adjusts ticks → clicks `Create N lists`.
4. VM creates one `ListEntity` per ticked-new row via `ListRepository`.
5. Modal closes → the **caller reloads the Lists island** so new lists appear:
- Lists-island entry point: `ListsIslandViewModel.LoadAsync()`.
- Help-menu entry point: `MainWindowViewModel` reloads its `Lists` (the
`ListsIslandViewModel` instance) after the modal closes.
## DI / Wiring
- Register `RepoImportModalViewModel` (transient) alongside other modal VMs.
- Register `RepoScanner` if implemented as an injected service; a static helper needs no
registration.
- `ListsIslandViewModel` gains `Func<RepoImportModalViewModel, Task>? ShowRepoImportModal` and
an `OpenRepoImportCommand`, wired in `ListsIslandView.axaml.cs` (mirrors
`ShowListSettingsModal`).
- `MainWindowViewModel` gains the same `Func` + an `OpenRepoImportCommand`, wired in
`MainWindow.axaml.cs`.
## Error Handling
- Unreadable / missing folders: `RepoScanner` returns empty, no crash.
- Re-adding a folder already scanned: de-duped by path, no duplicate rows.
- Two ticked repos sharing a folder name: both created (list names are not unique) — acceptable.
- List creation failure (rare): best-effort per the existing pattern; do not block remaining
creations.
## Testing
- `RepoScanner` unit tests (the testable seam): a temp directory tree with a mix of git repos
(`.git` dir), a `.git`-file repo, plain folders, and an empty/missing parent. Assert only the
repo subfolders are returned and missing folders yield empty.
- VM-level "already added" logic and `CreateCount` can be exercised if a test seam is convenient,
but the filesystem scanner is the primary unit under test. UI wiring verified manually.
## Out of Scope (YAGNI)
- Recursive / deep scanning.
- Inline editing of the list name before creation.
- Setting model / system prompt / agent during import (tuned later per-list in List Settings).
- Picking repo folders directly (only parent-folder scan, per decision).

View File

@@ -0,0 +1,165 @@
# Worker per-user autostart (drop Windows service)
Status: approved 2026-05-29
Author: brainstorm session (mika kuns + Claude)
## Problem
The worker runs as a Windows **service** registered under `LocalSystem`. The worker
shells out to the `claude` CLI, whose authentication is stored per-user
(`%USERPROFILE%\.claude`). Under `LocalSystem` the worker uses the system profile and
cannot see the user's Claude login, so task execution fails. The installer even exposes a
"Current User" service-account radio that the backend rejects (`RegisterServiceStep`
fails the install). Net effect: the only installable configuration cannot authenticate
Claude.
## Goal
Run the worker as the logged-in **user** so it inherits the user's Claude auth, starting
automatically at logon and staying alive in the background (independent of the desktop
app, so Prime/scheduled tasks fire when the UI is closed).
## Decisions (locked)
1. **Lifetime:** background from logon, always — independent of the UI.
2. **Mechanism:** per-user **logon Scheduled Task** (`schtasks`), run only when the user is
logged on (no stored password), hidden, with restart-on-failure.
3. **No console window:** worker becomes `WinExe`; add a **Serilog rolling file sink** so
worker diagnostics aren't lost.
4. **App ensures running:** "Restart Worker" becomes process-based; on app startup, if
SignalR doesn't connect within a few seconds, the app launches the worker.
5. **Auto-migrate:** the installer detects and removes the old `ClaudeDoWorker` service,
then registers the task. Uninstall removes the task + kills the worker process.
## Non-goals
- Cross-account elevation (admin elevates as a *different* account than the interactive
user). Single-user / user-is-admin is assumed; the task targets the interactive user.
- Running the worker when no user is logged on (that's the whole point — it must be a user
session for Claude auth).
---
## Component changes
### ClaudeDo.Worker
- **`ClaudeDo.Worker.csproj`**: `<OutputType>WinExe</OutputType>`. Add packages
`Serilog.AspNetCore` and `Serilog.Sinks.File`.
- **`Program.cs`**:
- Remove `builder.Host.UseWindowsService(...)`.
- Configure Serilog file sink: path `<LogRoot>/worker-.log`, `rollingInterval: Day`,
`retainedFileCountLimit: 7`, shared write. `LogRoot` comes from `WorkerConfig`
(expand `~`). Wire via `builder.Host.UseSerilog(...)`.
- **Single-instance guard:** at startup create `new Mutex(true, @"Local\ClaudeDoWorker",
out var createdNew)`. If `!createdNew`, log "another worker instance is already
running" and exit 0. Hold the mutex for process lifetime. `Local\` namespace = per
user session, which is what we want.
- CLI preflight (`ClaudeCliPreflight`) behavior unchanged.
### ClaudeDo.Installer
- **New `Steps/RegisterAutostartStep.cs`** (`IInstallStep`, "Register Autostart"):
- Build a Task Scheduler **definition XML** (UTF-16) and register via
`schtasks /Create /TN "ClaudeDoWorker" /XML "<tmpfile>" /F`.
- XML shape:
- `Principals/Principal`: `UserId` = current interactive user
(`WindowsIdentity.GetCurrent().Name`), `LogonType=InteractiveToken`,
`RunLevel=LeastPrivilege`.
- `Triggers/LogonTrigger` with the same `UserId`.
- `Settings`: `Hidden=true`, `MultipleInstancesPolicy=IgnoreNew`,
`StartWhenAvailable=true`, `ExecutionTimeLimit=PT0S`,
`DisallowStartIfOnBatteries=false`, `StopIfGoingOnBatteries=false`,
`RestartOnFailure` with `Interval` (>= `PT1M`; Task Scheduler's minimum granularity
is one minute) and `Count=3`.
- `Actions/Exec/Command`: quoted path to `<installDir>/worker/ClaudeDo.Worker.exe`.
- The XML builder is a **pure function** (string in → XML string out) so it is unit
testable without admin.
- **`MigrateServiceStep`** (or folded into `RegisterAutostartStep` as a first phase):
detect the old service via `sc query ClaudeDoWorker`; if present, `sc stop` then
`sc delete` (poll for clearance like the old `RegisterServiceStep` did). No-op when the
service doesn't exist (fresh installs).
- **Rename `StopServiceStep``StopWorkerStep`, `StartServiceStep``StartWorkerStep`**,
reworked to be process/task based:
- Stop: `schtasks /End /TN ClaudeDoWorker` (ignore errors) + kill any
`ClaudeDo.Worker` process whose `MainModule.FileName` is under the install dir;
wait for exit. This unlocks `worker/` binaries before extract.
- Start: `schtasks /Run /TN ClaudeDoWorker` (preferred — launches as the task principal).
Used by fresh install (so the worker runs immediately rather than waiting for next
logon) and by Settings "restart".
- **`Pages/ServicePage/ServicePageViewModel.cs`**: remove `IsLocalSystem`/`IsCurrentUser`
radios and `ServiceAccount` usage. Keep SignalR port, Claude CLI path, "Start at logon"
toggle (`AutoStart`), restart delay (maps to task `RestartOnFailure/Interval`, clamped
to >= 1 min). Update `ServicePageView.xaml` accordingly. Remove `ServiceAccount` from
`InstallContext`.
- **`RegisterServiceStep.cs`**: deleted (replaced by `RegisterAutostartStep`).
- **Pipelines (`InstallPageViewModel`)**:
- Fresh: DownloadAndExtract → WriteConfig → InitDatabase → **RegisterAutostart** (incl.
migration no-op) → CreateShortcuts → WriteUninstallRegistry → WriteInstallManifest →
**StartWorker**.
- Update: **StopWorker** → DownloadAndExtract → **RegisterAutostart** (migrates old
service) → **StartWorker** → WriteInstallManifest → WriteUninstallRegistry.
- **DI (`App.xaml.cs`)**: register the renamed/new steps (concrete + `IInstallStep` where
needed, following the existing double-registration pattern).
- **`Core/UninstallRunner.cs`**: replace `sc delete ClaudeDoWorker` with
`schtasks /Delete /TN ClaudeDoWorker /F` and kill the worker process; also `sc delete`
the legacy service best-effort (in case an old service still lingers).
### ClaudeDo.Ui / ClaudeDo.App
- **New `Services/WorkerLocator.cs`**: resolve `<installDir>/worker/ClaudeDo.Worker.exe`
by walking up for `install.json` then registry `InstallLocation` (mirrors
`InstallerLocator`).
- **`ViewModels/IslandsShellViewModel.cs`**:
- `RestartWorkerService`: drop `System.ServiceProcess.ServiceController`. Kill worker
process(es) under the install dir, then `Process.Start(workerExe)`.
- **Ensure-running:** on startup, if the `WorkerClient` connection isn't established
within ~4s, launch the worker via `WorkerLocator` + `Process.Start`. Guarded so it
runs at most once per app session.
- Remove the `System.ServiceProcess` package reference / usings if no longer used.
---
## Data flow
- **Logon:** Task Scheduler starts `ClaudeDo.Worker.exe` in the user session → mutex
acquired → Serilog file logging → SignalR hub on `127.0.0.1:47821` → app connects.
- **App start with worker down:** app waits ~4s for SignalR; if absent, `Process.Start`
worker → mutex acquired → hub up → app connects.
- **Duplicate launch (task + app race):** second instance fails the mutex → logs → exits 0.
- **Restart Worker button:** kill worker proc → relaunch → mutex reacquired.
## Error handling
- `schtasks`/`sc` calls go through the existing `ProcessRunner`; non-zero exits surface as
`StepResult.Fail` with the captured output (except best-effort cleanup which is ignored).
- Worker single-instance: losing the mutex is a normal, non-error exit (code 0).
- App ensure-running: `Process.Start` failures are swallowed (the logon task is the primary
mechanism; the app launch is a convenience).
## Testing
- **Unit (no admin required):**
- Task-definition XML builder: asserts UserId, LogonType, Hidden, RestartOnFailure
interval clamping, quoted command path.
- `WorkerLocator`: path resolution via temp `install.json`.
- Migration decision: given `sc query` output (exists / not-found), decide stop+delete vs
no-op — keep the decision pure, mock `ProcessRunner` output.
- Restart-delay → task interval clamping (`< 1 min``PT1M`).
- **Manual verification (post-build, on this machine):**
1. Update from installed `1.0.2-alpha`: old service is removed (`sc query ClaudeDoWorker`
→ not found), task exists (`schtasks /Query /TN ClaudeDoWorker`), worker process runs
as the user, app connects, no console window.
2. Worker log file appears at `~/.todo-app/logs/worker-<date>.log`.
3. Kill worker → click Restart Worker in app → reconnects.
4. Close app, confirm worker still running (Prime/queue alive); reopen app → connects.
5. Log off / log on → worker autostarts.
6. Uninstall → task gone, worker process gone, (data kept unless opted out).
## Risks
- **Task restart granularity is minutes**, not the old seconds-level service restart. The
worker's own long-running resilience + the app ensure-running cover short gaps; acceptable.
- **Elevated installer must target the interactive user.** Using
`WindowsIdentity.GetCurrent().Name` is correct when the user elevates themselves (the
assumed single-user case). Documented non-goal otherwise.

View File

@@ -0,0 +1,125 @@
# External MCP — UI Parity for Start & Observe
**Date:** 2026-05-30
**Status:** Approved (design)
## Goal
Expand the always-on **External MCP server** (`ExternalMcpService`, exposed on
`cfg.ExternalMcpPort` under `/mcp`) so an external Claude session can **start and
observe** ClaudeDo work sessions end-to-end, reaching parity with the desktop UI
for those two concerns.
The server's purpose is deliberately scoped: **help the user start sessions and
observe them.** It is *not* a git/worktree console — branch merging, worktree
resets, and multi-turn continuation are things Claude does *inside* a task, so
they stay out of the tool surface.
## Scope
### In scope
**START — set up and launch a session**
- *(existing)* `AddTask`, `UpdateTask`, `UpdateTaskStatus` (Idle/Queued), `RunTaskNow`, `CancelTask`, `DeleteTask`
- **List management** — create / rename / delete lists; set working dir + default commit type
- **List & task config** — per-list defaults and per-task overrides for `model`, `system_prompt`, `agent_path`
- **Agents (read-only)** — list agent files and refresh, so Claude can choose a valid `agent_path`
- **Reset failed task** — discard the failed worktree and reset the row to Idle (the retry path)
**OBSERVE**
- *(existing)* `ListTaskLists`, `ListTasks`, `GetTask`
- **Run history** — read `task_runs` for a task (session id, tokens, turns, result, structured output, error)
- **Logs** — fetch a task's (or run's) log output
- **App settings (read-only)** — read worker app settings
### Out of scope (explicitly excluded)
- **Tags** — already removed from the system (migration `20260519044715_RemoveTags`); only the stale doc reference in `src/ClaudeDo.Worker/CLAUDE.md` needs deleting.
- **Multi-turn continue** (`--resume`) — Claude's own concern inside a task.
- **Worktree ops** — merge, merge targets, cleanup-finished, reset-all, force-remove, set-state.
- **Start planning session** — not needed via MCP.
- **App settings writes** — risky (e.g. flips permission mode); read-only only.
- **Agent file create/edit/delete** — not part of "starting a session".
## Approach (chosen: A)
**Reuse existing worker services; split the growing tool surface into focused
`[McpServerToolType]` classes.** No business logic is duplicated — each new tool
injects the same service the SignalR hub already uses, so MCP behavior stays
identical to the UI.
Adding ~12 tools to the single `ExternalMcpService` would push it past 600 lines
across eight unrelated jobs. Instead, organize tools by category, mirroring the
existing `External/` + `Planning/` layout:
| Class (new, in `External/`) | Tools | Backing service |
|---|---|---|
| `ExternalMcpService` *(existing, unchanged scope)* | task CRUD + run/cancel/status | `TaskRepository`, `QueueService`, `ITaskStateService` |
| `ListMcpTools` | `CreateList`, `RenameList`, `DeleteList`, `SetListWorkingDir` (name/dir/commitType) | `ListRepository` |
| `ConfigMcpTools` | `GetListConfig`, `SetListConfig`, `SetTaskConfig` (model/system_prompt/agent_path) | `ListRepository`, `TaskRepository.UpdateAgentSettingsAsync` |
| `RunHistoryMcpTools` | `ListRuns`, `GetRun`, `GetTaskLog` | `TaskRunRepository`, log file read |
| `AgentMcpTools` | `ListAgents`, `RefreshAgents` | `AgentFileService.ScanAsync` |
| `LifecycleMcpTools` | `ResetFailedTask` | `TaskResetService.ResetAsync` |
| `AppSettingsMcpTools` | `GetAppSettings` | `AppSettingsRepository.GetAsync` |
(Exact class grouping may be tuned during planning, but each class stays small
and single-purpose.)
## Architecture & wiring
The external MCP server is a **separate `WebApplication`** built in
`Program.cs` (≈ lines 188217) with its own DI container, distinct from the main
SignalR app. Shared singletons (`HubBroadcaster`, `QueueService`,
`ITaskStateService`, db factory, `WorkerConfig`) are injected by instance so both
apps act on the same runtime state.
Each new tool class must be:
1. Registered in the **external** builder (`externalBuilder.Services.AddScoped<…>()`),
alongside any newly required services (`TaskRunRepository`, `AgentFileService`,
`TaskResetService` + their dependencies).
2. Registered as tools via additional `.WithTools<T>()` calls on the external
`AddMcpServer()` chain.
No change to auth: the existing `ExternalMcpAuthMiddleware` (optional
`X-ClaudeDo-Key`, loopback-only otherwise) covers all tools uniformly. No
per-tool gating — the surface is read/observe + start, with the one borderline
write (`ResetFailedTask`) being a normal retry affordance.
## Data flow
- **Start:** Claude calls e.g. `CreateList``SetListConfig``AddTask(queueImmediately: true)`. Writes go through `ListRepository` / `TaskStateService`, which wake the queue and broadcast `ListUpdated` / `TaskUpdated` so the UI reflects changes live.
- **Observe:** Claude calls `ListTasks` / `GetTask``ListRuns` / `GetRun``GetTaskLog`. Pure reads from `TaskRepository` / `TaskRunRepository` and the log file at `TaskRunEntity.LogPath`.
- **Mutations broadcast** the same SignalR events the hub raises, keeping the desktop UI in sync.
## DTOs
- `RunDto` — projection of `TaskRunEntity`: `Id`, `RunNumber`, `SessionId`, `IsRetry`, `ResultMarkdown`, `StructuredOutputJson`, `ErrorMarkdown`, `ExitCode`, `TurnCount`, `TokensIn`, `TokensOut`, `StartedAt`, `FinishedAt`.
- `AgentDto` — from `AgentInfo` (`Name`, `Description`, `Path`).
- `ListConfigDto``Model`, `SystemPrompt`, `AgentPath` (reuse the shape already used by the hub).
- App-settings read reuses the existing `AppSettingsDto` shape (read-only subset is fine).
- Log fetch returns the file contents as a string (with a size cap / tail option decided in planning).
## Error handling
Follow the existing `ExternalMcpService` convention: throw
`InvalidOperationException` with a clear message for not-found / invalid-input /
illegal-state (e.g. "List {id} not found", "Cannot reset a non-failed task").
Reuse the guard patterns already present (required-field checks, status checks).
`ResetFailedTask` must refuse non-`Failed` tasks.
## Testing
Extend `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` (and add
sibling test files per new tool class) using the existing real-SQLite + real-git
integration pattern:
- List CRUD round-trips; rename/delete propagate; delete blocked/handled sensibly.
- List + task config set/get round-trips; clearing all three fields removes list config (matches hub behavior).
- Run history reads return correct projections; `GetTaskLog` returns file contents and errors cleanly when no log exists.
- `ResetFailedTask` succeeds on a Failed task and refuses other statuses.
- Agent listing reflects files on disk after refresh.
- App-settings read returns current values.
## Doc cleanup (part of this work)
- `src/ClaudeDo.Worker/CLAUDE.md` — remove the stale `SetTaskTags` / `ListTags` /
"AddTask (with tags)" claim; replace the External MCP tool inventory with the
new surface.

View File

@@ -0,0 +1,96 @@
# UI Normalization & Single Source of Truth — Design
Date: 2026-05-30
Status: Approved
## Goal
Make working on the ClaudeDo UI simpler by establishing the design tokens as the single source of truth for **every** visual value, eliminating duplicated styles, and providing reusable helpers for the patterns that are currently copy-pasted across views. Accept minor visual shifts where current values don't match the token scale — consistency is the priority over pixel-preservation.
## Scope decisions (locked)
- **Lane C (full normalization)** — global defaults + shared helpers + tokenize every hardcoded font/spacing/radius/color.
- **Normalization strategy: B (snap to existing scale).** Stray values round to the nearest existing token; off-palette colors fold into the closest design brush. The token vocabulary stays small; the UI shifts slightly in places and is verified by human eyeball.
- Badge colors collapse to palette (option A): blue is dropped.
## 1. Global defaults — `src/ClaudeDo.App/App.axaml`
Add application-level default styles so unstyled controls inherit the intended look instead of falling back to FluentTheme's Segoe UI:
- Default `FontFamily` = `{DynamicResource SansFont}` (Inter Tight) for text-bearing controls (`TextBlock`, `TextBox`, `Button`, `ComboBox`, `CheckBox`, `NumericUpDown`, `TabItem`).
- Default `FontSize` baseline = `{StaticResource FontSizeBody}` (13) where a control has no more specific style.
- Controls that need mono (`MonoFont`) continue to opt in explicitly via their class/style.
This single change fixes the Settings modal font and every other bare-Segoe-UI label across the app.
## 2. Tokens = source of truth — `src/ClaudeDo.Ui/Design/Tokens.axaml`
### Fonts — snap to the existing scale
Existing tokens: Eyebrow=10, Mono=11, Micro=11, Body=13, TaskTitle=14, H3=18, H2=24, H1=32.
- `9 → 10` (FontSizeEyebrow)
- `12 → 13` (FontSizeBody)
- `16 → 18` (FontSizeH3)
- Every `FontSize="N"` literal across all views/styles becomes a `{StaticResource FontSize*}` reference. No new size tokens are added.
### Spacing / radius — snap to the existing scale
- Modal body padding `16` / `20 → 18` (SpaceXl); the vertical component `12` stays `SpaceMd`.
- Corner radius `4 → 6` (ButtonCornerRadius).
- Text inputs (TextBox) standardize on `InputCornerRadius` (8); the `6` currently on DetailsIslandView TextBoxes moves to 8.
### Colors — fold off-palette into the palette
Add semantic brushes where a recurring role genuinely needs one, but reuse existing palette brushes wherever possible:
- **Connection-status dots** (MainWindow): green `#4CAF50``StatusRunningBrush`; amber `#FFA726``StatusReviewBrush`; red `#EF5350``StatusErrorBrush`. Also applies to the `#EF5350` literals in WorktreesOverviewModal.
- **Planning/draft badges** (IslandStyles `DraftBadgeBrush`/`PlanningBadgeBrush`/`PlannedBadgeBrush`): re-point to palette — draft → `TextMuteBrush`, planning → `PeatBrush`, planned → `SageBrush`. Blue dropped.
- **Named-color literals:** `OrangeRed` / `Orange``BloodBrush`; `White``TextBrush` (or `DeepBrush` where it sits on an accent fill, e.g. primary button text).
- **Terminal background** `#FF080C0B` (terminal + task-live-tail) → `VoidBrush` (`#FF0A0E0C`).
- **Status alpha-tints:** the repeated `#1F<hue>` fills and `#4C<hue>` borders used by chips and agent-strips become named brushes defined once in Tokens (e.g. `RunningTintBrush` / `RunningTintBorderBrush`, and the same for review/error/queued), then referenced from IslandStyles. The `#26<hue>` worktree-badge tints and `#147C9166` agent-strip tints fold into the same named tint family (snap the alpha to one value per family).
- **Island hairline overlay** `#0DFFFFFF` → a named `HairlineOverlayBrush` token.
## 3. Shared helpers
### `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
Promote the styles currently copy-pasted into modals into the shared stylesheet, then delete the per-modal copies:
- `Button.primary` — standardize on **one** definition: `AccentDimBrush` background + `AccentBrush` border + `TextBrush` foreground (matching the existing `Button.btn.primary` variant). Resolves the AccentBrush-vs-AccentDimBrush divergence.
- `Button.danger``BloodBrush` background + `TextBrush` foreground.
- `TextBlock.field-label` — FontSize Micro (11), `TextDimBrush`, bottom margin 4.
- `TextBlock.section-label` already exists in IslandStyles; remove the duplicate local copies.
### New control: `ModalShell` (`src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml`)
A reusable `TemplatedControl` / `UserControl` providing the chrome every modal re-implements:
- Title bar: mono uppercase title (FontSize Mono, LetterSpacing 1.4), draggable region, ✕ close button (`icon-btn`).
- Outer border (SurfaceBrush bg, LineBrush border, ModalCornerRadius).
- Content slot for the body.
- Optional footer slot for action buttons (right-aligned).
- Exposes: `Title` (string), `Body` content, `Footer` content, and a `CloseCommand`.
The 8 modal windows (Settings, ListSettings, Merge, About, UnfinishedPlanning, RepoImport, Diff, PlanningDiff, ConflictResolution) migrate to wrap their content in `ModalShell` instead of re-declaring titlebar/border/footer grids. Window-level concerns (Width/Height, KeyBindings, WindowDecorations) stay on the `Window`; only the inner chrome is replaced.
## 4. Bug fixes (folded into the migration)
- `TaskRowView.axaml` schedule flyout: `BorderBrush="{DynamicResource BorderBrush}"``{DynamicResource LineBrush}` (the `BorderBrush` key does not exist in Tokens; current runtime resource-not-found).
- `DiffModalView.axaml`, `PlanningDiffView.axaml`, `ConflictResolutionView.axaml`: convert all `{StaticResource <token>}` references to `{DynamicResource <token>}` to match the rest of the app and survive theme changes. (Style-internal `Setter` references that must stay `StaticResource` for Avalonia reasons are left as-is; only token lookups in element attributes are converted.)
## 5. Verification
- `dotnet build` per project (`.slnx` requires .NET 9 — build individual csproj):
- `src/ClaudeDo.App/ClaudeDo.App.csproj` (pulls in Ui + Data)
- `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
- A clean build confirms XAML compiles and all resource keys resolve (compiled bindings + StaticResource keys are validated at build time).
- Human visual pass: launch the app and walk each view/modal against a per-view checklist (provided with the plan), since lane B intentionally shifts some values. The eyeball is the regression check.
## Sequencing
1. Tokens.axaml: add new named brushes (tints, status, hairline), re-point badge brushes. (No behavior change yet.)
2. App.axaml: global font/size defaults.
3. IslandStyles.axaml: promote shared styles (primary/danger/field-label), replace internal hardcoded values with token refs.
4. Per-view migration: replace every hardcoded FontSize/spacing/radius/color with token refs; snap stray values.
5. ModalShell control + migrate the 8 modals.
6. Bug fixes (BorderBrush key, Static→Dynamic in the three views).
7. Build all projects; produce visual-check checklist.
## Out of scope
- No layout/structure redesign — only values and shared chrome.
- No new features.
- No changes to ViewModels or behavior (ModalShell migration is markup-only; existing `CancelCommand` etc. bind through unchanged).

View File

@@ -0,0 +1,123 @@
# Waiting for Review — Task State — Design
**Date:** 2026-06-01
**Status:** Approved (brainstorming)
**Scope:** `ClaudeDo.Data` (TaskEntity, EF config + migration), `ClaudeDo.Worker` (TaskStateService, TaskRunner, QueueService, WorkerHub, ExternalMcpService), `ClaudeDo.Ui` (StatusColorConverter, TaskRowViewModel, views), CLAUDE.md docs
## Problem
A successful task run currently transitions straight to `Done` and is considered complete. There is no gate for a human (or another agent) to review the result before it is accepted. We want review to be a mandatory step: after a successful run a task waits for an explicit approval, and a reviewer can send it back with feedback for another turn.
## Goals
- Add a `WaitingForReview` lifecycle state that a task enters automatically after a **successful** run.
- Reviewer can **approve** (→ `Done`), **reject-and-re-run** (→ `Queued`, resuming the same Claude session with required feedback), **reject-and-park** (→ `Idle`), or **cancel** (→ `Cancelled`).
- Reject-and-re-run reuses the existing session-resume mechanism so the agent continues with full context.
- Both the desktop UI and the external MCP surface can perform review actions.
## Non-Goals
- No change to the failure path: a **failed** run still goes straight to `Failed`, never to `WaitingForReview`.
- No change to planning-phase finalization. A planning parent that generates child tasks keeps its current behavior and does **not** route through review. Only ordinary executable runs (`Running` → success) are affected.
- No change to worktree state flow (`Active | Merged | Discarded | Kept`).
- No change to the in-run auto-retry-on-failure behavior; only the *final* successful completion routes to review.
## Design
### 1. State machine
Changed/added transitions in **bold**:
| From | To | Trigger |
|---|---|---|
| Idle | Queued | enqueue (unchanged) |
| Queued | Running | queue picker claim (unchanged) |
| Running | **WaitingForReview** | **successful run (was → Done)** |
| Running | Failed | failed run (unchanged) |
| Running | Cancelled | cancel during run (unchanged) |
| **WaitingForReview** | **Done** | **approve** |
| **WaitingForReview** | **Queued** | **reject + required feedback → resume re-run** |
| **WaitingForReview** | **Idle** | **reject → park for manual edit** |
| **WaitingForReview** | **Cancelled** | **abandon an almost-done task** |
| Done \| Failed \| Cancelled | Idle | reset (unchanged) |
### 2. Data model
`ClaudeDo.Data`:
- `TaskStatus` enum (`Models/TaskEntity.cs`): add `WaitingForReview` after `Running`.
- EF string converter (`Configuration/TaskEntityConfiguration.cs`): map `WaitingForReview``"waiting_for_review"` (TEXT column, no schema constraint to change).
- New nullable column **`ReviewFeedback : string?`** on `TaskEntity`. Holds the reviewer's rejection comment until the re-run consumes it, then it is cleared. Persisted so it survives a worker restart and is visible to the UI.
- One EF migration: add the `review_feedback` column. No backfill — the new status value and column are only written going forward.
### 3. Worker — status transitions (`State/TaskStateService.cs`)
`TaskStateService` remains the sole owner of status writes. New/changed methods:
- `SubmitForReviewAsync(taskId)``Running``WaitingForReview`. Sets `FinishedAt` and `Result` exactly as `CompleteAsync` does today. Called by `TaskRunner` on success **instead of** `CompleteAsync`. (`CompleteAsync` is retained for the approve path.)
- `ApproveReviewAsync(taskId)``WaitingForReview``Done`.
- `RejectToQueueAsync(taskId, feedback)``WaitingForReview``Queued`. Rejects empty/whitespace feedback with a failed `TransitionResult`. Stores `feedback` in `ReviewFeedback`. Wakes the queue.
- `RejectToIdleAsync(taskId)``WaitingForReview``Idle`. Parks for manual editing; leaves `Result` intact, clears `ReviewFeedback`.
- `CancelAsync` — extend the allowed source states to include `WaitingForReview`.
Each transition broadcasts `TaskUpdated` as today. Invalid source states return a failed `TransitionResult` (no throw), matching existing convention.
### 4. Resume-aware re-run (`Queue/QueueService.cs`)
The queue picker still atomically claims a `Queued`, unblocked task (`UPDATE … SET status='running' … RETURNING *`). The `RETURNING` row already carries `ReviewFeedback`. After a successful claim, `QueueService` branches:
1. **`ReviewFeedback` set + latest run has a `SessionId`** → `TaskRunner.ContinueAsync(task, feedback)``--resume {sessionId}` with `feedback` as the next-turn prompt.
2. **`ReviewFeedback` set, no prior `SessionId`** (edge case) → `TaskRunner.RunAsync` with the feedback appended to the task prompt, so the comment is not lost.
3. **No `ReviewFeedback`** → normal `TaskRunner.RunAsync` (fresh session).
`ReviewFeedback` is cleared once consumed (single UPDATE), so a later re-run does not re-apply stale feedback.
### 5. External MCP surface (`External/ExternalMcpService.cs`)
- New tool **`review_task(taskId, decision, feedback?)`**, `decision ∈ {approve, reject_rerun, reject_park, cancel}`. `feedback` is required when `decision = reject_rerun` (validation error otherwise). Maps onto the `TaskStateService` methods in §3. This lets automation / other agents act as reviewers.
- `get_task_status_values` — add `WaitingForReview` with a description covering the four exit actions.
- `list_tasks` status-filter parsing and validation message — include `WaitingForReview`.
- `get_task` lifecycle description text — update to `Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled`.
- `update_task_status` stays restricted to `Idle` and `Queued`; all review decisions go through `review_task` (keeps the "set status freely" affordance and the review affordance distinct).
### 6. Worker hub (`Hub/WorkerHub.cs` + `Hub/HubBroadcaster.cs`)
New hub methods called by the UI, each delegating to `TaskStateService`:
- `ApproveReview(taskId)`
- `RejectReviewToQueue(taskId, feedback)`
- `RejectReviewToIdle(taskId)`
Cancel already exists. No new broadcast events — `TaskUpdated` covers it.
### 7. UI (`ClaudeDo.Ui`)
- `Converters/StatusColorConverter.cs`: add a `waiting_for_review` case. Snap to an existing color token from the scale; final visual pass is left to the user (per project convention — centralize/tokenize, user does the visual pass).
- `ViewModels/Islands/TaskRowViewModel.cs`: add `IsWaitingForReview` computed property and commands **Approve**, **RejectRerun**, **RejectPark**, **Cancel** (the last reuses the existing cancel command). Commands are enabled only when `Status == WaitingForReview`.
- Reject-Rerun opens a small flyout/dialog with a required multi-line feedback text box; on confirm it calls `RejectReviewToQueue(taskId, feedback)`.
- Wire the commands to the new SignalR client methods.
### 8. Docs
Update the status flow in:
- root `CLAUDE.md` — "Task status flow" line.
- `src/ClaudeDo.Data/CLAUDE.md` — TaskEntity status list.
- `src/ClaudeDo.Worker/CLAUDE.md` — status-model transition table.
## Testing
`ClaudeDo.Worker.Tests` (real SQLite + real git, existing harness):
- `SubmitForReviewAsync`: a successful run lands in `WaitingForReview`, not `Done`.
- `ApproveReviewAsync`: `WaitingForReview``Done`.
- `RejectToQueueAsync`: empty feedback rejected; valid feedback stored in `ReviewFeedback` and status → `Queued`.
- `RejectToIdleAsync`: → `Idle`, `Result` preserved, `ReviewFeedback` cleared.
- `CancelAsync` from `WaitingForReview``Cancelled`.
- Invalid source states (e.g. approve from `Idle`) return a failed `TransitionResult`.
- Resume-aware re-run: a task with `ReviewFeedback` + a prior `SessionId`, when claimed, resumes the session with the feedback as the prompt and clears `ReviewFeedback`.
- `review_task` MCP tool: each decision maps to the correct transition; `reject_rerun` without feedback errors.
## Open questions
None outstanding. Planning-task exclusion (Non-Goals) is the one assumption to verify against the planning-finalization code path during implementation; if planning finalization shares `CompleteAsync`, route only the executable-run success site through `SubmitForReviewAsync`.

View File

@@ -0,0 +1,153 @@
# Worker Lifecycle Redesign
**Date:** 2026-06-01
**Status:** Approved (design)
## Problem
The worker process has multiple competing owners, which collide in development and
muddy production behavior:
- The App auto-spawns its own worker on startup (`EnsureWorkerRunningAsync`,
`IslandsShellViewModel.cs:310`, called at line 224) ~4s after launch if it isn't
yet connected. In the IDE "Start Everything" multilaunch — which already runs the
worker via the `http` launch profile (`dotnet run`) — this produces a *second*
worker that fails to bind to `127.0.0.1:47821` and dies, surfacing a stray console
with a "failed to bind to address" error.
- Production autostart uses a per-user logon **Scheduled Task** (`RegisterAutostartStep`
+ `ScheduledTaskXml`), which the user wants to replace with a simpler Startup-folder
shortcut.
- When the App can't reach the worker, the only feedback is a silent "Offline" pill in
the footer — no guidance to the user.
## Goal
Establish a single owner for the worker lifecycle and make connection failures
actionable:
1. The worker is owned **externally** — a per-user **Startup-folder shortcut** in
production (replacing the Scheduled Task), or the IDE in development.
2. The App **only connects**; it never auto-spawns a worker.
3. When the App can't connect, it shows a one-time prompt offering **Start Worker**,
**Rerun Installer**, or **Dismiss**, plus a clickable Offline pill to reopen it.
## Non-Goals
- No change to the IDE dev setup. The "Start Everything" multilaunch keeps running the
worker via the `http` profile (console with live logs); the duplicate/bind-error
worker disappears purely because the App no longer auto-spawns. Rider run configs live
in `.idea/.../workspace.xml` (per-user, gitignored) and are out of scope.
- No change to the SignalR hub URL, port, reconnect policy, or the worker's
single-instance mutex.
## Design
### Component 1 — Installer: Scheduled Task → Startup-folder shortcut
**`RegisterAutostartStep`** (`src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs`)
- Replace the task-XML build + `schtasks /Create` with creation of a `.lnk` in the
per-user Startup folder (`Environment.SpecialFolder.Startup`) targeting
`{InstallDirectory}\worker\ClaudeDo.Worker.exe`. The worker is `WinExe`, so it launches
with no console window.
- **Migration:** keep the existing legacy Windows-service removal, and **add** removal of
the old scheduled task: `schtasks.exe /Delete /TN "ClaudeDoWorker" /F` (best-effort),
so existing installs migrate cleanly to the shortcut model.
**`StartWorkerStep`** (`src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`)
- Replace `schtasks /Run /TN ClaudeDoWorker` with a direct
`Process.Start(new ProcessStartInfo(workerExe) { UseShellExecute = true })`.
**`StopWorkerStep`** (`src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`)
- Drop the `schtasks /End` call. Keep the existing install-dir-scoped process kill, which
is the real stop mechanism.
**`UninstallRunner`** (`src/ClaudeDo.Installer/Core/UninstallRunner.cs`)
- Keep the existing `schtasks /Delete` and `sc delete` (migration/legacy cleanup).
- **Add** deletion of the Startup-folder `.lnk` alongside the existing Start Menu /
Desktop shortcut removal.
**Shared shortcut helper**
- Extract the `IShellLink` COM interop currently embedded in `CreateShortcutsStep` into a
shared `src/ClaudeDo.Installer/Core/ShortcutFactory.cs` (`CreateShortcut(path, target,
workingDir, description)`). Both `CreateShortcutsStep` and `RegisterAutostartStep` use it.
**Cleanup**
- Delete `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs` once unreferenced.
The autostart shortcut name and location: `ClaudeDo Worker.lnk` in
`Environment.SpecialFolder.Startup`, working directory `{InstallDirectory}\worker`.
### Component 2 — App: stop auto-spawning the worker
**`IslandsShellViewModel`** (`src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`)
- Remove the `_ = EnsureWorkerRunningAsync();` call (line 224) and the
`EnsureWorkerRunningAsync` method + its `_ensureRunningAttempted` flag.
- Keep the worker-launch logic (`RestartWorkerService`, which finds the worker exe via
`WorkerLocator` and starts it) — it becomes the backing action for the prompt's
**Start Worker** button. The existing `RestartWorkerAsync` command stays.
### Component 3 — App: connection-failure prompt
**New dialog** `WorkerConnectionModalViewModel`
(`src/ClaudeDo.Ui/ViewModels/Modals/WorkerConnectionModalViewModel.cs`) +
`WorkerConnectionModalView` (`src/ClaudeDo.Ui/Views/Modals/`).
- Buttons: **Start Worker**, **Rerun Installer**, **Dismiss**.
- Uses the established dialog pattern: a `Func<WorkerConnectionModalViewModel, Task>`
hook on `IslandsShellViewModel` set by `MainWindow` (mirroring `ShowAboutModal`), and
the dialog resolves a `TaskCompletionSource` on button press.
- **Start Worker** → `WorkerLocator.Find()` + `Process.Start` (reuse the
`RestartWorkerService` path). **Rerun Installer**`InstallerLocator.Find()` + launch
+ `Environment.Exit(0)` (same pattern as the existing `UpdateNow` command).
**Dismiss** → close.
**Trigger logic** (in `IslandsShellViewModel`)
- A one-shot grace timer (~12s) started on construction/startup. When it elapses, if the
worker is still offline (`IsOffline` — not connected and not reconnecting) and the
prompt hasn't been shown yet (`_connectionPromptShown`), show the dialog once and set
the flag.
- If the worker connects before the grace elapses, the prompt is never shown.
**Clickable Offline pill** (`src/ClaudeDo.Ui/Views/MainWindow.axaml`)
- Turn the footer status pill into a button bound to a command that opens the same dialog
on demand (independent of the one-shot flag), so the user can reopen guidance anytime
while offline.
### Component 4 — Dev
No code change (see Non-Goals).
## Data Flow
```
Startup (production):
Windows logon -> Startup-folder .lnk -> ClaudeDo.Worker.exe (WinExe, mutex-guarded)
App launches -> WorkerClient connects to 127.0.0.1:47821
connected within grace -> Online pill, no prompt
still offline after ~12s -> WorkerConnectionModal (once)
User clicks Offline pill (anytime offline) -> WorkerConnectionModal
Start Worker -> Process.Start(worker exe)
Rerun Installer -> Process.Start(installer), Environment.Exit(0)
Dismiss -> close
```
## Error Handling
- Worker exe / installer not found (`Locator.Find()` returns null): the corresponding
dialog button is a no-op (consistent with existing `UpdateNow` behavior); the dialog
stays open so the user can pick another action.
- Startup-shortcut creation failure in the installer: surfaced as a failed install step
(`StepResult.Fail`), same as the current task-registration failure path.
- Legacy scheduled-task deletion is best-effort and never fails the install.
## Testing
- **`Installer.Tests`**: `RegisterAutostartStep` creates the Startup `.lnk` at the
expected path with the correct target, and issues the legacy-task delete command.
`UninstallRunner` removes the Startup `.lnk`.
- **`Ui.Tests`**: prompt trigger logic — grace elapsed while offline shows the prompt
exactly once; a connection established before grace suppresses it; the clickable-pill
command opens the dialog regardless of the one-shot flag. (Abstract the dialog-show
hook so it can be asserted without real UI.)
- **Manual**: dialog buttons (Start Worker / Rerun Installer / Dismiss) and the clickable
Offline pill in a running App.

View File

@@ -0,0 +1,116 @@
# Prime: recurring weekday schedule
**Date:** 2026-06-02
**Status:** Approved
## Problem
The Prime feature fires a single non-interactive "ping" prompt to warm up the
Claude usage window. Today a schedule is defined by a **date range**
(`StartDate`/`EndDate`) plus a `TimeOfDay` and a single `WorkdaysOnly` toggle.
This is awkward for the real use case: the user wants a *recurring* morning ping
on specific weekdays, not a bounded calendar window.
Desired behavior: pick the **days of the week** (e.g. MonFri) and a **time**.
The schedule recurs forever. Whenever the worker is running and it is one of the
selected days, the ping fires at (or shortly after) the chosen time. Concretely:
the worker autostarts on login, detects it is an eligible day around the target
time, and fires the ping.
## Decisions
- **Catch-up window:** unchanged. Keep the existing 30-minute catch-up — if the
worker boots within 30 min after the target time, the ping fires immediately;
otherwise it waits for the next eligible day. (User chose "keep current 30 min".)
- **Day picker UI:** seven compact **toggle buttons** in one row (Mo Tu We Th Fr
Sa Su), highlighted when selected — not labeled checkboxes.
## Design
### 1. Data model
`PrimeScheduleEntity` (`ClaudeDo.Data/Models`):
- **Remove:** `StartDate`, `EndDate`, `WorkdaysOnly`
- **Add:** `Days` — a `[Flags] enum PrimeDays` (`Monday=1, Tuesday=2, Wednesday=4,
Thursday=8, Friday=16, Saturday=32, Sunday=64`), stored as a single
`days_of_week INTEGER` column.
- **Keep:** `TimeOfDay`, `Enabled`, `LastRunAt`, `PromptOverride`, `CreatedAt`.
Rationale for a bitmask over a CSV string or 7 bool columns: one column, trivial
EF mapping (int), and a clean eligibility check.
`PrimeScheduleEntityConfiguration`: drop the `start_date`/`end_date`/
`workdays_only` property mappings; map `Days` to `days_of_week` (int, required,
default 31 = MonFri).
### 2. Scheduling logic — `NextDueCalculator`
- Drop all `StartDate`/`EndDate` gating (the `EndDate < today` early-out, the
`StartDate > today` clamps, and the bounds check in `IsEligibleDay`).
- `IsEligibleDay(s, d)` becomes: does `s.Days` contain the flag for
`d.DayOfWeek`? (Map `System.DayOfWeek` → `PrimeDays`.)
- The existing forward search (loops up to 8 days ahead) now simply walks to the
next selected weekday.
- `alreadyFiredToday` (compares `LastRunAt`'s local date to today) is unchanged.
- The 30-min catch-up (`FireImmediately`) is unchanged.
- A schedule with `Days == 0` (none selected) is never eligible. UI validation
prevents saving that state.
### 3. UI — `SettingsModalView.axaml` + `PrimeScheduleRowViewModel`
Row template changes:
- **Remove** the `ThemedDatePicker` (range) and the single "MonFri" checkbox.
- **Add** a horizontal row of 7 `ToggleButton`s (Mo Tu We Th Fr Sa Su), styled
to highlight when checked, bound to seven bool properties on the row VM.
- Keep the enabled checkbox, the time `TextBox`, the last-run label, and the
remove button.
`PrimeScheduleRowViewModel`:
- Replace `StartDate`/`EndDate`/`WorkdaysOnly` with seven `[ObservableProperty]`
bools: `Monday`…`Sunday`.
- Constructor decomposes `dto.Days` into the seven bools.
- `ToDto()` composes the seven bools back into the `Days` int.
`PrimeClaudeTabViewModel`:
- `AddSchedule` default: MonFri selected, time 07:00, enabled.
- `Validate`: replace the `StartDate > EndDate` check with "at least one day must
be selected"; keep the time-range (00:0023:59) check.
Update the explainer `TextBlock` text to describe weekday recurrence (keep the
"fires immediately if started within 30 minutes of the target time" note).
### 4. Migration
New EF Core migration in `ClaudeDo.Data/Migrations`:
- Add `days_of_week INTEGER NOT NULL DEFAULT 31`.
- Backfill from existing rows: `workdays_only = 1` → `31` (MonFri),
`workdays_only = 0` → `127` (all 7 days).
- Drop `start_date`, `end_date`, `workdays_only`.
- Update the model snapshot.
### 5. DTOs
Both copies of `PrimeScheduleDto` (Worker `ClaudeDo.Worker.Prime` and UI
`ClaudeDo.Ui.Services`) are passed over SignalR and must stay structurally
compatible. In both: remove `StartDate`, `EndDate`, `WorkdaysOnly`; add a single
`int Days` field (serializes cleanly as JSON; avoids sharing the enum across
projects). `PrimeScheduler.ToDto` maps `entity.Days` → `(int)`.
`PrimeScheduleRepository`: update `UpsertAsync` (copy `Days` instead of the three
removed fields) and `ListAsync` ordering (order by `TimeOfDay` instead of
`StartDate`).
### 6. Tests
- `NextDueCalculatorTests` — rewrite cases around weekday sets (e.g. MonFri
skips weekend; single-day schedule; catch-up still fires; already-fired-today
skips to next eligible day).
- `PrimeSchedulerTests` — update fixture DTOs to the new shape.
- `PrimeScheduleRepositoryTests` — update entity construction and assertions.
- `PrimeClaudeTabViewModelTests` — update for the day-bool VM and new validation.
## Out of scope
- Per-schedule catch-up tuning (rejected; fixed 30 min).
- Multiple times per day, timezones, or holiday calendars.

View File

@@ -0,0 +1,182 @@
# Daily Prep ("Prime Claude") — Design
Date: 2026-06-03
## Overview
Turn the existing Prime Time warm-up into a **daily preparation** ("Tagesvorbereitung").
At a scheduled time (or on demand), Claude reads the open tasks, estimates effort,
and selects a focused subset into the MyDay list — capped so it never moves
everything in. Claude does the reasoning itself (agentic), via the already-registered
ClaudeDo MCP. This replaces the current `"ping"` behavior entirely.
A later phase will feed external tickets (Jira, possibly a second system) into the
same candidate pool; that is out of scope for this spec.
## Goals
- Scheduled and manual ("Tag vorbereiten" button) daily prep.
- Claude picks a subset of open tasks into MyDay, ordered so related tasks sit together.
- Effort-aware selection, hard-capped at `X` open MyDay tasks.
- Keep existing MyDay tasks across re-runs; only top up to `X`.
- Candidates limited to tasks in repos that are **not** excluded from the weekly report.
## Non-Goals
- External ticket integration (Jira etc.) — future phase.
- Group labels/headers in the MyDay view — grouping is ordering-only via `SortOrder`.
- A user-editable prep prompt — the prompt is fixed, parameterized.
## Key Decisions
| Topic | Decision |
| --- | --- |
| Who reasons | Agentic — Claude decides via MCP tools. |
| MyDay model | `TaskEntity.IsMyDay` flag (smart list `smart:my-day`). |
| Grouping | Ordering only via existing `SortOrder` (no new field, no migration for grouping). |
| Selection | Effort estimate, hard cap `X` tasks/day. |
| Candidates | `Status == Idle`, `BlockedByTaskId == null`, list `WorkingDir` not under `ReportExcludedPaths`. |
| Re-run | Keep existing MyDay tasks; top up to `X`. |
| Trigger | Existing Prime schedule **and** a manual button. |
| Ping | Removed — daily prep replaces it. |
| Prompt | Fixed, with injected parameters (`X`, today's date). |
| Tool access | Reuse the globally registered `claudedo` MCP — **no** separate `--mcp-config`. |
## Architecture
### 1. MCP tools (extend `ExternalMcpService`, port 47822)
The worker already exposes `ExternalMcpService` as the `claudedo` MCP server. Add two tools;
they automatically surface as `mcp__claudedo__get_daily_prep_candidates` and
`mcp__claudedo__set_my_day`.
- **`get_daily_prep_candidates()`** → JSON containing:
- `candidates[]`: open, non-blocked tasks in non-excluded repos, each with
`id, title, description, listName, isStarred, scheduledFor, age` (age derived from `CreatedAt`).
- `currentMyDay[]`: currently-`IsMyDay` open tasks (so Claude sees remaining capacity).
- Filter: `Status == Idle` AND `BlockedByTaskId == null` AND the task's list `WorkingDir`
does not start with any prefix in `AppSettings.ReportExcludedPaths`
(default `["C:\\Private"]`; case-insensitive prefix match, same semantics as the weekly report).
- **`set_my_day(taskId, isMyDay, sortOrder?)`** →
- Sets `IsMyDay` and (optionally) `SortOrder` on the task via `TaskRepository`.
- Broadcasts `TaskUpdated` via `HubBroadcaster` so the UI updates live.
- **Cap-guard:** when `isMyDay == true`, count current open (`Idle`) tasks with
`IsMyDay == true`. If `count >= X`, reject with an error message
("MyDay limit {X} reached"). `isMyDay == false` is always allowed.
`X = AppSettings.DailyPrepMaxTasks`. This guarantees the "never move everything in"
invariant server-side, independent of Claude's behavior.
### 2. `DailyPrepRunner` (replaces ping logic)
Rename `IPrimeRunner`/`PrimeRunner``IDailyPrepRunner`/`DailyPrepRunner` (the `"ping"`
concept is gone). It:
- Loads `AppSettings` (`X = DailyPrepMaxTasks`).
- Builds the fixed prompt with injected parameters (`X`, today's date).
- Invokes `claude -p --output-format stream-json --verbose` with:
- `--permission-mode` set so the headless run won't block on permission prompts,
- `--allowedTools mcp__claudedo__get_daily_prep_candidates mcp__claudedo__set_my_day`,
- `--max-turns 30` (constant), timeout 5 min (constant; larger than the old 60s ping).
- **No `--mcp-config`** — relies on the globally registered `claudedo` MCP (the worker runs
as the user via the per-user logon Scheduled Task, so the headless run inherits the
user-scope registration and its auth).
- Returns an outcome (e.g. number of tasks added) for broadcasting.
### 3. Scheduler
`PrimeScheduler` is unchanged in structure — it now calls `IDailyPrepRunner` instead of the
ping runner. `NextDueCalculator` and the schedule model are untouched.
### 4. Manual trigger
- Worker hub method `RunDailyPrepNow()` invokes the same `DailyPrepRunner`.
- UI button **"Tag vorbereiten"** in the MyDay list header.
- **Single-flight guard:** if a prep run is already in progress, the trigger reports
"already running" and does not start a parallel run (applies to both schedule and button).
### 5. Parameter config
- New field **`DailyPrepMaxTasks`** (int, default `5`) on `AppSettingsEntity`.
- Plumbing: EF config + migration, `AppSettingsRepository`, `WorkerHub` AppSettings DTO,
UI DTO mirror + `WorkerClient`, and a numeric editor in the Prime Claude settings tab.
- `ReportExcludedPaths` is reused as-is (already on `AppSettings`).
## Data Flow
1. Trigger (schedule due **or** button) → `DailyPrepRunner.RunAsync`.
2. Runner loads `AppSettings` (`X`), builds prompt, launches Claude.
3. Claude → `get_daily_prep_candidates` → DB query returns filtered candidates + current MyDay.
4. Claude estimates effort, tops up to **X total**, calls `set_my_day(id, true, sortOrder)`
for each chosen task (consecutive `sortOrder` for related tasks).
5. `ExternalMcpService` writes `IsMyDay`/`SortOrder`, broadcasts `TaskUpdated` → MyDay list
updates live.
6. Runner updates `LastRunAt`, broadcasts "prep done" (count added).
## Fixed Prompt (parameterized)
Content (parameters in `{}`):
> Du bereitest meinen Arbeitstag für **{today}** vor.
> 1. Rufe `get_daily_prep_candidates` auf.
> 2. Behalte bereits als MyDay markierte offene Tasks.
> 3. Fülle bis **maximal {X} offene Tasks gesamt** in MyDay auf — niemals mehr.
> 4. Schätze pro Task grob den Aufwand; wähle eine machbare Mischung (nicht nur Großbrocken).
> Priorisiere `isStarred`, fällige (`scheduledFor`) und ältere Tasks.
> 5. Lege thematisch verwandte Tasks durch aufeinanderfolgende `sortOrder`-Werte nebeneinander.
> 6. Setze die Auswahl via `set_my_day(id, true, sortOrder)`. Markiere nichts außerhalb der
> Kandidatenliste.
Injected parameters: `{today}` (date) and `{X}` (= `DailyPrepMaxTasks`).
## Error Handling
- No candidates → Claude marks nothing; runner reports "0 added".
- Claude run fails / times out → log + failure broadcast (existing scheduler event channel);
`LastRunAt` is set on attempt, as today, to avoid tight retry loops.
- `set_my_day` on an invalid/ineligible id → tool returns an error string; Claude adapts.
- Cap exceeded → tool returns an error; Claude stops adding.
- Concurrent trigger → single-flight guard reports "already running".
## Testing
Real SQLite + real git (project convention).
- `get_daily_prep_candidates`: only `Idle`; blocked excluded; tasks in excluded repos
(`ReportExcludedPaths`) excluded; current MyDay tasks included.
- `set_my_day`: sets flag + `SortOrder`; broadcasts `TaskUpdated`; cap-guard rejects at limit;
unset always allowed.
- `DailyPrepRunner`: prompt contains `{X}` + date; args contain `--allowedTools` +
permission-mode + `--max-turns`; success/failure outcomes via an `IClaudeProcess` fake.
- Rename `IPrimeRunner``IDailyPrepRunner` requires syncing `PrimeScheduler` tests/fakes.
## Files to Create / Modify (high level)
**Data**
- `Models/AppSettingsEntity.cs` — add `DailyPrepMaxTasks`.
- `Configuration/AppSettingsEntityConfiguration.cs` — map new column.
- `Migrations/` — new migration for `daily_prep_max_tasks`.
- `Repositories/AppSettingsRepository.cs` — persist new field.
**Worker**
- `External/ExternalMcpService.cs` — add `get_daily_prep_candidates`, `set_my_day` (+ cap-guard).
- `Prime/PrimeRunner.cs``DailyPrepRunner.cs`; `Prime/Interfaces/IPrimeRunner.cs`
`IDailyPrepRunner.cs`; prompt builder + arg builder.
- `Prime/PrimeScheduler.cs` — depend on `IDailyPrepRunner`.
- `Hub/WorkerHub.cs` — AppSettings DTO field; `RunDailyPrepNow()`.
- `Program.cs` — DI registration update.
**UI**
- `Services/WorkerClient.cs` + AppSettings DTO mirror — new field; `RunDailyPrepNow` call.
- Prime Claude settings tab VM/view — numeric editor for `DailyPrepMaxTasks`.
- MyDay list header — "Tag vorbereiten" button + command (Lists/IslandsShell VM).
**Tests**
- `ClaudeDo.Worker.Tests` — MCP tools, runner, scheduler fakes.
- `ClaudeDo.Data.Tests` — AppSettings persistence (if covered there).
- `ClaudeDo.Ui.Tests` — settings VM / button wiring as applicable.
## Future Phase (out of scope)
External ticket sources (Jira, possibly a second system) feed into the candidate pool used by
`get_daily_prep_candidates`, behind a task-source abstraction. Designed separately.

View File

@@ -0,0 +1,151 @@
# Daily Prep — Live Output View + Clear Day — Design
Date: 2026-06-03
## Overview
Two follow-ups to the daily-prep ("Prime Claude") feature:
1. **Live output view.** While Claude prepares the day, there is no feedback. Add a
live, human-readable view of the prep run's output, shown as a new content mode in
the existing right-hand **Details island** (mirroring how Daily Notes works — a mode
swap, not a separate window/column).
2. **Clear Day button.** A MyDay-header button that clears the MyDay selection
immediately.
## Goals
- See the prep run's progress live, rendered with the same friendly terminal renderer
used for task runs (assistant text + tool calls like `set_my_day …`, not raw NDJSON).
- Both manual (button) and scheduled prep runs stream into the log.
- The manual button opens the prep view; a scheduled run fills the log silently and is
opened via a dedicated "Vorbereitungs-Log" button (the existing `PrimeStatus` footer
remains the hint that a run happened).
- A "Tag leeren" button clears all MyDay tasks (any status) with no confirmation.
## Non-Goals
- No new island/column and no popup/overlay — reuse the Details island as a mode swap.
- No persistence of prep output across app restarts (in-memory log only).
- No undo for Clear Day (re-runnable via "Tag vorbereiten").
## Key Decisions
| Topic | Decision |
| --- | --- |
| Rendering | Reuse the existing `SessionTerminalView` / `StreamLineFormatter` renderer. |
| Location | New `IsPrepMode` content panel inside the Details island (like `IsNotesMode`). |
| Lifecycle | Manual click opens the view (UI-local); `PrepStarted/PrepLine/PrepFinished` events fill the log regardless of current mode; scheduled runs do not auto-open. |
| Open after schedule | Dedicated "Vorbereitungs-Log" header button + existing `PrimeStatus` footer hint. |
| Clear Day scope | All MyDay tasks regardless of status. |
| Clear Day confirm | None — clear directly. |
## Architecture
### Feature A — Live prep output
**Worker**
- Extend `IPrimeBroadcaster` (`src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs`)
with `PrepStartedAsync()`, `PrepLineAsync(string line)`, `PrepFinishedAsync(bool success)`.
- Implement in `HubBroadcaster` (`src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`) sending
SignalR events `PrepStarted`, `PrepLine` (string), `PrepFinished` (bool).
- `PrimeRunner` (`src/ClaudeDo.Worker/Prime/PrimeRunner.cs`): inject `IPrimeBroadcaster`.
In `FireAsync`, after the single-flight gate is entered and a run will actually happen:
call `PrepStartedAsync()` before `RunAsync`; replace the discard lambda with
`async line => await _broadcaster.PrepLineAsync(line)`; call
`PrepFinishedAsync(result.IsSuccess)` after. The "already running" early-return path
emits nothing (no run occurs). Both scheduled and manual runs go through `FireAsync`,
so both stream.
**UI**
- `WorkerClient` (`src/ClaudeDo.Ui/Services/WorkerClient.cs`): register
`_hub.On<…>("PrepStarted"/"PrepLine"/"PrepFinished", …)` each via
`Dispatcher.UIThread.Post`, raising `PrepStartedEvent` / `PrepLineEvent(string)` /
`PrepFinishedEvent(bool)`. Declare these on `IWorkerClient`.
- `DetailsIslandViewModel`: add `IsPrepMode` (bool), `IsPrepRunning` (bool), a dedicated
`PrepLog` (`ObservableCollection<LogLineViewModel>`), and `ShowPrep()` (calls
`Bind(null)`, sets `IsNotesMode=false`, `IsPrepMode=true`). Subscribe to the three prep
events in the ctor (always active, independent of mode):
- `PrepStarted` → clear `PrepLog`, `IsPrepRunning=true`.
- `PrepLine` → format the line with the same `StreamLineFormatter` path used by the
stdout branch of `OnTaskMessage`, append a `LogLineViewModel` to `PrepLog`.
- `PrepFinished``IsPrepRunning=false` (optionally append a status line).
Mode exclusivity: the normal task-details panel becomes visible on
`!IsNotesMode && !IsPrepMode`; `ShowNotes()` also sets `IsPrepMode=false`; `Bind(task)`
resets both flags.
- `DetailsIslandView.axaml`: add a third `<Panel IsVisible="{Binding IsPrepMode}">` in the
body grid alongside the existing details/notes panels, rendering `PrepLog` in the
terminal style (reuse the `LogLineViewModel` item template used by `SessionTerminalView`).
**Wiring**
- `TasksIslandViewModel`: add a `PrepRequested` event (mirror `NotesRequested`).
`PrepareDayCommand` raises `PrepRequested` in addition to calling
`RunDailyPrepNowAsync()`. Add `ShowPrepLogCommand` that raises `PrepRequested`. Add the
"Vorbereitungs-Log" button to the MyDay header (`IsVisible="{Binding IsMyDayList}"`).
- `IslandsShellViewModel`: wire `Tasks.PrepRequested += () => Details.ShowPrep()`.
### Feature B — Clear Day
**Worker**
- `WorkerHub.ClearMyDay()` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs`): query ids where
`IsMyDay == true`; `ExecuteUpdateAsync` setting `is_my_day = false`; broadcast
`TaskUpdated(id)` for each affected id (the UI reloads the current list on `TaskUpdated`).
**UI**
- `IWorkerClient.ClearMyDayAsync()` + `WorkerClient` impl invoking `"ClearMyDay"`.
- `TasksIslandViewModel.ClearDayCommand` calls `_worker.ClearMyDayAsync()` (no confirm).
Add the "Tag leeren" button to the MyDay header next to "Tag vorbereiten".
## Data Flow (live view)
1. Trigger (schedule or button) → `PrimeRunner.FireAsync`.
2. `PrepStartedAsync()` → SignalR `PrepStarted``WorkerClient.PrepStartedEvent`
`DetailsIslandViewModel` clears `PrepLog`, sets `IsPrepRunning`.
3. Each Claude stdout line → `PrepLineAsync(line)``PrepLine` → formatted, appended to
`PrepLog` (visible if the user is in prep mode; filled silently otherwise).
4. Run ends → `PrepFinishedAsync(success)``PrepFinished``IsPrepRunning=false`.
5. Manual button click also raised `PrepRequested``Details.ShowPrep()` (view open).
After a scheduled run, the user clicks "Vorbereitungs-Log" to open it.
## Error Handling
- Prep run fails/times out → `PrepFinished(false)`; the existing `PrimeFired` footer
status still reports failure.
- "Already running" → no prep events emitted (no run happened); existing behavior intact.
- `ClearMyDay` with zero MyDay tasks → no-op, no broadcasts.
## Testing
- Worker: `PrimeRunner` streams `PrepStarted` → N×`PrepLine``PrepFinished` (fake
`IClaudeProcess` invokes `onStdoutLine` with sample lines; fake `IPrimeBroadcaster`
records calls). `WorkerHub.ClearMyDay` clears all IsMyDay rows and broadcasts per id
(real SQLite, mirror existing hub tests).
- UI: `DetailsIslandViewModel` appends to `PrepLog` on `PrepLineEvent` and `ShowPrep()`
sets the mode flags (mutual exclusivity with notes); `TasksIslandViewModel.ClearDayCommand`
calls `ClearMyDayAsync` (stub worker client).
## Files (high level)
**Modify**
- `src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs`
- `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`
- `src/ClaudeDo.Worker/Prime/PrimeRunner.cs`
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` (ClearMyDay)
- `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
- `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- `src/ClaudeDo.Localization/locales/en.json`, `de.json` (button labels)
**Test**
- `tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs`
- `tests/ClaudeDo.Worker.Tests/Hub/…` (ClearMyDay)
- `tests/ClaudeDo.Ui.Tests/…` (DetailsIslandViewModel prep events; TasksIslandViewModel ClearDay) + `StubWorkerClient`
## Known fragility
Changing `IWorkerClient` / `WorkerClient` / VM constructors breaks hand-rolled fakes
(`StubWorkerClient`, `FakeWorkerClient`) in both test projects — update all of them.

View File

@@ -0,0 +1,114 @@
# Localization (i18n) Support — Design
**Date:** 2026-06-03
**Status:** Approved (pending spec review)
## Goal
Add translation support to ClaudeDo. The user picks a language in the Settings modal and **all** UI text reflects it instantly (no restart). The WPF installer is localized the same way and gets its own language picker. Ship **English only** now, but the system is fully data-driven: adding a new language means dropping one JSON file into a folder — **no code changes, no rebuild**.
## Decisions (from brainstorming)
- **Languages:** English only at launch; extensible via translation files.
- **Switching:** Live / instant — all bound UI text updates the moment the language changes.
- **Storage:** Selected language stored in `~/.todo-app/ui.config.json` (the local UI config that also holds `DbPath`/`SignalRUrl`). Purely a UI concern — does **not** go through the worker/SignalR settings path.
- **Installer:** Defaults to existing config language (upgrade) → OS culture → English. Shows a language picker in the wizard, live-switches its own UI, and writes the chosen language into `ui.config.json` so the app launches matching the installer.
- **Locale files:** Loose `*.json` files in a `locales/` folder next to the running exe, scanned at startup to discover available languages.
- **Code sharing:** A shared `ClaudeDo.Localization` project holds the loading/lookup/language-list logic, referenced by `ClaudeDo.Ui`, `ClaudeDo.App`, and `ClaudeDo.Installer`. Each UI framework keeps its own thin markup-extension binding layer (Avalonia ≠ WPF).
## Architecture & Components
### New shared project: `ClaudeDo.Localization`
- **`LocaleStore`** — discovers and loads `*.json` files from the `locales/` folder next to the running exe. Parses each file's nested JSON, **flattens it into an internal `Dictionary<string,string>`** keyed by dot-path for O(1) lookup, and captures `metadata.code` / `metadata.name`. Exposes the list of available languages for the dropdowns.
- **`ILocalizer` / `Localizer`** — singleton holding the *active* language dictionary. Members:
- indexer `this[string key]` → translated string (with fallback),
- `string Get(string key, params object[] args)``string.Format` for parameterized strings,
- `void SetLanguage(string code)` → swaps the active dictionary and raises `PropertyChanged` for the indexer so **all live bindings refresh** (this is what enables instant switching),
- `AvailableLanguages` (list of `{ code, name }`), `CurrentCode`.
- **Fallback chain:** requested key in active language → same key in English → the key path string itself (a missing translation is visible, never a crash).
- **OS-culture resolution:** helper that maps the current OS UI culture to an available locale code, falling back to English.
### Per-framework binding layer (not shared)
- **Avalonia:** a `{loc:Tr Some.Key}` markup extension that binds to `Localizer[key]` (Source = the singleton `Localizer`, Path = `[key]`). Language change raises the indexer `PropertyChanged`, refreshing every binding.
- **WPF installer:** an equivalent markup extension doing the same against the installer's own `Localizer` instance.
Both consume the **same JSON files and the same `LocaleStore`/`Localizer` logic** from the shared project.
## Translation File Format
`locales/en.json` (and future `de.json`, `fr.json`, …) — nested, human-friendly hierarchy:
```json
{
"metadata": { "code": "en", "name": "English" },
"settings": {
"save": "Save",
"cancel": "Cancel",
"general": { "model": "Model", "maxParallel": "Max parallel executions" }
},
"tasks": {
"addPlaceholder": "Add a task…",
"overdue": "OVERDUE"
},
"worktrees": { "autoCleanupDays": "{0} days" }
}
```
- `metadata.code` is the language id stored in `ui.config.json` and matched to OS culture; `metadata.name` is the dropdown label.
- **Lookup by dot-path key** (`"settings.general.model"`). On-disk file stays grouped/nested; the runtime flattens it for fast lookup. Authors edit a clean hierarchy.
- **Parameters:** `{0}`, `{1}` placeholders resolved via `Get(key, args)`.
- **Encoding:** UTF-8 — non-ASCII languages work out of the box.
## Data Flow & Wiring
### App config
- Add `Language` (string, e.g. `"en"`) to `AppSettings` (`ClaudeDo.Ui/AppSettings.cs`) and to the installer mirror `InstallerAppSettings` (`ClaudeDo.Installer/Core/ConfigModels.cs`).
- Add a `Save()` method to `AppSettings` (today the UI only reads it).
### App startup (`ClaudeDo.App/Program.cs`)
1. `AppSettings.Load()` reads `Language` (missing/empty → resolve from OS culture, else `"en"`).
2. `LocaleStore` scans `locales/` next to the exe; `Localizer` is registered as a singleton and set to the configured language.
3. UI renders; every `{loc:Tr ...}` binding pulls from the active dictionary.
### Changing language in Settings (General tab)
- New "Language" dropdown bound to `Localizer.AvailableLanguages`; selection bound to current code.
- On change → `Localizer.SetLanguage(code)` (instant UI refresh) **and** `AppSettings.Language = code; AppSettings.Save()`. Local UI state only — not routed through worker/SignalR.
### Installer (`ClaudeDo.Installer`)
- On launch: default language = existing `ui.config.json` `Language` if present (upgrade), else OS culture, else English.
- Wizard gets a language dropdown (same `LocaleStore`, installer's own markup extension) → live-switches the installer UI.
- When writing `ui.config.json`, persists the chosen `Language` so the app launches matching the installer.
### Build wiring
- `locales/*.json` copied to output (`CopyToOutputDirectory`) for both App and Installer.
- Installer packages the `locales/` folder so it lands beside the installed exe.
## String-Extraction Scope
Mechanical but large; done screen-by-screen so each commit is reviewable, building one `en.json` as the single source of truth.
- **22 Avalonia `.axaml` views** — replace inline `Text="..."`, `Content="..."`, `PlaceholderText="..."`, and inline `ComboBoxItem` text with `{loc:Tr key}`.
- **ViewModel strings** — user-facing literals built in C# (e.g. `HeaderTitle`, `StatusPill`, status text, parameterized messages) resolve via injected `ILocalizer` (`localizer.Get(...)`). Log messages and non-user-facing strings stay as-is. **Live-switch note:** a VM string resolved once will not refresh on language change. For VM-built user-facing text, either (a) prefer resolving in XAML via `{loc:Tr}` where possible, or (b) have the VM subscribe to the `Localizer` change event and re-raise `PropertyChanged` (or re-resolve) for its localized properties. Decide per-property during extraction.
- **10 WPF installer files** — same treatment with the installer's markup extension; VM-driven headings (`Heading`, `NextButtonText`, etc.) go through `ILocalizer`.
- **Enum-ish display values** (model names, permission modes, weekday names) — translate the *display* text while keeping the underlying value/binding intact.
## Testing
- `ClaudeDo.Localization` unit tests: load/flatten nested JSON, dot-path lookup, fallback chain (active→en→key), `{0}` formatting, OS-culture resolution.
- `LocaleStore` discovery test (folder scan → available languages).
- **Key-coverage test:** every locale file's flattened key set matches `en.json`; fails the build if `en.json` drifts from other locale files.
- Settings round-trip test: `SetLanguage` updates `Localizer` **and** persists to `ui.config.json`.
- Manual UI pass (user's visual review): confirm instant switching with a throwaway `de.json` stub during dev, then remove it.
## Out of Scope (YAGNI)
- Pluralization rules, RTL layout, per-string gender.
- Translating the German weekly-report **body** (generated content — stays as-is).
- Localizing log output and non-user-facing strings.

View File

@@ -0,0 +1,226 @@
# Weekly Report — Design
**Date:** 2026-06-03
**Status:** Approved (pending spec review)
## Goal
Generate a short, standup-focused report of what the user did over the past week,
for the Wednesday standup. The report is built from the user's Claude Code session
history across all repos, distilled and summarized by Claude. Personal repos under a
configurable excluded path (default `C:\Private`) are left out. The user can author
per-day bullet notes inside ClaudeDo (via the My Day list) that are folded into the
report.
## Decisions (from brainstorming)
- **Data source:** all Claude Code history in `~/.claude/projects/*/*.jsonl`, both manual
sessions and ClaudeDo-run tasks, grouped by repo.
- **Exclusion:** a configurable list of path prefixes (default `["C:\\Private"]`). Any
session whose `cwd` starts with an excluded prefix is dropped.
- **Summarization:** Claude CLI summarizes. The Worker distills the logs, then runs a
single one-shot `claude -p` call via the existing `ClaudeProcess` and returns the
result markdown. No worktree, no task row, no queue.
- **Period:** default "since last Wednesday → today", computed from a configurable
standup weekday. The range is adjustable in the modal.
- **Signal fed to Claude:** user prompts (intent), assistant closing summaries, and the
user's daily notes. No git-commit scanning.
- **Report shape:** German, grouped by day, first-person past-tense bullets, ~3-5
bullets/day with trivia merged/dropped, notes blended into one deduplicated list per
day. See the Report Prompt section.
- **Placement:** a "Weekly Report" overlay modal opened from the toolbar, rendering via
the existing `MarkdownView`.
- **Output:** view-only in-app (no export).
- **Notes UI:** authored in the My Day list via a pinned non-task "Notes" pseudo-row that
repurposes the Details island into a bullet-notes editor. Per-day bullets with a day
navigator (prev/next arrows + date picker + Today).
- **Report persistence:** generated reports are stored, keyed by exact date range, and
reused. Generation is button-driven (never automatic); a Regenerate button overwrites.
## Architecture Overview
```
UI (WeeklyReportModal, Details-island notes mode)
│ SignalR
WorkerHub ── GetWeekReport / GenerateWeekReport / daily-notes CRUD
├── WeekReportService ──► ClaudeHistoryReader (scan ~/.claude/projects)
│ │ (distilled activity)
│ ├── DailyNoteRepository (notes in window)
│ ├── ClaudeProcess (one-shot summarize)
│ └── WeekReportRepository (store/reuse)
└── DailyNoteRepository (CRUD)
Data: DailyNoteEntity, WeekReportEntity + repositories + EF migration
AppSettingsEntity: ReportExcludedPaths, StandupWeekday
```
## Components
### 1. Data layer (`ClaudeDo.Data`)
**`DailyNoteEntity`** (table `daily_notes`)
- `Id` (GUID string, init-only PK)
- `Date` (date-only; the day the bullet belongs to)
- `Text` (string, the bullet content)
- `SortOrder` (int; ordering within a day)
- `CreatedAt` (DateTime)
**`DailyNoteRepository`** (async, CancellationToken, follows existing repo pattern)
- `ListByDayAsync(DateOnly day)` — bullets for one day, ordered by `SortOrder`.
- `ListBetweenAsync(DateOnly start, DateOnly end)` — bullets in a window (used by the report).
- `AddAsync(DateOnly day, string text)` — appends a bullet (assigns next `SortOrder`).
- `UpdateAsync(string id, string text)`
- `DeleteAsync(string id)`
**`WeekReportEntity`** (table `week_reports`)
- `Id` (GUID string, init-only PK)
- `StartDate`, `EndDate` (date-only; the report window — unique together)
- `Markdown` (string; the generated report)
- `GeneratedAt` (DateTime)
**`WeekReportRepository`**
- `GetByRangeAsync(DateOnly start, DateOnly end)` — stored report for an exact range, or null.
- `UpsertAsync(DateOnly start, DateOnly end, string markdown)` — insert or overwrite by range.
**`AppSettingsEntity`** — two new columns:
- `ReportExcludedPaths` (string, JSON array of path prefixes; default `["C:\\Private"]`)
- `StandupWeekday` (int, `DayOfWeek`; default `Wednesday` = 3)
**Migration** — one EF migration adds `daily_notes`, `week_reports`, and the two
`app_settings` columns. Entity configs in `Configuration/` (date-only and enum/JSON
conversion via `ValueConverter`, per existing convention).
### 2. Worker (`ClaudeDo.Worker`) — new `Report/` folder
**`ClaudeHistoryReader`** (raw → distilled)
- Input: date window + excluded path prefixes.
- Enumerates `~/.claude/projects/*/*.jsonl`.
- Parses each line as JSON; tolerant of malformed lines (skip, never throw).
- Drops a session entirely if its `cwd` starts with any excluded prefix
(case-insensitive, normalized separators).
- Keeps messages whose `timestamp` falls in `[start, end]`.
- Extracts, per repo (`cwd`) → per day:
- **user prompts**: `type == "user"` text content (string or `content[].text`).
Skip tool-result-only user turns and queue/attachment/hook noise.
- **assistant closing summaries**: the final assistant text block of each turn/session.
- Output: a structured model, e.g.
`IReadOnlyList<RepoActivity>` where `RepoActivity { RepoPath, Days: List<DayActivity{ Date, Prompts[], Summaries[] }> }`.
**`WeekReportService`** (distilled → stored summary)
- `GenerateAsync(start, end, ct)`:
1. Read settings (excluded paths, standup weekday).
2. `ClaudeHistoryReader` → distilled activity.
3. `DailyNoteRepository.ListBetweenAsync` → notes grouped by day.
4. Pivot the distilled activity (repo→day from the reader) into **day-major**
(day→repo) to match the day-grouped report, and build the prompt from the
template in the Report Prompt section. Empty window → produce a "no activity"
report without calling Claude.
5. Run `ClaudeProcess` once (`claude -p`, no worktree/agents; working dir = a neutral
dir). Read `RunResult.ResultMarkdown`.
6. `WeekReportRepository.UpsertAsync(start, end, markdown)`; return markdown.
7. On Claude failure, surface `RunResult.ErrorMarkdown` to the caller (do not store).
- `GetStoredAsync(start, end)``WeekReportRepository.GetByRangeAsync`.
Interfaces live in `Report/Interfaces/` per the area convention.
#### Report Prompt
`WeekReportService` assembles this prompt. Instructions are in English (more reliable
steering); the output is forced to German. `{...}` are filled at build time.
```
You are generating a concise weekly standup report for a software developer.
Summarize what they accomplished between {start:dd.MM.yyyy} and {end:dd.MM.yyyy}.
Rules:
- Write the ENTIRE report in German.
- Group by day. One "## {Wochentag}, {dd.MM.yyyy}" section per day that has
activity (German weekday names). Omit days with no activity entirely.
- Within each day: 35 first-person, past-tense bullets ("- Habe X umgesetzt",
"- Y behoben"). Merge related small work into one bullet.
- Drop trivia: typo fixes, pure exploration, false starts, tooling/log noise.
- Blend the developer's own notes and the derived activity into ONE deduplicated
bullet list per day. The developer's notes are authoritative — never omit or
contradict their substance.
- Name the project/repo when it adds clarity.
- Output ONLY the dated sections. No preamble, no intro, no closing remarks.
== Activity (from session history) ==
{day-major: for each day → for each repo → its prompts + closing summaries}
== Developer notes ==
{day-major: for each day → the bullets}
```
### 3. IPC (Hub + WorkerClient)
**`WorkerHub`** new methods:
- `GetWeekReport(string startIso, string endIso)` → stored markdown or null.
- `GenerateWeekReport(string startIso, string endIso)` → generates, stores, returns markdown.
- `GetDailyNotes(string dayIso)` → bullets for a day.
- `AddDailyNote(string dayIso, string text)` → created bullet.
- `UpdateDailyNote(string id, string text)`.
- `DeleteDailyNote(string id)`.
**`WorkerClient`** (UI) mirrors these, following the existing
`WorkerPrimeScheduleApi`/AppSettings method pattern.
### 4. UI (`ClaudeDo.Ui`)
**Weekly Report modal** (`WeeklyReportModalView` + `WeeklyReportModalViewModel`)
- Overlay modal in the `Modals/` pattern (like `WorktreesOverviewModalView`),
registered in `IslandsShellViewModel`, opened from a new toolbar button.
- Date range: two `ThemedDatePicker`s, default "since last Wednesday → today" computed
from `StandupWeekday`.
- On open and on range change: call `GetWeekReport`.
- Stored report exists → render markdown via `MarkdownView`, show `GeneratedAt`, show
a **Regenerate** button.
- None → empty state ("Not generated yet") + a **Generate** button.
- **Generate**/**Regenerate**: call `GenerateWeekReport` with a busy/spinner state;
render the returned markdown. Generation only ever runs from these buttons.
- View-only; no export.
**Notes in My Day**
- The My Day smart list (`smart:my-day`) pins a fixed, non-task "Notes" pseudo-row at
the top, recognized by the list/selection code (not a `TaskEntity`).
- Selecting it puts the **Details island** into **notes mode** (task fields hidden,
notes editor shown). The island hosts a dedicated `NotesEditorViewModel` + small view
rather than swelling `DetailsIslandViewModel` (already ~978 lines); the bullet logic
stays isolated and testable.
- **Day navigator** in the editor header: `<` / `>` arrows to step days, a
`ThemedDatePicker` to jump to any date, and a "Today" button. Defaults to today; the
pinned row's default day rolls over at midnight (no data lost — past days remain
reachable via the navigator).
- **Bullet editing** for the selected day: list of bullets with add / inline-edit /
delete / reorder (`SortOrder`). Each operation goes through the daily-notes hub CRUD.
### 5. Settings
- Add the excluded-path list and the standup weekday to the existing Settings modal,
persisted via the new `app_settings` columns and the existing
`GetAppSettings`/`UpdateAppSettings` path.
## Error Handling
- Malformed/unreadable JSONL lines are skipped, never fatal.
- Empty window → a "no activity" report, no Claude call.
- Claude call failure → error surfaced in the modal; nothing stored.
- Date ranges normalized to date-only; the stored report key is the exact (start, end).
## Testing
- **`ClaudeHistoryReader`** (Worker tests, fixture `.jsonl`): date-window filtering,
excluded-prefix dropping (case/separator normalization), prompt/summary extraction,
malformed-line tolerance, repo/day grouping.
- **`WeekReportService`**: prompt-building from distilled activity + notes; empty-window
short-circuit; storage upsert; with a faked `ClaudeProcess`.
- **`DailyNoteRepository`** and **`WeekReportRepository`**: CRUD / upsert / range lookup
against real SQLite (matches existing test style).
## Out of Scope
- Report export (clipboard/file) — view-only for now.
- Git-commit scanning.
- Editing or summarizing full transcripts; only prompts + closing summaries are used.

View File

@@ -0,0 +1,236 @@
# Bundled Prompts Overhaul — Design
Date: 2026-06-04
## Goal
Replace ClaudeDo's bundled prompts with a clean, professional baseline and make
every prose prompt a user-editable file with a bundled default. Add a roadblock
protocol so an autonomous run can flag problems mid-task without aborting.
The execution-side defaults (`system.md`) ship as a moderate, **project-agnostic**
engineering baseline — ClaudeDo users run tasks against their *own* repos, so no
ClaudeDo-specific rules belong there. Everything is in English (tighter
tokenization, more reliable instruction-following); the only German output is the
weekly report, which a human reads.
## File layout
All prompts live under `~/.todo-app/prompts/` as editable files with bundled
defaults seeded by `PromptFiles.EnsureExists` (which never overwrites a file the
user already has). The `system` + `agent` prompts collapse into one `system.md`;
the old `agent`/manual distinction was removed when tags were retired.
| File | Replaces | Placeholders |
|---|---|---|
| `system.md` | system + agent (merged) | — |
| `planning-system.md` | planning system prompt | — |
| `planning-initial.md` | "analyze & break down" kickoff | `{title}`, `{description}` |
| `retry.md` | "try again and fix" prompt | — |
| `daily-prep.md` | daily-prep prompt | `{date}`, `{maxTasks}` |
| `weekly-report.md` | weekly-report instructions | `{start}`, `{end}` |
The task-execution prompt (title + description + `## Sub-Tasks` checkboxes) stays
assembled in code — it is data-shaped, not prose.
### Templating
`PromptFiles` gains `Render(PromptKind kind, IReadOnlyDictionary<string,string> values)`
that replaces **only** the known named tokens for that kind. Any other `{...}` in
the file (e.g. the literal `{Wochentag}` / `{dd.MM.yyyy}` in the German report
rules) passes through untouched. Daily-prep tool names are inlined as literals —
`--allowedTools` already carries the real names, and inlining keeps the file from
silently breaking if a user edits a placeholder.
### Migration
`EnsureExists` keeps its current semantics: it seeds a default only when the file
is missing, never overwriting user edits. The old `planning.md` and `agent.md`
become inert — `TaskRunner` stops reading `agent.md`, and the planning system
prompt now reads `planning-system.md`. Old files are harmless to leave or delete.
`PromptKind` changes: `Agent` is removed; `Planning` maps to `planning-system.md`;
new kinds `PlanningInitial`, `Retry`, `DailyPrep`, `WeeklyReport` are added.
## Roadblock protocol
An autonomous run has no human watching, so it must not silently stop or block on
a question. Instead the agent emits an inline marker whenever it hits a true
blocker, **any number of times**, and keeps working on whatever it still can.
- **Prompt side** (`system.md`): instruct the agent to write
`CLAUDEDO_BLOCKED: <one short sentence>` on its own line whenever something
genuinely prevents progress (missing credentials, contradictory requirements, a
destructive action it won't take unasked) — then continue with the rest of the
task. Reserved for true blockers, not routine decisions it can make itself.
- **Detection** (`StreamAnalyzer`): as `assistant` messages stream, scan their
text content for lines matching `^CLAUDEDO_BLOCKED:` and collect each reason
into an ordered list (`Blocks`). This is live and cumulative — multiple problems
across one run are all captured, not just the last.
- **Result wiring** (`StreamResult``RunResult` → run record): carry the
collected `Blocks`. Strip the marker lines from the displayed result text.
- **Routing**: a run that finishes with blocks still goes to `WaitingForReview`
(standalone tasks) — it is "done as far as the agent could get". The review card
shows a ⚠ roadblock hint listing the collected problems. The user answers them
via the existing reject-rerun feedback path, which resumes the session with the
answers as the next-turn prompt — so the agent continues with the problems
resolved rather than restarting.
## The prompts
### `system.md`
```markdown
# Working Agreement
You are completing one well-defined task autonomously in a git repository.
## Scope
- Do exactly what the task asks — no unrequested refactors, renames, dependency
changes, or "while I'm here" cleanup.
- If intent is ambiguous, state the assumption you're making and proceed with the
most reasonable reading. Stop only if you genuinely cannot move forward.
- Prefer three similar lines over a premature abstraction. Don't build for
hypothetical future needs.
## Working in the repo
- Read a file before editing it. Match the conventions already in this codebase —
they override generic defaults.
- Prefer editing existing files to creating new ones. Don't write comments that
just restate the code.
- Validate only at real boundaries (user input, external APIs).
## Finishing
- Before claiming done, verify: run the build and relevant tests, confirm they
pass, and report what you ran. If you couldn't verify something, say so plainly.
- Make focused commits using the repository's existing commit-message convention.
## Safety
- Never force-push, hard-reset, or delete branches/files beyond the task's scope
without being asked.
- Don't introduce injection/XSS/secret-leak issues. Never commit credentials.
## You are running unattended
You run autonomously with no human watching. There is no one to answer mid-task
questions, so never stop to ask — make the most reasonable decision, note the
assumption, and continue.
## When you are blocked
If something genuinely prevents you from completing part of the task (missing
credentials, contradictory requirements, a destructive action you won't take
unasked), do NOT silently give up. Write this marker on its own line, then keep
working on whatever else you can:
CLAUDEDO_BLOCKED: <one short sentence describing what blocked you>
Emit it as many times as needed — once per distinct blocker. Use it only for true
blockers, not for routine decisions you can make yourself.
```
> `system.md` also gains an **"Out-of-scope improvements"** section that tells the
> agent to file follow-up work via the `SuggestImprovement` tool. That section is
> defined in `2026-06-04-child-tasks-and-improvement-loop-design.md` and lands with
> that feature.
### `planning-system.md`
```markdown
You are the planning assistant for ClaudeDo. Your job is to break a task into
smaller, independently executable subtasks — the session ends by creating those
subtasks.
Start every session by invoking the `superpowers:brainstorming` skill (Skill
tool) and follow it end to end: clarifying questions one at a time, then 23
approaches with a recommendation, then a short design. Do not create any subtasks
until the user has approved the design.
You can ONLY shape this task's plan — you cannot edit files or touch other tasks.
The tools available to you are: CreateChildTask, ListChildTasks, UpdateChildTask,
DeleteChildTask, UpdatePlanningTask, and Finalize. Use nothing else.
Once the design is approved, create the child tasks with CreateChildTask, then
call Finalize. Keep each subtask concrete and self-contained with a clear
done-state, ordered so dependencies come first.
```
### `planning-initial.md`
```markdown
# Task to plan: {title}
{description}
```
### `retry.md`
```markdown
The task did not complete on the previous attempt — you may have run out of
turns, hit an error, or stopped before finishing.
Review the work already done in this session and the current state of the
repository, identify what is still incomplete or broken, and finish the task.
Don't restart from scratch or repeat a failed approach. Verify the result
(build + tests) before you stop.
```
Self-contained — no error injection. The runner appends the captured process
output **only when it is a genuine error** (i.e. not the generic
`"Claude exited with code N and no result."` fallback), since real session errors
are already in the resumed context.
### `daily-prep.md`
```markdown
You are preparing my workday for {date}.
1. Call mcp__claudedo__get_daily_prep_candidates.
2. Keep tasks already marked MyDay (currentMyDay) — never remove them.
3. Fill MyDay to at most {maxTasks} open tasks TOTAL (currentMyDay counts). Never exceed it.
4. Estimate each candidate's effort and pick a feasible mix — not only big items.
Prioritize isStarred, due (scheduledFor), and older tasks.
5. Place related tasks next to each other using consecutive sortOrder values.
6. Apply via mcp__claudedo__set_my_day(taskId, true, sortOrder). Never mark anything
outside the candidate list.
If there are no candidates, do nothing.
```
### `weekly-report.md`
```markdown
You are generating a concise weekly standup report for a software developer,
covering {start} to {end}.
Rules:
- Write the ENTIRE report in German.
- Group by day. One "## {Wochentag}, {dd.MM.yyyy}" section per day that has
activity (German weekday names). Omit days with no activity.
- Within each day: 35 first-person, past-tense bullets ("- Habe X umgesetzt",
"- Y behoben"). Merge related small work into one bullet.
- Drop trivia: typo fixes, pure exploration, false starts, tooling/log noise.
- Blend the developer's own notes and the derived activity into ONE deduplicated
bullet list per day. The notes are authoritative — never omit or contradict them.
- Name the project/repo when it adds clarity.
- Output ONLY the dated sections. No preamble, no intro, no closing remarks.
Two sections follow below: an activity log derived from Claude session history,
and the developer's own notes. Base the report on both; the notes are
authoritative where they conflict with the derived activity.
```
## Touch points
- `src/ClaudeDo.Data/PromptFiles.cs` — new `PromptKind` members, new defaults,
`Render` helper.
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — stop reading `agent.md`; use
`retry.md`; conditional stderr append on retry; carry/route `Blocks`.
- `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs` — scan assistant text for
`CLAUDEDO_BLOCKED:` markers, collect `Blocks`, strip from result.
- `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs` / `RunResult` — carry `Blocks`.
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — read
`planning-system.md` and `planning-initial.md` via `PromptFiles.Render`.
- `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs` — read `daily-prep.md`.
- `src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs` — read `weekly-report.md`.
- UI — review card shows the ⚠ roadblock hint with collected problems.
- `src/ClaudeDo.Ui/.../FilesSettingsTabViewModel.cs` — expose the new prompt files.
- Tests — `PromptFiles` render/seed; `StreamAnalyzer` marker collection; planning/
prep/report builders read from files.
## Out of scope
- The in-code task-execution assembly (title/description/subtasks) is unchanged.
- `ResultSchema` / `--output-schema` remains untouched.
- No change to commit-message templating.
```

View File

@@ -0,0 +1,186 @@
# Reusable Child Tasks + Agent Improvement Loop — Design
Date: 2026-06-04
## Goal
Let an executing task agent offload out-of-scope improvements it spots into
**child tasks** that run automatically, so ClaudeDo can drive a self-improvement
loop. Generalize the parent/child machinery that planning uses today into a
reusable subsystem not bound to planning.
Example: while implementing task X, Claude notices "this module should really be
refactored, but that's out of scope" — instead of scope-creeping, it calls a tool
that files the refactor as a child of X. The child runs on its own; once all of
X's children finish, X surfaces for review with its whole tree visible.
This builds on the bundled-prompts overhaul (`system.md` gains one instruction to
use the offload tool). It is otherwise independent.
## Lifecycle
A new task status `WaitingForChildren` is added.
```
Running → WaitingForReview standalone success, no children (existing)
Running → WaitingForChildren standalone success, ≥1 child (new)
Running → Done planning child success (existing)
WaitingForChildren → WaitingForReview all children terminal (new)
WaitingForChildren → Cancelled cancel (new)
```
- Improvement-children are created `Idle` **during** the parent's run and stay
unqueued until the parent's own run finishes — this avoids the parent and a
child working the same repo concurrently.
- When the parent's run succeeds and it has ≥1 non-terminal child, the parent goes
to `WaitingForChildren` and its children are enqueued (they then run under the
normal queue, governed by max-parallel — they are independent, not a forced
sequential chain like planning).
- Children run automatically and reach `Done` on success without their own review
gate (a per-child review would stall the loop). Each child still produces its
own worktree/commit; those worktrees are surfaced under the parent for merge.
- Children emit `CLAUDEDO_BLOCKED:` markers like any run (see the prompt-overhaul
spec). Each child's collected problems roll up onto the **parent's** review card,
so a parent in `WaitingForReview` shows "child N reported a problem" alongside
its own roadblocks.
## Worktree topology & merge
The correctness rule that makes this work:
- **Children base off the parent's worktree HEAD, not the list's base branch.**
The parent's code work lives only on `claudedo/{parentId}` until merged, so a
child refactoring code the parent just wrote must branch from the parent's HEAD
to see it. (Planning children base off the target branch because a planning
parent writes no code — improvement parents do, hence the difference.) The
per-run worktree setup takes the base commit from the parent task's recorded
worktree HEAD when `ParentTaskId` is set and the parent is a non-planning task.
- **Fan-out:** all children branch off the same parent HEAD and run independently
(parallel allowed). Parent-dependency is always satisfied; sibling overlaps
surface later as merge conflicts.
- **Merge reuses the planning orchestrator,** generalized into a shared
"tree merge": build an integration branch off the target, then sequentially
`merge --no-ff` the **parent's own branch** followed by each child branch,
pausing on conflict (continue / abort), exactly as `PlanningMergeOrchestrator`
/`PlanningAggregator` do today. Approving the parent triggers this one guided
flow, merging parent + all children in as few steps as possible. Because
children descend from the parent HEAD, the parent's commits are shared ancestors
and merge cleanly ahead of the children.
- The parent advances to `WaitingForReview` once **all** children are terminal —
counting `Done`, `Failed`, and `Cancelled`, so a failed child can't wedge the
parent forever. Failed/cancelled children are flagged on the review card.
Planning parents keep their existing behavior (parent → `Done` when its chain
finishes); they do not use `WaitingForChildren`.
## Consolidating the child subsystem
Today child handling is planning-coupled. Generalize:
- **`TaskRepository.CreateChildAsync`** — drop the `parent.PlanningPhase != None`
guard. A child can attach to any existing parent. (Planning callers are
unaffected; their parents have a planning phase.) The child sets
`ParentTaskId = parentId`; the caller decides `CreatedBy`.
- **Child-completion coordinator** — generalize planning's
`OnChildFinishedAsync` / `TryCompleteParentAsync` into a single component that,
on any child reaching a terminal state, checks the parent and applies a
**completion policy**:
- *planning parent* → finalize/Done (existing chain advancement stays in the
planning layer: unblock the next chained child).
- *improvement parent* (in `WaitingForChildren`, all children terminal) →
`WaitingForReview`.
- `TaskStateService` remains the sole writer of `Status` and owns the new
transitions (`SubmitForChildrenAsync`, the `WaitingForChildren → WaitingForReview`
advance).
## The offload tool
A narrow MCP tool exposed only to task runs (not the general external surface):
```
SuggestImprovement(title, description) → { childTaskId }
```
- The **server** stamps everything — the agent cannot choose the parent, the
status, or queue anything directly:
- `ParentTaskId = <calling task id>`
- `CreatedBy = <calling task id>` (unambiguous "agent-suggested improvement"
marker — distinct from `null` user/planning tasks and `"mcp"` external tasks)
- `Status = Idle`, same `ListId` as the parent.
- **One layer deep:** the tool rejects the call if the calling task already has a
`ParentTaskId` (a child cannot spawn children).
### Knowing the caller's identity
The always-on external `claudedo` MCP is shared and can't tell which task is
calling. So task runs get a **per-run MCP identity**, mirroring planning's
per-session token:
- `TaskRunner` mints a per-run token and writes a run-scoped `.mcp.json` (or
reuses the global server with a token header) so the offload tool resolves
token → calling task id server-side. A `TaskRunMcpContextAccessor` exposes the
current task id to the tool, the same way `PlanningMcpContextAccessor` does.
- This is the reliable path for both correct provenance and the one-layer-deep
guard — the id is never supplied by the model.
`system.md` gains a short instruction (from the prompt-overhaul spec):
```markdown
## Out-of-scope improvements
If you notice worthwhile work that is genuinely outside this task's scope
(a refactor, a follow-up, tech debt), do NOT do it here. File it with
SuggestImprovement(title, description) and stay focused on the task at hand.
```
## UI
- **Collapsible tree:** children group under their parent (by `ParentTaskId`).
Improvement-children are visually marked as agent-suggested (via
`CreatedBy == parentId`).
- **New status chip** for `WaitingForChildren` (e.g. amber "waiting on N
improvements") with its own color in `StatusColorConverter`.
- **Review card** for a parent in `WaitingForReview` lists child outcomes
(done/failed) and their rolled-up `CLAUDEDO_BLOCKED` problems, and drives the
shared tree-merge (parent + children) via the planning-style sequential flow
with conflict pause/continue/abort.
## Data / migration
- Add `WaitingForChildren` to the `TaskStatus` enum and its EF `ValueConverter`.
No new columns — `ParentTaskId` and `CreatedBy` already exist. No backfill
needed (no existing rows use the new value).
## Touch points
- `src/ClaudeDo.Data/Models/TaskStatus` (enum) + `TaskEntityConfiguration` — new value.
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — generalize `CreateChildAsync`.
- `src/ClaudeDo.Worker/State/TaskStateService.cs``WaitingForChildren` transitions.
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — route to `WaitingForChildren` when
children exist; enqueue children on parent finish; mint per-run MCP token.
- New: child-completion coordinator (generalized from planning) + the offload tool
(e.g. `TaskRunMcpService.SuggestImprovement`) + `TaskRunMcpContextAccessor` +
token auth (mirrors `PlanningTokenAuth`).
- `src/ClaudeDo.Worker/Planning/*` — refactor planning to consume the shared
child-completion coordinator and the shared tree-merge; keep chain-specific
advancement local. Generalize `PlanningMergeOrchestrator` / `PlanningAggregator`
into a reusable tree-merge that also folds in the parent's own branch.
- Worktree setup (`TaskRunner` / `WorktreeManager`) — base an improvement-child's
worktree on the parent task's recorded worktree HEAD instead of the list base.
- UI — tree grouping, `WaitingForChildren` chip/color, parent review card with
child outcomes + rolled-up roadblocks + the merge flow.
- Tests — offload tool stamps parent/createdBy + rejects nested calls;
parent → `WaitingForChildren``WaitingForReview` lifecycle; child worktree
bases off parent HEAD; tree-merge folds parent + children; planning regression
(still reaches Done).
## Open questions for review
1. **Failed child:** parent still advances to `WaitingForReview` with the failure
flagged (default), vs. parent → `Failed` if any child failed.
## Out of scope
- Multi-level nesting (only one layer deep by design).
- Per-list "disable improvement offload" toggle (could come later; the tool is
always available to top-level runs for now).
- Changes to how planning sets up its sequential chain.

View File

@@ -0,0 +1,90 @@
# Debug Logging & Frontend↔Backend Traceability — Design
**Date:** 2026-06-04
**Status:** Approved (pending spec review)
## Goal
Make debug logging rich enough to diagnose problems across the UI↔Worker boundary, while keeping the installed (production) build near-silent. Verbosity is decided by **build configuration, detected at runtime** — no runtime knob, no config field, no `#if DEBUG`:
- **Debug build** (Rider run button) → verbose, console + file.
- **Release build** (installed app) → minimal, file only.
## Decisions (from brainstorming)
1. **Mechanism:** runtime build-config detection via the entry assembly's `DebuggableAttribute` (JIT optimizer disabled ⇒ Debug build). A single `BuildConfig.IsDebug` helper drives ordinary `if` branching — no `#if DEBUG` directives. Rider's run button builds `Debug`; the installer ships `-c Release`.
2. **Scope:** Worker **and** App/Ui. The desktop side currently has no log sink at all — UI/IPC failures vanish today.
3. **Release behavior:** all three log `Warning`+ to file (not silent — capture crashes). Worker drops from its current `Information` to `Warning`.
4. **One shared log file** across both processes, unified timeline.
5. **Correlation:** TaskId-based (option A). Enrich log lines with `TaskId` when one is in scope. No changes to the SignalR contract (`IWorkerClient`/`WorkerHub` untouched → test fakes untouched).
## Verbosity matrix
| Process | Debug build | Release build |
|---|---|---|
| Worker | `Debug` level, console + shared file | `Warning` level, shared file |
| App/Ui | `Debug` level, console + shared file | `Warning` level, shared file |
## Shared log file
- Single daily-rolling file: `~/.todo-app/logs/claudedo-.log` (Serilog appends the date).
- `shared: true` on both processes' file sinks → Serilog coordinates multi-process writes via a global mutex.
- `retainedFileCountLimit: 2`.
- Each line is tagged with a `Process` property (`"worker"` / `"app"`) so the two sides are distinguishable in the interleaved timeline.
> The existing `worker-.log` is replaced by `claudedo-.log`. Task-run NDJSON (`{taskId}_run{n}.ndjson`) and `daily-prep.log` are **out of scope** — they are data streams, not diagnostic logs, and stay exactly as they are.
## Output template
```
[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Process}/{SourceContext} [{TaskId}] {Message:lj}{NewLine}{Exception}
```
- `{Process}``worker` or `app`.
- `{SourceContext}` — the `ILogger<T>` category (the logging class), so you see *which* component spoke.
- `{TaskId}` — the correlation key, defaulted to `-` when no task is in scope (see enricher below).
## Traceability (TaskId correlation)
Use Serilog's `LogContext` (`.Enrich.FromLogContext()` on both processes) plus a small default enricher so `TaskId` is always present (renders `-` when absent — avoids the raw `{TaskId}` token leaking into output).
Push the property at the entry points where a task is in scope; all nested `ILogger<T>` calls inherit it automatically:
- **Worker:** wrap per-task execution in `TaskRunner` (the run/continue entry) with `using (LogContext.PushProperty("TaskId", task.Id))`. This covers the bulk of backend activity (runner, state transitions, worktree, planning) for free.
- **App/Ui:** push `TaskId` in `WorkerClient` task-targeted calls (e.g. RunNow / Cancel / Continue / review actions) so the UI side of a task action carries the same key.
Result: grep one `TaskId` in `claudedo-.log` and read the full UI→Worker→UI story in timestamp order.
This adds **no parameters** to the SignalR surface — correlation rides on the existing `taskId` arguments already present in those calls.
## Implementation surface
A single shared helper keeps the two processes' Serilog setup from drifting.
- **New project:** `ClaudeDo.Logging` — a small library both `ClaudeDo.App` and `ClaudeDo.Worker` reference (keeps `ClaudeDo.Data` free of any Serilog dependency). Contains:
- `BuildConfig.IsDebug` — checks the entry assembly's `DebuggableAttribute` (`IsJITOptimizerDisabled` ⇒ Debug build). Cached static.
- The output template and the default-TaskId enricher.
- `ConfigureLogger(LoggerConfiguration, processTag, logRoot)` — applies level/sink choices by branching on `BuildConfig.IsDebug` (Debug ⇒ `Debug` level + console + file; Release ⇒ `Warning` level + file only). Both processes call it so level/template/retention stay in sync.
- **Worker `Program.cs:34`:** replace the inline `UseSerilog` body with a call into the shared helper (`processTag = "worker"`).
- **App `Program.cs`:** add Serilog packages; build a logger via the shared helper (`Process = "app"`) and register it with `sc.AddLogging(b => b.AddSerilog(logger, dispose: true))`. App currently registers **no** logging at all, so this also makes `ILogger<T>` injection actually work UI-side. Remove/keep `.LogToTrace()` as appropriate (Avalonia internal trace, separate concern — leave it).
- **App shutdown:** flush/close the logger (`Log.CloseAndFlush()` or dispose via the container's existing `finally`).
### Packages to add (App project)
- `Serilog.Extensions.Logging` (bridge `ILogger` → Serilog)
- `Serilog.Sinks.File`
- `Serilog.Sinks.Console`
- (Worker already has Serilog + File sink; add `Serilog.Sinks.Console` for the Debug console output.)
## Testing
- This is logging wiring; per project policy, no tests that spawn the real Claude CLI and no heavy test scaffolding for log output.
- Light verification: a unit-level check that the default enricher yields `-` when no `TaskId` is pushed, and (if practical) that `ConfigureLogger` wires the expected sinks. `BuildConfig.IsDebug` reflects the test assembly's own build config, so it can't be flipped within one run — assert each branch by passing the level/flag explicitly rather than relying on the ambient value, or verify the Release path and smoke-test Debug manually from Rider.
- Manual smoke test (documented, not automated): run from Rider, confirm console + `claudedo-.log` show `Debug` lines with `Process`/`SourceContext`; run a task and confirm both `app` and `worker` lines share the same `[TaskId]`.
## Out of scope
- Runtime/config log-level knob.
- Per-call correlation IDs for non-task flows (connect, config edits, prep) — TaskId-only for now; revisit if a non-task flow proves to be a black hole.
- Changes to task-run NDJSON capture or `daily-prep.log`.
- Any change to `IWorkerClient` / `WorkerHub` signatures.

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