234 Commits

Author SHA1 Message Date
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
313 changed files with 31956 additions and 3983 deletions

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

2
.gitignore vendored
View File

@@ -45,6 +45,8 @@ artifacts/
# Avalonia / XAML designer # Avalonia / XAML designer
*.designer.cs *.designer.cs
# ...but EF Core migration Designer files are real source and must be tracked
!**/Migrations/*.Designer.cs
# Project-specific # Project-specific
*.db *.db

View File

@@ -35,18 +35,23 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data) - EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker) - `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder - Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
- Task status flow: Manual | Queued -> Running -> Done | Failed - Task status flow: Idle | Queued -> Running -> Done | Failed | Cancelled
- Worktree state flow: Active -> Merged | Discarded | Kept - 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` - Commit messages use conventional format: `{commitType}(slug): title`
- Views use compiled bindings (`x:DataType`) - Views use compiled bindings (`x:DataType`)
- ViewModels use `[ObservableProperty]` and `[RelayCommand]` source generators - ViewModels use `[ObservableProperty]` and `[RelayCommand]` source generators
## Building & Testing ## Building & Testing
`dotnet build ClaudeDo.slnx` requires .NET 9; on .NET 8 build individual projects instead.
```bash ```bash
dotnet build ClaudeDo.slnx dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj # pulls in Ui + Data
dotnet test tests/ClaudeDo.Worker.Tests dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet test tests/ClaudeDo.Worker.Tests # also: Data.Tests, Ui.Tests, Installer.Tests, Releases.Tests
``` ```
## Docs ## Docs

View File

@@ -8,6 +8,7 @@
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" /> <Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <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.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.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.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />

View File

@@ -1,228 +1,286 @@
# ClaudeDo — Offene Punkte # ClaudeDo — Offene Punkte
Stand: 2026-04-13 nach Slice F. Branch `main` @ `48e4aab`. Alle Tests grün (38/38), Build 0 Warnings. Stand: 2026-04-30. Neu erstellt nach Code-Audit gegen `plan.md`, `improvement-plan.md` und `mailbox-proposal.md`.
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. Die alte Version dieses Dokuments war auf 2026-04-13 ("nach Slice F") datiert und ignorierte die seither gelandeten Slices (Planning Sessions, Prime Claude, Self-Update, Externe MCP-Tools, editierbare Status/Tags, BlockedBy-Chains). Diese Version trennt sauber zwischen **erledigt**, **teilweise**, **offen** — und listet das, was inzwischen gebaut wurde, explizit als „shipped" auf, damit es nicht verloren geht.
Legende: ✅ DONE — 🟡 PARTIAL — ⬜ OPEN — ⛔ DROPPED
---
## 0. Was seit dem 2026-04-13 dazugekommen ist
Diese Slices gab es im alten Dokument noch nicht (oder nur als Platzhalter). Sie sind **fertig im Code**, brauchen aber jeweils noch ein paar Polish-Punkte (siehe Sektion 2/3).
| Slice | Worker-Anker | UI-Anker | Status |
|---|---|---|---|
| **Planning Sessions** (Plan B+C) | `Planning/PlanningSessionManager`, `PlanningChainCoordinator`, `PlanningMcpService` | `Views/Planning/PlanningDiffView`, `ConflictResolutionView`, `UnfinishedPlanningModalView` | ✅ Code, manuelle Verifikation siehe §1.1 |
| **Prime Claude** (geplante Recurrence) | `Prime/PrimeScheduler`, `NextDueCalculator`, `PrimeRunner` | `ViewModels/Modals/PrimeClaudeTabViewModel`, `Views/Controls/ThemedDatePicker` | ✅ Code, manuelle Verifikation siehe §1.2 |
| **Self-Update System** (Gitea Releases) | — | `ClaudeDo.Releases` (`ReleaseClient`, `SelfUpdater`, `ChecksumVerifier`, `VersionComparer`), `ClaudeDo.Installer` (Pages/Steps/Core) | ✅ Code, manuelle Verifikation siehe §1.3 |
| **Externes MCP-Endpoint** (11 Tools für Drittsessions) | `External/ExternalMcpService` (`ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus`, `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`), `ExternalMcpAuthMiddleware` (X-ClaudeDo-Key) | — | ✅ Code, ohne Tests am Endpoint selbst |
| **Editierbare Status & Tags** (entkoppelt vom `agent`-Tag) | `WorkerHub.SetTaskStatus`, `SetTaskTags`, `UpdateTaskAgentSettings`; Queue-Picker filtert nicht mehr nach `agent`-Tag | `DetailsIslandViewModel`, Status-/Tag-Kontextmenü in `TasksIslandView` | ✅ Code |
| **BlockedBy-Chains** (sequenzielle Subtask-Ausführung) | `TaskStateService.BlockOn`/`UnblockAsync`, `QueuePicker` filter `BlockedByTaskId IS NULL`, `PlanningChainCoordinator.OnChildFinishedAsync` | Drittes Feld neben `Status` und `PlanningPhase` | ✅ Code, Migration `20260423154708_AddPlanningSupport` |
| **Worker-State-Konsolidierung** | `TaskStateService` ist alleiniger Owner von `Status`/`PlanningPhase`/`BlockedByTaskId`-Writes; `OverrideSlotService` ausgelagert; `QueueWaker` + `QueuePicker` getrennt | — | ✅ Code |
| **MarkdownView / Tabbed Settings / About-Modal / Prime-Status-Footer / Doppelklick-Edit** | — | `Views/MarkdownView`, `SettingsModalView` als `TabControl`, `AboutModalView`, transient Prime-Status in Footer, `DoubleTapped` an List/Task-Rows | ✅ Code |
--- ---
## 1. Verification (vor allem anderen) ## 1. Verification (vor allem anderen)
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. Der Großteil der Verification-Steps aus `plan.md` ist im Code abgedeckt — was fehlt ist die **manuelle Bestätigung mit explizit notiertem Pass-Kriterium**. Ohne falsifizierbare Observable produziert ein Manual-Run nur "sah ok aus".
| # | Plan | Status | Was tun | ### 1.0 Plan-Verification 113
|---|------|--------|---------|
| 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 |
**Vorschlag:** Wir machen einmal Step 4 (Haiku-Happy-Path) gemeinsam — wenn das läuft, ist die ganze Pipeline lebendig. | # | Item | Status | Pass-Kriterium (was muss konkret zu sehen sein) |
|---|------|---|---|
| 1 | Schema-Init | ✅ | `~/.todo-app/todo.db` + `*-wal` + `*-shm` existieren; EF-Migrationsverlauf in `__EFMigrationsHistory` enthält alle 8 Migrationen; Worker-Log: „listening on …" |
| 1a | SignalR-Endpoint | ✅ | `curl http://127.0.0.1:47821/hub` → HTTP 400 (kein Handshake) |
| 1b | Hub-Roundtrip `Ping` | 🟡 | UI-Statusbar zeigt „Connected"; `WorkerClient.PingAsync()` liefert `"pong"` (UI-Test fehlt) |
| 2 | `claude --version` Preflight | ✅ | `Worker/Lifecycle/ClaudeCliPreflight.cs` + Wiring in `Program.cs`. Kaputter `claude_bin``LogCritical(...) + Environment.Exit(1)`. Skip via `CLAUDEDO_SKIP_CLI_PREFLIGHT=1`. Tests: `tests/.../Lifecycle/ClaudeCliPreflightTests.cs` |
| 3 | Smoke-Spawn (`claude -p` Prompt „ping") | ⬜ | `task_runs`-Row mit `session_id NOT NULL`, `result` non-empty, `output_tokens > 0` |
| 4 | E2E Happy Path (Non-Worktree) | ⬜ | Liste „Test" anlegen → Task „Schreibe ein Haiku über Intralogistik" → `tasks.status='Done'`, `tasks.result IS NOT NULL`, Logfile unter `~/.todo-app/logs/<taskId>.ndjson`, UI-Row mit Done-Badge |
| 5 | Worktree Happy Path | ⬜ | Liste mit `working_dir` auf temp-Repo, Task mit Codeänderung → `worktrees.state='active'`, `head_commit IS NOT NULL`, `diff_stat` non-empty, Branch `claudedo/<id>` auf Disk |
| 6 | No-Changes-Run | ⬜ | Prompt der nichts ändert → `tasks.status='Done'` aber `worktrees.head_commit IS NULL`, `diff_stat IS NULL` |
| 7 | Kein Git-Repo | ⬜ | `working_dir=C:\Temp` (kein Repo) → `tasks.status='Failed'`, **keine** `worktrees`-Row, Worker-Log enthält Git-Fehler |
| 8 | Merge-UI | 🟡 | `MergeTask`-Hub-Methode + `MergeModalView` vorhanden, manueller Run nicht durchgespielt → `worktrees.state='merged'`, im Ziel-Repo `git log` zeigt Commit, `git worktree list` ohne Branch |
| 9 | Override-Parallelität | 🟡 | `OverrideSlotService`-Tests grün; UI-E2E nicht durchgespielt → `WorkerHub.GetActive` ≥ 2 Einträge bei Run+RunNow |
| 10 | Schedule | 🟡 | `QueuePicker`-Tests grün; UI-E2E nicht → `scheduled_for=now+2min` bleibt Queued, dann automatisch Running, `started_at >= scheduled_for` |
| 11 | Worker-Offline-Erkennung | 🟡 | `WorkerClient.OnServerConnectionClosed` + Auto-Reconnect implementiert (`WithAutomaticReconnect`); visuell prüfen: nach `taskkill` der Worker-Exe wechselt Statusbar in ≤ 5s auf „Offline", `RunNow`-Buttons disabled |
| 12 | Live-Stream | 🟡 | `ClaudeProcess` streamt NDJSON via `TaskMessage`-Event, UI hat `LiveTail`; visuell prüfen: während Run laufen ndjson-Zeilen ein |
| 13 | Wake-up (`WakeQueue` nach Anlage) | 🟡 | `QueueWaker.Wake()` wird bei Enqueue aufgerufen; visuell prüfen: Task wechselt in ≤ 1s auf Running (statt nach `queue_backstop_interval_ms`=30s) |
**Empfohlener Sprint:** Steps 37 in einem Rutsch durchspielen (alles non-UI), parallel daneben 813 visuell beim normalen App-Lauf abhaken.
### 1.1 Planning Sessions — Manual Verification (unverändert relevant)
Bedingt durch Slice "Planning B/C". Ablauf identisch zur alten open.md:
1. Manual-Task mit Title + TODO-Description anlegen.
2. Rechtsklick → **Open planning Session** → Windows Terminal mit Claude CLI öffnet.
3. In CLI: zwei Children via `mcp__claudedo__create_child_task` anlegen.
4. UI: Drafts erscheinen eingerückt, italic, mit `DRAFT`-Badge; Parent zeigt `PLANNING`-Badge.
5. Chevron klappt ein/aus.
6. CLI `finalize` → Children werden Queued (erste) bzw. Queued+BlockedBy (Rest); Parent flippt von `Active` auf `Finalized` (`PLANNED`-Badge); erste Child startet automatisch.
7. Neuer Planning-Task, Terminal ohne Finalize schließen → Rechtsklick öffnet Resume/Finalize-now/Discard-Modal.
8. Delete-Versuch auf Parent mit Children → freundlicher Fehlerdialog, kein Delete.
**Bekannte Follow-ups (non-blocking):**
-`Border.badge.planned` (blau) wird jetzt bei `Finalized` angewendet — `TaskRowView` nutzt `Classes.planning`/`Classes.planned` gebunden an `IsPlanActive`/`IsPlanFinalized`; der Child-„PLANNED"-Badge nutzt direkt `planned`.
- ✅ Tote `Instance`-Statics auf `BoolToItalicConverter` und `BoolToDraftOpacityConverter` entfernt (Registrierung läuft über das Resource-Dictionary in `App.axaml`).
-`Ui.Tests` IWorkerClient-Fakes auf gemeinsame Basis `StubWorkerClient` rebased — kein Constructor-Drift mehr; die drei Fakes überschreiben nur ihre relevanten Member.
### 1.2 Prime Claude — Manual Verification
Slice "Prime" (Recurrence-Scheduler).
1. Settings → Prime-Claude-Tab → Schedule mit `at: 09:00`, `every: workday`, `task_template: "Daily Standup"` anlegen.
2. Test mit verschobenem `IPrimeClock` (oder Schedule mit `at: now+1min`) → bei Trigger erscheint Toast/Footer-Notification „Prime fired", neuer Task entsteht in der Ziel-Liste.
3. Worker-Restart innerhalb des Schedule-Fensters → Catch-up läuft genau einmal (kein Doppelfeuer).
4. Schedule editieren → `next_due_at` wird neu berechnet; UI-Anzeige aktualisiert.
5. Schedule löschen → keine weiteren Trigger, keine ghost-Tasks.
### 1.3 Self-Update — Manual Verification (aus alter open.md, weiterhin gültig)
Voraussetzung: funktionierendes Gitea-Release unter `git.kuns.dev/releases/ClaudeDo` mit drei Assets — `ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, `checksums.txt`.
1. Baseline-Version (z.B. `0.2.x`) normal installieren.
2. Neues Release `v0.3.0` mit frischem Installer + App-Zip + Checksums veröffentlichen.
3. App starten → Banner erscheint: `Update available: v0.2.x → v0.3.0`.
4. **Update now** klicken → App schließt, Installer öffnet im Update-Mode, läuft, restartet Worker.
5. App neu starten → Banner weg; `Help → Check for updates` zeigt kurz „You're up to date (v0.3.0)".
6. `v0.2.x`-Installer manuell starten → bietet Self-Update auf v0.3.0 an. **Update** → laufende Exe wird ersetzt, Wizard öffnet auf neuer Version.
7. Schritt 6 mit **Continue anyway** → Wizard öffnet ohne Self-Update.
8. Schritt 6 mit **Cancel** → Installer beendet ohne Aktion.
9. Network-Kill in App und Installer beim Start → silent fallback (kein Error, kein Banner).
--- ---
## 2. UI-Polish (kritisch für Benutzbarkeit) ## 2. UI-Polish
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:** `Views/ListSettingsModalView.axaml` + zugehöriges VM
### 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. - **Aktuell:** plain `TextBox` — Pfad muss getippt werden.
- **Soll:** Button "…" daneben → öffnet `IStorageProvider.OpenFolderPickerAsync`, schreibt Pfad ins Feld. - **Soll:** Button …" daneben → öffnet `IStorageProvider.OpenFolderPickerAsync`, schreibt Pfad ins Feld.
- **Aufwand:** klein, ~30 Zeilen. - **Aufwand:** klein.
### 2.2 Delete-Confirmation ### 2.2 Delete-Confirmation
- **Dateien:** `MainWindowViewModel.DeleteList`, `TaskListViewModel.DeleteTask` - **Aktuell:** Listen/Tasks-Delete läuft direkt ohne Rückfrage. Datenverlust-Risiko.
- **Aktuell:** löscht direkt ohne Rückfrage. Datenverlust-Risiko. - **Soll:** generischer `ConfirmDialog` (1× bauen, mehrfach nutzen), Mini-Dialog „Wirklich löschen?".
- **Soll:** Mini-Dialog "Wirklich löschen?" mit Ja/Nein. - **Aufwand:** klein.
- **Aufwand:** klein, generisches `ConfirmDialog` lohnt sich (1× bauen, mehrfach nutzen).
### 2.3 Markdown-Rendering für Result + Description ### 2.3 Markdown-Rendering Result + Description
- **Datei:** `src/ClaudeDo.Ui/Views/TaskDetailView.axaml` - `Views/MarkdownView.axaml` + Detail-Pane verwenden Markdown.Avalonia.
- **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 ### 2.4 Live-Log Auto-Scroll
- **Datei:** `src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs` (oder im VM) - **Datei:** `Views/DetailsIslandView.axaml(.cs)` (Live-Tail-Section)
- **Aktuell:** ndjson-Zeilen werden angehängt, aber Scrollposition bleibt stehen. - **Aktuell:** ndjson-Zeilen werden angehängt, Scrollposition bleibt stehen.
- **Soll:** Bei jeder neuen Zeile `ScrollViewer.ScrollToEnd()` solange User nicht manuell hochgescrollt hat (Sticky-Bottom-Pattern). - **Soll:** Sticky-Bottom-Pattern — bei jeder neuen Zeile `ScrollToEnd()`, solange User nicht manuell hochgescrollt hat. Attached-Behavior reicht.
- **Aufwand:** klein, ein attached behavior reicht.
### 2.5 Diff-Viewer ### 2.5 Diff-Viewer 🟡
- **Datei:** `TaskDetailViewModel.ShowDiffAsync` - `DiffModalView.axaml` + `PlanningDiffView` existieren; integriert für Planning-Merges.
- **Aktuell:** `Process.Start("cmd", "/k git diff …")` — separates Konsolenfenster, hässlich. - **Offen:** Task-Level-Diff (Worktree vs. main) noch nicht im Modal-Flow geprüft. Verwenden statt `Process.Start("cmd /k git diff …")`.
- **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 ### 2.6 Status-Bar Active-Tasks Live-Update
- **Datei:** `StatusBarViewModel` - **Datei:** `ViewModels/StatusBarViewModel`
- **Risiko:** das Slot-State-Update kommt vom WorkerClient, aber `RunNowCommand.NotifyCanExecuteChanged` triggert nicht pro Item bei `IsConnected`-Wechsel (vom Slice-F-Agent dokumentiert). - **Risiko:** `RunNowCommand.NotifyCanExecuteChanged` triggert nicht pro Item bei Connection-Change.
- **Soll:** Über `WeakReferenceMessenger` (CommunityToolkit.Mvvm) eine Connection-Change-Message verteilen, an die alle `TaskItemViewModel` lauschen. - **Soll:** `WeakReferenceMessenger`-Connection-Change-Message; alle `TaskRowViewModel` lauschen.
- **Aufwand:** klein, aber muss sauber gemacht werden. - **Aufwand:** klein, muss sauber gemacht werden.
### 2.7 Settings-Dialog ### 2.7 Settings-Dialog
- **Datei:** *neu*`Views/SettingsDialog.axaml` + VM - `SettingsModalView` als `TabControl`, Tabs: General, Prime Claude, etc. Persistiert in `~/.todo-app/ui.config.json` und `worker.config.json`.
- **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. ### 2.8 (NEU) Planning-Phase Badge-Farbe für `Finalized` ✅
- **Aufwand:** mittel. Achtung: Port-Wechsel braucht Worker-Restart. `Finalized` zeigt jetzt den blauen `planned`-Badge (Class-Binding in `TaskRowView`).
### 2.9 (NEU) Tote Converter-Statics entfernen ✅
`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance` entfernt.
--- ---
## 3. Worker-Robustheit ## 3. Worker-Robustheit
### 3.1 CLI-Preflight beim Worker-Start ### 3.1 CLI-Preflight beim Worker-Start
- **Datei:** `src/ClaudeDo.Worker/Program.cs` - `src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs` + Wiring in `Program.cs`. Tests: `tests/.../Lifecycle/ClaudeCliPreflightTests.cs`. Skippable via `CLAUDEDO_SKIP_CLI_PREFLIGHT=1`.
- **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 ### 3.2 Worktree-Cleanup beim Anlege-Failed
- **Datei:** `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` - **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 zwischen `git worktree add` und DB-Insert `git worktree remove --force` als Best-Effort-Cleanup.
- **Soll:** try/finally — bei Fehler `git worktree remove --force` als Best-Effort-Cleanup.
- **Aufwand:** klein. - **Aufwand:** klein.
### 3.3 Logging über `Microsoft.Extensions.Logging` strukturieren ### 3.3 Logging über file-Sink ⬜
- **Datei:** alle Worker-Komponenten - ILogger ist überall verdrahtet, aber kein File-Sink konfiguriert.
- **Aktuell:** ILogger wird benutzt, aber kein File-Sink konfiguriert. - **Soll:** Serilog oder `Karambolage.Extensions.Logging.File` — für Service-Modus zwingend, console-only ist im SCM-Fenster verloren.
- **Soll:** Optional Serilog oder einfach `AddFile` (Karambolage.Extensions.Logging.File) — Service-Modus braucht persistente Logs außerhalb der Console.
- **Aufwand:** klein. - **Aufwand:** klein.
### 3.4 Tag-Negation / Exclusion (Plan-TODO) ### 3.4 Tag-Negation / Exclusion
- **Plan-Sektion:** "Tag-Modell" - Tags sind weiterhin rein additiv (`list_tags task_tags`). Nach Slice „Editierbare Tags" weniger dringend, aber nicht gelöst.
- **Aktuell:** Tags sind rein additiv (`list_tags task_tags`). - **Soll:** entweder neue Tabelle `task_tag_exclusions` oder Prefix `!tag` im task_tags-Eintrag.
- **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. - **Aufwand:** mittel — Schema + Repo + Tests + UI.
--- ---
## 4. Service-Deployment (Plan-Sektion „Worker als Windows-Service") ## 4. Service-Deployment
### 4.1 Windows-Service-Hosting in Code ### 4.1 Worker-Autostart via Startup-Shortcut ✅ (ersetzt Scheduled Task + Windows-Service)
- **Datei:** `src/ClaudeDo.Worker/Program.cs` - Der Worker läuft als `WinExe` (kein Konsolenfenster) + Serilog-File-Sink (`~/.todo-app/logs/worker-*.log`) + Single-Instance-Mutex.
- **Pakete:** `Microsoft.Extensions.Hosting.WindowsServices` - Autostart über eine **Startup-Ordner-Verknüpfung** `ClaudeDo Worker.lnk` (`%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\`), die der Installer via `AutostartShortcut`/`ShortcutFactory` COM-Helper anlegt. Kein Scheduled Task, kein Windows-Service.
- **Soll:** - `StartWorkerStep` startet den Worker per `Process.Start`; `StopWorkerStep` beendet ihn per prozessbasiertem Kill.
```csharp - Die App (`IslandsShellViewModel`) startet den Worker nicht selbst. Bei offline-Worker ~12s nach App-Start: einmaliges `WorkerConnectionModal` (Start Worker / Rerun Installer / Dismiss); Connection-Status-Pill in der Fußzeile ist ein Button zum erneuten Öffnen des Modals.
builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker"); - `UninstallRunner` löscht die Startup-`.lnk`; migriert ältere Installs durch best-effort-Löschen des Legacy-Scheduled-Tasks „ClaudeDoWorker" und des Legacy-Windows-Service.
builder.Logging.AddEventLog(...); - **Manuelle E2E-Verifikation am Gerät ausstehend** (Logoff/Logon-Autostart, Update-Pfad, Uninstall).
```
- **Aufwand:** klein.
### 4.2 Pfad-Auflösung absolut machen ### 4.2 Pfad-Auflösung absolut
- Bereits in `WorkerConfig.Load` per `Paths.Expand` gemacht — verifizieren, dass auch `cfg.ClaudeBin` ggf. in Service-PATH gefunden wird. - `WorkerConfig.Load` expandiert `~`/`%USERPROFILE%` für alle Pfad-Felder.
### 4.3 Install-Skripte / Doku ### 4.3 Install-Skripte / Doku
- **Datei:** *neu* — `docs/install-service.md` oder `scripts/install-service.cmd` - **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. - **Inhalt:** `dotnet publish` + `sc.exe create` + `sc.exe failure` + Hinweis auf `obj=` (User-Account) wegen Claude-CLI-Session.
- **Aufwand:** klein. - **Aufwand:** klein.
### 4.4 (später) Installer-Projekt ### 4.4 Installer-Projekt
- WiX/MSIX, registriert Service + UI-Shortcut. Plan-Sektion „Offene Punkte". - `ClaudeDo.Installer` (WPF) + `ClaudeDo.Releases` mit Pages/Steps/Core/Theme — Self-Update funktioniert (siehe §1.3).
--- ---
## 5. Tests / CI ## 5. Tests / CI
### 5.1 GitHub-Actions / Gitea-Actions Pipeline ### 5.1 CI-Pipeline (Gitea Actions) ⬜
- **Datei:** *neu* — `.gitea/workflows/ci.yml` (oder `.github/workflows/ci.yml`) - **Datei (neu):** `.gitea/workflows/ci.yml`
- **Inhalt:** `dotnet restore` → `dotnet build --no-restore` → `dotnet test --no-build`. Auf Push + PR. - **Inhalt:** `dotnet restore``dotnet build` (csproj-weise wegen `.slnx`-Bug auf .NET 8)`dotnet test`. Auf Push + PR.
- **Achtung:** Pipeline darf NICHT die `.slnx` als Build-Target nehmen — explizite csproj-Liste in einem checked-in Build-Skript.
- **Aufwand:** klein. - **Aufwand:** klein.
### 5.2 Echter SignalR-Roundtrip-Test ### 5.2 SignalR-Hub-Tests ✅
- **Datei:** *neu* — `tests/ClaudeDo.Worker.Tests/Hub/WorkerHubTests.cs` - `tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs`, `AgentSettingsHubTests.cs` testen Hub-Methoden via Fakes (kein realer SignalR-Roundtrip, aber alle Code-Pfade abgedeckt).
- **Soll:** mit `WebApplicationFactory` + `HubConnectionBuilder` testen, dass `Ping`, `GetActive`, `RunNow`-Throw-Verhalten korrekt sind. Plan-Verification 1b + 9. - **Optional verbleibt:** echter Roundtrip-Test mit `WebApplicationFactory<Program>` + `HubConnectionBuilder` für End-to-End-Validierung der SignalR-Pipeline. Niedriger Mehrwert solange Fakes alle Methoden treffen.
- **Aufwand:** mittel.
### 5.3 Smoke-Test gegen echten `claude` ### 5.3 Smoke-Test gegen echten `claude`
- **Datei:** *neu* — `tests/ClaudeDo.Worker.Tests/Runner/ClaudeProcessSmokeTest.cs` - **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. - **Soll:** Real-CLI-Test mit `[Fact(Skip="...")]` ausgegraut, nur lokal aktiviert wenn `CLAUDE_AUTHENTICATED=1` Env-Var gesetzt ist.
- **Aufwand:** klein. - **Aufwand:** klein.
### 5.4 (NEU) ExternalMcpService-Tests ⬜
- `External/ExternalMcpService` hat 11 Tools, aber nur partielle Coverage in `tests/.../External/ExternalMcpServiceTests.cs`. Für jedes Tool mindestens einen Happy-Path + einen Error-Pfad ergänzen.
--- ---
## 6. Dokumentation ## 6. Dokumentation
### 6.1 README.md ### 6.1 README.md
- Komplett fehlt. Mind. 1× kurz: was ist es, wie starten (Worker + UI), wo Config. - Komplett fehlt. Mind. 1× kurz: was ist es, wie starten (Worker + UI), wo Config, wie Self-Update.
- **Aufwand:** klein.
### 6.2 `docs/architecture.md` ### 6.2 docs/architecture.md 🟡
- In `plan.md` schon teilweise enthalten — kann entweder konsolidiert oder explizit ausgegliedert werden. - In `plan.md` enthalten — entweder konsolidieren oder explizit ausgliedern. CLAUDE.md-Dateien pro Projekt sind aktuell de-facto-Architecture-Doc.
### 6.3 ADRs für die getroffenen Entscheidungen ### 6.3 ADRs
- Z.B. „SignalR vs. SQLite-Polling für IPC", „Worktree pro Task", „SignalR über Loopback ohne Auth". - Vorschläge: „SignalR vs. SQLite-Polling für IPC", „Worktree pro Task", „TaskStateService als alleiniger State-Owner", „BlockedByTaskId statt Status='Waiting'", „External MCP als zweite WebApplication".
- **Aufwand:** klein, hilfreich für später. - **Aufwand:** klein, hilfreich für später.
### 6.4 (NEU) Mailbox-Proposal ⬜
- `docs/mailbox-proposal.md` ist als Vorschlag vorhanden, nicht implementiert. Entscheidung: bauen, droppen oder parken? Wenn droppen → Datei entfernen, sonst klare Roadmap.
--- ---
## 7. Bekannte Code-Schulden / Smells ## 7. Bekannte Code-Schulden / Smells
| Stelle | Issue | | Stelle | Issue | Status |
|---|---| |---|---|---|
| `WorkerHub.GetActive` returnt `IReadOnlyList<object>` mit anonymen Typen | Sollte ein expliziter DTO sein (`ActiveTaskDto`), den Worker UND Ui teilen. Aktuell duplizieren beide das Schema. | | `WorkerHub.GetActive` returnt `IReadOnlyList<object>` mit anonymen Typen | Sollte expliziter `ActiveTaskDto` sein, den Worker UND UI teilen | ✅ (gibt bereits `IReadOnlyList<ActiveTaskDto>` zurück) |
| `TaskRunner` führt eine `if (list.WorkingDir != null)` Verzweigung mitten in der Methode | Strategy-Pattern (`IRunStrategy`: SandboxStrategy, WorktreeStrategy) wenn die Methode wächst. Aktuell noch klein genug. | | `TaskRunner` führt eine `if (list.WorkingDir != null)`-Verzweigung mitten in der Methode | Strategy-Pattern wenn die Methode wächst, aktuell noch klein genug | ⬜ |
| `App.Services` als public static `ServiceProvider` | Service-Locator-Antipattern. Toleriert, weil nur in `App.OnFrameworkInitializationCompleted` verwendet. Falls mehr Code drauf zugreift → echtes DI durchziehen. | | `App.Services` als public static `ServiceProvider` | Service-Locator-Antipattern, toleriert weil nur in `App.OnFrameworkInitializationCompleted` | ⬜ |
| Embedded `schema.sql` ohne Versionierung | Solange das Schema nicht in Production läuft, OK. Sobald User-Daten existieren → `migrations/` Folder + Version-Tabelle. | | Embedded `schema.sql` ohne Versionierung | Durch EF-Core-Migrationen ersetzt | ✅ |
| CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer. | | CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer | ✅ (`.gitattributes` angelegt) |
| Tote Converter-Instances (`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance`) | Per Resource-Dictionary registriert, Statics ungenutzt | ✅ (entfernt) |
| 1 unausgeführter `// TODO` in `DetailsIslandViewModel` (`SendPromptAsync` ohne Hub-Methode) | Entweder Hub-Methode bauen oder TODO entfernen | ✅ (im Main-Code nicht mehr vorhanden) |
--- ---
## Empfohlene Reihenfolge für die nächste Session ## 8. Improvement Plan (improvement-plan.md, Stand 2026-04-13)
1. **Verification Step 4** zusammen durchspielen → falls etwas grundlegend kaputt ist, jetzt finden, nicht später. | ID | Item | Status | Bemerkung |
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. | IP-1 | UI ↔ Worker Auto-Reconnect | ✅ | `WorkerClient` mit `WithAutomaticReconnect()` + Reconnect-Handler |
4. **Markdown-Rendering (2.3)** — größer, lohnt sich aber für Lesbarkeit. | IP-2 | Listen-Modus „Notes" (non-autonomous) | ⬜ | Nach Slice „editierbare Status/Tags" weniger dringend (man kann jetzt einen Task ohne `agent`-Tag idle lassen), aber `lists.kind` als sauberer Mode-Switch fehlt. |
5. **Worktree-Cleanup (3.2)** — Robustheit, bevor wir Worktrees ernsthaft nutzen. | IP-3 | Doppelklick öffnet Edit-Dialog | ✅ | `DoubleTapped`-Handler in `ListsIslandView`, `TasksIslandView` |
6. **CI-Pipeline (5.1)** — automatisches Sicherheitsnetz für alles weitere. | IP-4 | Tag Multi-Select Control | ⬜ | Tags sind via Picker im Detail-Pane editierbar, aber kein dediziertes Multi-Select-Control mit Auto-Vervollständigung in Editor-Dialogen. |
7. **Service-Deployment (4)** — wenn die App lokal stabil läuft. | IP-5 | Rechtsklick-Kontextmenü | ✅ | Listen + Tasks haben Context-Menüs (Edit, Delete, Run Now, Show Diff, Merge, Cancel, Status, Tags) |
8. **Settings-Dialog (2.7)** + **Diff-Viewer (2.5)** — Polish. | IP-6 | Schema-Migration-Mechanismus | ✅ | EF-Core-Migrations + `__EFMigrationsHistory` |
9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird. | IP-7 | Status-Bar Reconnect-States | ✅ | `connected`/`connecting`/`reconnecting`/`offline` farbcodiert |
| IP-8 | Tag-Repository `GetAllKnownTagsAsync` | ✅ | `TagRepository.GetAllAsync` + `WorkerClient.GetAllTagsAsync` |
Punkte 13 sind ein realistischer Block für eine Session.
--- ---
## Self-Update — Manual Verification ## 9. Empfohlene Reihenfolge für die nächsten Sessions
Preconditions: a working Gitea release at `git.kuns.dev/releases/ClaudeDo` with three assets — `ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, and `checksums.txt` listing both. **Block 1 — Verification durchspielen** (kein neuer Code, nur Beweis):
1. §1.0 Steps 37 manuell (Smoke + E2E + Worktree + No-Changes + Kein-Repo) — ist die Pipeline wirklich lebendig?
2. §1.1 Planning-Walkthrough — nach den uncommitted Coordinator-Änderungen einmal durchspielen.
3. §1.2 Prime-Walkthrough — Schedule-Trigger einmal beobachten.
1. Install a baseline version (e.g. `0.2.x`) normally. **Block 2 — Niedrig hängende UI-Polish** (eine Session):
2. Publish a new release tagged `v0.3.0` with fresh installer + app zip + checksums. 4. §2.1 Folder-Picker
3. Launch the app — confirm the banner appears: `Update available: v0.2.x → v0.3.0`. 5. §2.2 Delete-Confirmation
4. Click **Update now** — app closes, installer opens in Update mode, runs, restarts the worker. 6. §2.4 Live-Log Auto-Scroll
5. Re-launch the app — banner is gone; `Help → Check for updates` briefly shows "You're up to date (v0.3.0)". 7. §2.6 Status-Bar Live-Update
6. Run the `v0.2.x` installer manually — confirm it prompts to self-update to v0.3.0. Click **Update** → running exe is replaced and the wizard opens on the new version. 8. §2.8 Planning-Badge-Farbe + §2.9 tote Converter weg
7. Repeat step 6 with **Continue anyway** → wizard opens without self-update.
8. Repeat step 6 with **Cancel** → installer exits without any action.
9. Kill network during startup in both app and installer → confirm silent fallback (no errors, no banner, wizard opens normally).
--- **Block 3 — Robustheit & Service-Deployment**:
9. §3.2 Worktree-Cleanup
10. §3.3 File-Sink-Logging
11. §4.3 Install-Skripte/Doku
## Planning Sessions — Manual Verification (Plan C UI) **Block 4 — Sicherheitsnetz**:
12. §5.1 Gitea-Actions CI-Pipeline (csproj-weise)
13. §5.3 Smoke-Test gegen echten claude
14. §5.4 ExternalMcpService-Tests vervollständigen
Requires Plan B (worker hub endpoints) merged. Until then, only the UI structure/styling checks are meaningful. **Block 5 — Dokumentation & Aufräumen**:
15. §6.1 README
16. §6.3 ADRs (mind. die fünf wichtigsten)
17. §6.4 Mailbox-Proposal: bauen/droppen entscheiden
18. §7 Smells: `ActiveTaskDto`, `.gitattributes`, TODO-Comment
1. Create a Manual task with a title and a TODO-ish description. **Block 6 — Optional / wenn Bedarf konkret wird**:
2. Right-click the task → **Open planning Session** — Windows Terminal opens with Claude CLI running (Plan B). 19. §3.4 Tag-Negation
3. Ask Claude to create two child tasks via `mcp__claudedo__create_child_task`. 20. §IP-2 Notes-Modus
4. Watch the UI: drafts appear indented under the parent, italic, reduced opacity, with a `DRAFT` badge. 21. §IP-4 Tag Multi-Select Control
5. The parent shows a `PLANNING` badge. Click the chevron → children collapse; click again → children expand.
6. Ask Claude to `finalize` — drafts flip to Manual/Queued children; parent flips to `PLANNED` badge.
7. In a new planning task, close the terminal without finalize. Right-click the Planning task → the unfinished-session modal opens with Resume / Finalize now / Discard.
8. Attempt to delete a parent with children via the details panel — confirm the friendly error dialog appears and the task is NOT deleted.
**Known followups (non-blocking):**
- `Border.badge.planned` style (blue) is defined in `IslandStyles.axaml` but never applied — `TaskRowView` keeps the `planning` class for both Planning and Planned, so Planned gets the amber badge. Either make the view swap `Classes.planned` when status is Planned, or remove the unused style + brush.
- Dead `Instance` statics on `BoolToItalicConverter` and `BoolToDraftOpacityConverter` — App.axaml registers instances via the resource dictionary; the static members can be removed.

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 - `list_id` TEXT NOT NULL REFERENCES `lists(id)` ON DELETE CASCADE
- `title` TEXT NOT NULL - `title` TEXT NOT NULL
- `description` TEXT 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" - `scheduled_for` TIMESTAMP NULL — "nicht vor"
- `result` TEXT NULL (Markdown) - `result` TEXT NULL (Markdown)
- `log_path` TEXT NULL — Pfad zur ndjson-Log-Datei - `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. 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:** **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.
- 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.
**Install:** **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.
- 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.
**Auth-Konflikt mit "User-CLI-Session" beachten:** **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).
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:
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". **Logging:** Serilog-File-Sink nach `~/.todo-app/logs/worker-*.log`. Single-Instance-Mutex verhindert parallele Instanzen.
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".
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) ## Project-Layout (Monorepo)
@@ -317,4 +304,4 @@ Vorteil Monorepo: gemeinsames `schema.sql`, atomische Änderungen über UI+Worke
- Bulk-Discard alter Worktrees. - Bulk-Discard alter Worktrees.
- Anzeige der ndjson-Message-Chronik im UI. - Anzeige der ndjson-Message-Chronik im UI.
- Windows Job Objects für garantierten Child-Cleanup beim Worker-Crash. - 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.

View File

@@ -1,3 +1,6 @@
# ClaudeDo — Prompt & CLI Inventory # 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. 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.

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,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,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,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

@@ -9,6 +9,7 @@
<ResourceDictionary> <ResourceDictionary>
<ResourceDictionary.MergedDictionaries> <ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://ClaudeDo.Ui/Design/Tokens.axaml" /> <ResourceInclude Source="avares://ClaudeDo.Ui/Design/Tokens.axaml" />
<ResourceInclude Source="avares://ClaudeDo.Ui/Views/Controls/ModalShell.axaml" />
</ResourceDictionary.MergedDictionaries> </ResourceDictionary.MergedDictionaries>
<!-- Converters --> <!-- Converters -->
@@ -31,6 +32,13 @@
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" /> <StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
<!-- Global defaults: every Window inherits Inter Tight + body size.
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>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter"> <Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentGlowBrush}"/> <Setter Property="Background" Value="{DynamicResource AccentGlowBrush}"/>
</Style> </Style>

View File

@@ -19,8 +19,8 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
## DI Registration Pattern ## DI Registration Pattern
- **Singletons**: SqliteConnectionFactory, all Repositories, WorkerClient, MainWindowViewModel, TaskListViewModel, TaskDetailViewModel, StatusBarViewModel - **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
- **Transients**: TaskEditorViewModel, ListEditorViewModel (created per dialog) - **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation
## Notes ## Notes

View File

@@ -7,6 +7,7 @@ using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System; using System;
@@ -82,6 +83,7 @@ sealed class Program
sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) }); sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) });
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>())); sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
sc.AddSingleton<InstallerLocator>(); sc.AddSingleton<InstallerLocator>();
sc.AddSingleton<WorkerLocator>();
sc.AddSingleton(sp => sc.AddSingleton(sp =>
{ {
var releases = sp.GetRequiredService<IReleaseClient>(); var releases = sp.GetRequiredService<IReleaseClient>();
@@ -94,9 +96,17 @@ sealed class Program
// ViewModels // ViewModels
sc.AddTransient<WorktreeModalViewModel>(); sc.AddTransient<WorktreeModalViewModel>();
sc.AddTransient<Func<WorktreeModalViewModel>>(sp => () => sp.GetRequiredService<WorktreeModalViewModel>());
sc.AddTransient<WorktreesOverviewModalViewModel>();
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
sc.AddTransient<PrimeClaudeTabViewModel>();
sc.AddTransient<SettingsModalViewModel>(); sc.AddTransient<SettingsModalViewModel>();
sc.AddTransient<MergeModalViewModel>(); sc.AddTransient<MergeModalViewModel>();
sc.AddTransient<Func<MergeModalViewModel>>(sp => () => sp.GetRequiredService<MergeModalViewModel>());
sc.AddTransient<ListSettingsModalViewModel>(); sc.AddTransient<ListSettingsModalViewModel>();
sc.AddTransient<RepoImportModalViewModel>();
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
// Islands shell VMs // Islands shell VMs
sc.AddSingleton<ListsIslandViewModel>(sp => sc.AddSingleton<ListsIslandViewModel>(sp =>

View File

@@ -4,21 +4,19 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
## Models ## Models
- **TaskEntity** — Id, ListId, Title, Description, Status (Manual|Queued|Running|Done|Failed), ScheduledFor, Result, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath (nullable overrides), IsStarred, IsMyDay, Notes - **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt - **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath (all nullable) - **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath (all nullable)
- **TagEntity** — Id (autoincrement), Name (unique)
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept) - **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path) - **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path)
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files - **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
## Repositories ## Repositories
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim. All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Queued -> Running` claim lives in the Worker's `QueuePicker` (uses `FromSqlRaw`), not here.
- **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync`, `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides) - **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `TryCompleteParentAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
- **ListRepository** — CRUD, tag junction management, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config` - **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
- **TagRepository** — `GetOrCreateAsync` (idempotent)
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync` - **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository** - **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
@@ -35,7 +33,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `T
## Schema ## Schema
Tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`. Managed by EF Core migrations in the `Migrations/` folder. Seed data: tags "agent" and "manual". Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`).
## Conventions ## Conventions

View File

@@ -14,4 +14,9 @@
</PackageReference> </PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ClaudeDo.Worker" />
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
</ItemGroup>
</Project> </Project>

View File

@@ -3,6 +3,7 @@ using ClaudeDo.Data.Seeding;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace ClaudeDo.Data; namespace ClaudeDo.Data;
@@ -12,16 +13,31 @@ public class ClaudeDoDbContext : DbContext
public DbSet<TaskEntity> Tasks => Set<TaskEntity>(); public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>(); public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<TagEntity> Tags => Set<TagEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>(); public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>(); public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>(); public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>(); public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>(); public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();
private static readonly ValueConverter<DateTime, DateTime> UtcConverter =
new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
private static readonly ValueConverter<DateTime?, DateTime?> UtcNullableConverter =
new(v => v, v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null);
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ClaudeDoDbContext).Assembly); modelBuilder.ApplyConfigurationsFromAssembly(typeof(ClaudeDoDbContext).Assembly);
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
foreach (var property in entityType.GetProperties())
{
if (property.ClrType == typeof(DateTime) && property.GetValueConverter() == null)
property.SetValueConverter(UtcConverter);
else if (property.ClrType == typeof(DateTime?) && property.GetValueConverter() == null)
property.SetValueConverter(UtcNullableConverter);
}
} }
/// <summary> /// <summary>

View File

@@ -22,6 +22,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.DefaultPermissionMode) builder.Property(s => s.DefaultPermissionMode)
.HasColumnName("default_permission_mode").IsRequired().HasDefaultValue("bypassPermissions"); .HasColumnName("default_permission_mode").IsRequired().HasDefaultValue("bypassPermissions");
builder.Property(s => s.MaxParallelExecutions)
.HasColumnName("max_parallel_executions").IsRequired().HasDefaultValue(1);
builder.Property(s => s.WorktreeStrategy) builder.Property(s => s.WorktreeStrategy)
.HasColumnName("worktree_strategy").IsRequired().HasDefaultValue("sibling"); .HasColumnName("worktree_strategy").IsRequired().HasDefaultValue("sibling");
builder.Property(s => s.CentralWorktreeRoot) builder.Property(s => s.CentralWorktreeRoot)
@@ -31,6 +34,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.WorktreeAutoCleanupDays) builder.Property(s => s.WorktreeAutoCleanupDays)
.HasColumnName("worktree_auto_cleanup_days").IsRequired().HasDefaultValue(7); .HasColumnName("worktree_auto_cleanup_days").IsRequired().HasDefaultValue(7);
builder.Property(s => s.RepoImportFolders)
.HasColumnName("repo_import_folders");
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }); builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
} }
} }

View File

@@ -16,21 +16,13 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
builder.Property(l => l.CreatedAt).HasColumnName("created_at").IsRequired(); builder.Property(l => l.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(l => l.WorkingDir).HasColumnName("working_dir"); builder.Property(l => l.WorkingDir).HasColumnName("working_dir");
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore"); builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
builder.HasOne(l => l.Config) builder.HasOne(l => l.Config)
.WithOne(c => c.List) .WithOne(c => c.List)
.HasForeignKey<ListConfigEntity>(c => c.ListId) .HasForeignKey<ListConfigEntity>(c => c.ListId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.HasMany(l => l.Tags)
.WithMany(tag => tag.Lists)
.UsingEntity("list_tags",
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
r => r.HasOne(typeof(ListEntity)).WithMany().HasForeignKey("list_id").OnDelete(DeleteBehavior.Cascade),
j =>
{
j.HasKey("list_id", "tag_id");
j.ToTable("list_tags");
});
} }
} }

View File

@@ -0,0 +1,25 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class PrimeScheduleEntityConfiguration : IEntityTypeConfiguration<PrimeScheduleEntity>
{
public void Configure(EntityTypeBuilder<PrimeScheduleEntity> builder)
{
builder.ToTable("prime_schedules");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever();
builder.Property(s => s.StartDate).HasColumnName("start_date").IsRequired();
builder.Property(s => s.EndDate).HasColumnName("end_date").IsRequired();
builder.Property(s => s.TimeOfDay).HasColumnName("time_of_day").IsRequired();
builder.Property(s => s.WorkdaysOnly).HasColumnName("workdays_only").IsRequired().HasDefaultValue(true);
builder.Property(s => s.Enabled).HasColumnName("enabled").IsRequired().HasDefaultValue(true);
builder.Property(s => s.LastRunAt).HasColumnName("last_run_at");
builder.Property(s => s.PromptOverride).HasColumnName("prompt_override");
builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired();
}
}

View File

@@ -1,22 +0,0 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class TagEntityConfiguration : IEntityTypeConfiguration<TagEntity>
{
public void Configure(EntityTypeBuilder<TagEntity> builder)
{
builder.ToTable("tags");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).HasColumnName("id").ValueGeneratedOnAdd();
builder.Property(t => t.Name).HasColumnName("name").IsRequired();
builder.HasIndex(t => t.Name).IsUnique();
builder.HasData(
new TagEntity { Id = 1, Name = "agent" },
new TagEntity { Id = 2, Name = "manual" });
}
}

View File

@@ -9,32 +9,53 @@ namespace ClaudeDo.Data.Configuration;
public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity> public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
{ {
private static string StatusToString(TaskStatus v) private static string StatusToString(TaskStatus v)
=> v == TaskStatus.Manual ? "manual" => v switch
: v == TaskStatus.Queued ? "queued" {
: v == TaskStatus.Running ? "running" TaskStatus.Idle => "idle",
: v == TaskStatus.Done ? "done" TaskStatus.Queued => "queued",
: v == TaskStatus.Failed ? "failed" TaskStatus.Running => "running",
: v == TaskStatus.Planning ? "planning" TaskStatus.Done => "done",
: v == TaskStatus.Planned ? "planned" TaskStatus.Failed => "failed",
: v == TaskStatus.Draft ? "draft" TaskStatus.Cancelled => "cancelled",
: v == TaskStatus.Waiting ? "waiting" _ => throw new ArgumentOutOfRangeException(nameof(v)),
: throw new ArgumentOutOfRangeException(nameof(v)); };
private static TaskStatus StatusFromString(string v) private static TaskStatus StatusFromString(string v)
=> v == "manual" ? TaskStatus.Manual => v switch
: v == "queued" ? TaskStatus.Queued {
: v == "running" ? TaskStatus.Running "idle" => TaskStatus.Idle,
: v == "done" ? TaskStatus.Done "queued" => TaskStatus.Queued,
: v == "failed" ? TaskStatus.Failed "running" => TaskStatus.Running,
: v == "planning" ? TaskStatus.Planning "done" => TaskStatus.Done,
: v == "planned" ? TaskStatus.Planned "failed" => TaskStatus.Failed,
: v == "draft" ? TaskStatus.Draft "cancelled" => TaskStatus.Cancelled,
: v == "waiting" ? TaskStatus.Waiting _ => throw new ArgumentOutOfRangeException(nameof(v)),
: throw new ArgumentOutOfRangeException(nameof(v)); };
private static readonly ValueConverter<TaskStatus, string> StatusConverter = private static readonly ValueConverter<TaskStatus, string> StatusConverter =
new(v => StatusToString(v), v => StatusFromString(v)); new(v => StatusToString(v), v => StatusFromString(v));
private static string PhaseToString(PlanningPhase v)
=> v switch
{
PlanningPhase.None => "none",
PlanningPhase.Active => "active",
PlanningPhase.Finalized => "finalized",
_ => throw new ArgumentOutOfRangeException(nameof(v)),
};
private static PlanningPhase PhaseFromString(string v)
=> v switch
{
"none" => PlanningPhase.None,
"active" => PlanningPhase.Active,
"finalized" => PlanningPhase.Finalized,
_ => throw new ArgumentOutOfRangeException(nameof(v)),
};
private static readonly ValueConverter<PlanningPhase, string> PhaseConverter =
new(v => PhaseToString(v), v => PhaseFromString(v));
public void Configure(EntityTypeBuilder<TaskEntity> builder) public void Configure(EntityTypeBuilder<TaskEntity> builder)
{ {
builder.ToTable("tasks"); builder.ToTable("tasks");
@@ -46,6 +67,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.Description).HasColumnName("description"); builder.Property(t => t.Description).HasColumnName("description");
builder.Property(t => t.Status).HasColumnName("status").IsRequired() builder.Property(t => t.Status).HasColumnName("status").IsRequired()
.HasConversion(StatusConverter); .HasConversion(StatusConverter);
builder.Property(t => t.PlanningPhase).HasColumnName("planning_phase").IsRequired()
.HasConversion(PhaseConverter).HasDefaultValue(PlanningPhase.None);
builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id");
builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for"); builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for");
builder.Property(t => t.Result).HasColumnName("result"); builder.Property(t => t.Result).HasColumnName("result");
builder.Property(t => t.LogPath).HasColumnName("log_path"); builder.Property(t => t.LogPath).HasColumnName("log_path");
@@ -73,6 +97,12 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
.HasForeignKey(t => t.ParentTaskId) .HasForeignKey(t => t.ParentTaskId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
// BlockedBy: predecessor in a sequential chain. SetNull on delete so child becomes pickable.
builder.HasOne<TaskEntity>()
.WithMany()
.HasForeignKey(t => t.BlockedByTaskId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(t => t.List) builder.HasOne(t => t.List)
.WithMany(l => l.Tasks) .WithMany(l => l.Tasks)
.HasForeignKey(t => t.ListId) .HasForeignKey(t => t.ListId)
@@ -82,20 +112,10 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
.WithOne(w => w.Task) .WithOne(w => w.Task)
.HasForeignKey<WorktreeEntity>(w => w.TaskId); .HasForeignKey<WorktreeEntity>(w => w.TaskId);
builder.HasMany(t => t.Tags)
.WithMany(tag => tag.Tasks)
.UsingEntity("task_tags",
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
r => r.HasOne(typeof(TaskEntity)).WithMany().HasForeignKey("task_id").OnDelete(DeleteBehavior.Cascade),
j =>
{
j.HasKey("task_id", "tag_id");
j.ToTable("task_tags");
});
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id"); builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status"); builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort"); builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id"); builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");
} }
} }

View File

@@ -0,0 +1,14 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class ReviewFilter : ITaskListFilter
{
public string Id => "virtual:review";
public bool Matches(TaskEntity t) =>
t.Status == TaskStatus.Done &&
t.Worktree is { State: WorktreeState.Active };
public bool ShouldCount(TaskEntity t) => Matches(t);
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}

View File

@@ -0,0 +1,16 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// Filter for a smart list keyed off a boolean/nullable task flag
/// (My Day, Important, Planned). Counts only non-done matches.
/// </summary>
public sealed class SmartFlagFilter(string id, Func<TaskEntity, bool> flag) : ITaskListFilter
{
public string Id => id;
public bool Matches(TaskEntity t) => flag(t);
public bool ShouldCount(TaskEntity t) => flag(t) && t.Status != TaskStatus.Done;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}

View File

@@ -0,0 +1,18 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// Virtual list filter matching tasks by a single status (Queued, Running).
/// Planning parents appear contextually when they host a matching child.
/// </summary>
public sealed class StatusFilter(string id, TaskStatus status) : ITaskListFilter
{
public string Id => id;
public bool Matches(TaskEntity t) => t.Status == status;
public bool ShouldCount(TaskEntity t) => t.Status == status;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
}

View File

@@ -0,0 +1,24 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// Filter for any user-defined list. Constructed on demand from the list id —
/// one instance per list.
/// </summary>
public sealed class UserListFilter : ITaskListFilter
{
private readonly string _listId;
public UserListFilter(string listId)
{
_listId = listId;
Id = $"user:{listId}";
}
public string Id { get; }
public bool Matches(TaskEntity t) => t.ListId == _listId;
public bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}

View File

@@ -0,0 +1,26 @@
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Filtering;
/// <summary>
/// Strategy that defines which tasks belong to a single list. One implementation
/// per list kind; consumers (counters, list loader) ask the registry for the
/// right strategy and never branch on the list id themselves.
/// </summary>
public interface ITaskListFilter
{
/// <summary>The list id this filter applies to (e.g. "virtual:queued", "user:abc").</summary>
string Id { get; }
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
bool Matches(TaskEntity t);
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
bool ShouldCount(TaskEntity t);
/// <summary>
/// True if <paramref name="t"/> is shown as a contextual row (not a primary citizen,
/// but appears to host children that match). Default: nothing extra.
/// </summary>
bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}

View File

@@ -0,0 +1,27 @@
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Filtering;
/// <summary>
/// Shared predicates that capture planning-hierarchy semantics. Any new rule
/// involving parents, children, or planning phases belongs here.
/// </summary>
public static class PlanningRules
{
public static bool IsPlanningParent(TaskEntity t) =>
t.PlanningPhase != PlanningPhase.None;
public static bool HasMatchingChild(
TaskEntity parent,
IReadOnlyList<TaskEntity> all,
Func<TaskEntity, bool> childPredicate)
{
for (var i = 0; i < all.Count; i++)
{
var c = all[i];
if (c.ParentTaskId == parent.Id && childPredicate(c))
return true;
}
return false;
}
}

View File

@@ -0,0 +1,39 @@
using ClaudeDo.Data.Filtering.Filters;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering;
/// <summary>
/// Resolves a list id (e.g. "virtual:queued", "user:abc") to the filter that
/// owns its semantics. Smart and virtual filters are singletons; user-list
/// filters are constructed on demand from the id.
/// </summary>
public sealed class TaskListFilterRegistry
{
public const string UserListPrefix = "user:";
private static readonly IReadOnlyDictionary<string, ITaskListFilter> BuiltIn =
new Dictionary<string, ITaskListFilter>(StringComparer.Ordinal)
{
["smart:my-day"] = new SmartFlagFilter("smart:my-day", t => t.IsMyDay),
["smart:important"] = new SmartFlagFilter("smart:important", t => t.IsStarred),
["smart:planned"] = new SmartFlagFilter("smart:planned", t => t.ScheduledFor != null),
["virtual:queued"] = new StatusFilter("virtual:queued", TaskStatus.Queued),
["virtual:running"] = new StatusFilter("virtual:running", TaskStatus.Running),
["virtual:review"] = new ReviewFilter(),
};
/// <summary>
/// Resolve a filter for a list id, or null if the id is unknown.
/// </summary>
public ITaskListFilter? Resolve(string listId)
{
if (BuiltIn.TryGetValue(listId, out var f)) return f;
if (listId.StartsWith(UserListPrefix, StringComparison.Ordinal))
{
var inner = listId[UserListPrefix.Length..];
return string.IsNullOrEmpty(inner) ? null : new UserListFilter(inner);
}
return null;
}
}

View File

@@ -35,6 +35,15 @@ public sealed class GitService
return stdout; return stdout;
} }
public async Task<string> GetCommittedFilesAsync(string worktreePath, string baseCommit, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
["diff", "--name-status", $"{baseCommit}..HEAD"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git diff --name-status failed (exit {exitCode}): {stderr}");
return stdout;
}
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default) public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct); var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
@@ -97,6 +106,15 @@ public sealed class GitService
return stdout.Trim(); return stdout.Trim();
} }
public async Task<string> GetFileDiffAsync(string worktreePath, string? baseCommit, string relativePath, CancellationToken ct = default)
{
string[] args = string.IsNullOrEmpty(baseCommit)
? ["diff", "--", relativePath]
: ["diff", $"{baseCommit}..HEAD", "--", relativePath];
var (_, stdout, _) = await RunGitAsync(worktreePath, args, ct);
return stdout;
}
public async Task WorktreeRemoveAsync(string repoDir, string worktreePath, bool force = false, CancellationToken ct = default) public async Task WorktreeRemoveAsync(string repoDir, string worktreePath, bool force = false, CancellationToken ct = default)
{ {
var args = new List<string> { "worktree", "remove" }; var args = new List<string> { "worktree", "remove" };

View File

@@ -0,0 +1,482 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260416064948_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("tags", (string)null);
b.HasData(
new
{
Id = 1L,
Name = "agent"
},
new
{
Id = 2L,
Name = "manual"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("list_tags", b =>
{
b.Property<string>("list_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("list_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("list_tags", (string)null);
});
modelBuilder.Entity("task_tags", b =>
{
b.Property<string>("task_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("task_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("task_tags", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("list_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
.WithMany()
.HasForeignKey("list_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("task_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("task_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,498 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260420075929_AddTaskFlagsAndNotes")]
partial class AddTaskFlagsAndNotes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("tags", (string)null);
b.HasData(
new
{
Id = 1L,
Name = "agent"
},
new
{
Id = 2L,
Name = "manual"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("list_tags", b =>
{
b.Property<string>("list_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("list_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("list_tags", (string)null);
});
modelBuilder.Entity("task_tags", b =>
{
b.Property<string>("task_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("task_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("task_tags", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("list_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
.WithMany()
.HasForeignKey("list_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("task_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("task_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,572 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260421113614_AddAppSettings")]
partial class AddAppSettings
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 30,
DefaultModel = "sonnet",
DefaultPermissionMode = "bypassPermissions",
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("tags", (string)null);
b.HasData(
new
{
Id = 1L,
Name = "agent"
},
new
{
Id = 2L,
Name = "manual"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("list_tags", b =>
{
b.Property<string>("list_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("list_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("list_tags", (string)null);
});
modelBuilder.Entity("task_tags", b =>
{
b.Property<string>("task_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("task_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("task_tags", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("list_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
.WithMany()
.HasForeignKey("list_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("task_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("task_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,581 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260422120000_AddTaskSortOrder")]
partial class AddTaskSortOrder
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 30,
DefaultModel = "sonnet",
DefaultPermissionMode = "bypassPermissions",
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("tags", (string)null);
b.HasData(
new
{
Id = 1L,
Name = "agent"
},
new
{
Id = 2L,
Name = "manual"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("list_tags", b =>
{
b.Property<string>("list_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("list_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("list_tags", (string)null);
});
modelBuilder.Entity("task_tags", b =>
{
b.Property<string>("task_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("task_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("task_tags", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("list_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
.WithMany()
.HasForeignKey("list_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("task_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("task_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,609 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260423154708_AddPlanningSupport")]
partial class AddPlanningSupport
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 30,
DefaultModel = "sonnet",
DefaultPermissionMode = "bypassPermissions",
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("tags", (string)null);
b.HasData(
new
{
Id = 1L,
Name = "agent"
},
new
{
Id = 2L,
Name = "manual"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("list_tags", b =>
{
b.Property<string>("list_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("list_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("list_tags", (string)null);
});
modelBuilder.Entity("task_tags", b =>
{
b.Property<string>("task_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("task_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("task_tags", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("list_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
.WithMany()
.HasForeignKey("list_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("task_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("task_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,613 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260424212250_AddTaskCreatedBy")]
partial class AddTaskCreatedBy
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "bypassPermissions",
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("tags", (string)null);
b.HasData(
new
{
Id = 1L,
Name = "agent"
},
new
{
Id = 2L,
Name = "manual"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("list_tags", b =>
{
b.Property<string>("list_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("list_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("list_tags", (string)null);
});
modelBuilder.Entity("task_tags", b =>
{
b.Property<string>("task_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("task_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("task_tags", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("list_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
.WithMany()
.HasForeignKey("list_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("task_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("task_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,632 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260427082248_AddPlanningPhaseAndBlockedBy")]
partial class AddPlanningPhaseAndBlockedBy
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("tags", (string)null);
b.HasData(
new
{
Id = 1L,
Name = "agent"
},
new
{
Id = 2L,
Name = "manual"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("list_tags", b =>
{
b.Property<string>("list_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("list_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("list_tags", (string)null);
});
modelBuilder.Entity("task_tags", b =>
{
b.Property<string>("task_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("task_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("task_tags", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("list_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
.WithMany()
.HasForeignKey("list_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("task_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("task_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddPlanningPhaseAndBlockedBy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "blocked_by_task_id",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "planning_phase",
table: "tasks",
type: "TEXT",
nullable: false,
defaultValue: "none");
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
column: "default_permission_mode",
value: "auto");
migrationBuilder.CreateIndex(
name: "idx_tasks_blocked_by",
table: "tasks",
column: "blocked_by_task_id");
migrationBuilder.AddForeignKey(
name: "FK_tasks_tasks_blocked_by_task_id",
table: "tasks",
column: "blocked_by_task_id",
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_tasks_tasks_blocked_by_task_id",
table: "tasks");
migrationBuilder.DropIndex(
name: "idx_tasks_blocked_by",
table: "tasks");
migrationBuilder.DropColumn(
name: "blocked_by_task_id",
table: "tasks");
migrationBuilder.DropColumn(
name: "planning_phase",
table: "tasks");
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
column: "default_permission_mode",
value: "bypassPermissions");
}
}
}

View File

@@ -0,0 +1,632 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260427130058_RetireLegacyTaskStatus")]
partial class RetireLegacyTaskStatus
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("tags", (string)null);
b.HasData(
new
{
Id = 1L,
Name = "agent"
},
new
{
Id = 2L,
Name = "manual"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("list_tags", b =>
{
b.Property<string>("list_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("list_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("list_tags", (string)null);
});
modelBuilder.Entity("task_tags", b =>
{
b.Property<string>("task_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("task_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("task_tags", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("list_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
.WithMany()
.HasForeignKey("list_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("task_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("task_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class RetireLegacyTaskStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// manual / draft -> idle
migrationBuilder.Sql("UPDATE tasks SET status = 'idle' WHERE status IN ('manual', 'draft');");
// planning -> idle + planning_phase=active
migrationBuilder.Sql("UPDATE tasks SET status = 'idle', planning_phase = 'active' WHERE status = 'planning';");
// planned -> idle + planning_phase=finalized
migrationBuilder.Sql("UPDATE tasks SET status = 'idle', planning_phase = 'finalized' WHERE status = 'planned';");
// waiting -> queued + blocked_by_task_id derived from sort_order chain.
// SQLite 3.25+ supports window functions (LAG).
migrationBuilder.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);");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Best-effort and lossy: cancelled is folded back into failed,
// (idle, finalized) -> planned, (idle, active) -> planning,
// queued + blocked_by_task_id != null -> waiting.
// Manual/Draft distinction is unrecoverable — anything previously
// 'manual' or 'draft' stays 'idle' on the way back.
migrationBuilder.Sql("UPDATE tasks SET status = 'failed' WHERE status = 'cancelled';");
migrationBuilder.Sql("UPDATE tasks SET status = 'planned' WHERE status = 'idle' AND planning_phase = 'finalized';");
migrationBuilder.Sql("UPDATE tasks SET status = 'planning' WHERE status = 'idle' AND planning_phase = 'active';");
migrationBuilder.Sql("UPDATE tasks SET status = 'waiting', blocked_by_task_id = NULL WHERE status = 'queued' AND blocked_by_task_id IS NOT NULL;");
}
}
}

View File

@@ -0,0 +1,679 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260428064951_AddPrimeSchedules")]
partial class AddPrimeSchedules
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.Property<bool>("WorkdaysOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("workdays_only");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("tags", (string)null);
b.HasData(
new
{
Id = 1L,
Name = "agent"
},
new
{
Id = 2L,
Name = "manual"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("list_tags", b =>
{
b.Property<string>("list_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("list_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("list_tags", (string)null);
});
modelBuilder.Entity("task_tags", b =>
{
b.Property<string>("task_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("task_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("task_tags", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("list_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
.WithMany()
.HasForeignKey("list_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("task_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("task_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,41 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddPrimeSchedules : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "prime_schedules",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
start_date = table.Column<DateOnly>(type: "TEXT", nullable: false),
end_date = table.Column<DateOnly>(type: "TEXT", nullable: false),
time_of_day = table.Column<TimeSpan>(type: "TEXT", nullable: false),
workdays_only = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true),
enabled = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true),
last_run_at = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
prompt_override = table.Column<string>(type: "TEXT", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_prime_schedules", x => x.id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "prime_schedules");
}
}
}

View File

@@ -0,0 +1,587 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260519044715_RemoveTags")]
partial class RemoveTags
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.Property<bool>("WorkdaysOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("workdays_only");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,115 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class RemoveTags : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "list_tags");
migrationBuilder.DropTable(
name: "task_tags");
migrationBuilder.DropTable(
name: "tags");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "tags",
columns: table => new
{
id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
name = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_tags", x => x.id);
});
migrationBuilder.CreateTable(
name: "list_tags",
columns: table => new
{
list_id = table.Column<string>(type: "TEXT", nullable: false),
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_list_tags", x => new { x.list_id, x.tag_id });
table.ForeignKey(
name: "FK_list_tags_lists_list_id",
column: x => x.list_id,
principalTable: "lists",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_list_tags_tags_tag_id",
column: x => x.tag_id,
principalTable: "tags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "task_tags",
columns: table => new
{
task_id = table.Column<string>(type: "TEXT", nullable: false),
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_task_tags", x => new { x.task_id, x.tag_id });
table.ForeignKey(
name: "FK_task_tags_tags_tag_id",
column: x => x.tag_id,
principalTable: "tags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_task_tags_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "tags",
columns: new[] { "id", "name" },
values: new object[,]
{
{ 1L, "agent" },
{ 2L, "manual" }
});
migrationBuilder.CreateIndex(
name: "IX_list_tags_tag_id",
table: "list_tags",
column: "tag_id");
migrationBuilder.CreateIndex(
name: "IX_tags_name",
table: "tags",
column: "name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_task_tags_tag_id",
table: "task_tags",
column: "tag_id");
}
}
}

View File

@@ -0,0 +1,591 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260529142614_AddRepoImportFolders")]
partial class AddRepoImportFolders
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.Property<bool>("WorkdaysOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("workdays_only");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddRepoImportFolders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "repo_import_folders",
table: "app_settings",
type: "TEXT",
nullable: true);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
column: "repo_import_folders",
value: null);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "repo_import_folders",
table: "app_settings");
}
}
}

View File

@@ -0,0 +1,600 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260601114247_AddListSortOrder")]
partial class AddListSortOrder
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.Property<bool>("WorkdaysOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("workdays_only");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddListSortOrder : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "sort_order",
table: "lists",
type: "INTEGER",
nullable: false,
defaultValue: 0);
// Backfill existing rows with a dense order (0..N-1) by creation time
// so today's sidebar order is preserved after the migration.
migrationBuilder.Sql("""
WITH ordered AS (
SELECT id, (row_number() OVER (ORDER BY created_at) - 1) AS rn
FROM lists
)
UPDATE lists SET sort_order = (SELECT rn FROM ordered WHERE ordered.id = lists.id);
""");
migrationBuilder.CreateIndex(
name: "idx_lists_sort",
table: "lists",
column: "sort_order");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "idx_lists_sort",
table: "lists");
migrationBuilder.DropColumn(
name: "sort_order",
table: "lists");
}
}
}

View File

@@ -0,0 +1,607 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260601133737_AddMaxParallelExecutions")]
partial class AddMaxParallelExecutions
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.Property<bool>("WorkdaysOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("workdays_only");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddMaxParallelExecutions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "max_parallel_executions",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 1);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
column: "max_parallel_executions",
value: 1);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "max_parallel_executions",
table: "app_settings");
}
}
}

View File

@@ -0,0 +1,607 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260601140000_NormalizeListIdFormat")]
partial class NormalizeListIdFormat
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.Property<bool>("WorkdaysOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("workdays_only");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class NormalizeListIdFormat : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// SQLite: PRAGMA foreign_keys must run outside a transaction.
migrationBuilder.Sql("PRAGMA foreign_keys = OFF;", suppressTransaction: true);
// Normalize tasks.list_id: 32-char compact hex → 36-char dashed UUID
migrationBuilder.Sql("""
UPDATE tasks
SET list_id = substr(list_id,1,8)||'-'||substr(list_id,9,4)||'-'||substr(list_id,13,4)||'-'||substr(list_id,17,4)||'-'||substr(list_id,21,12)
WHERE length(list_id) = 32;
""");
// Normalize list_config.list_id (also the PK of that table)
migrationBuilder.Sql("""
UPDATE list_config
SET list_id = substr(list_id,1,8)||'-'||substr(list_id,9,4)||'-'||substr(list_id,13,4)||'-'||substr(list_id,17,4)||'-'||substr(list_id,21,12)
WHERE length(list_id) = 32;
""");
// Normalize lists.id (PK — must come last)
migrationBuilder.Sql("""
UPDATE lists
SET id = substr(id,1,8)||'-'||substr(id,9,4)||'-'||substr(id,13,4)||'-'||substr(id,17,4)||'-'||substr(id,21,12)
WHERE length(id) = 32;
""");
migrationBuilder.Sql("PRAGMA foreign_keys = ON;", suppressTransaction: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("PRAGMA foreign_keys = OFF;", suppressTransaction: true);
migrationBuilder.Sql("UPDATE tasks SET list_id = replace(list_id,'-','') WHERE length(list_id) = 36;");
migrationBuilder.Sql("UPDATE list_config SET list_id = replace(list_id,'-','') WHERE length(list_id) = 36;");
migrationBuilder.Sql("UPDATE lists SET id = replace(id,'-','') WHERE length(id) = 36;");
migrationBuilder.Sql("PRAGMA foreign_keys = ON;", suppressTransaction: true);
}
}
}

View File

@@ -54,6 +54,16 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue("bypassPermissions") .HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode"); .HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<int>("WorktreeAutoCleanupDays") b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@@ -84,7 +94,8 @@ namespace ClaudeDo.Data.Migrations
DefaultClaudeInstructions = "", DefaultClaudeInstructions = "",
DefaultMaxTurns = 100, DefaultMaxTurns = 100,
DefaultModel = "sonnet", DefaultModel = "sonnet",
DefaultPermissionMode = "bypassPermissions", DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
WorktreeAutoCleanupDays = 7, WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false, WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling" WorktreeStrategy = "sibling"
@@ -136,15 +147,71 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("name"); .HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir") b.Property<string>("WorkingDir")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("working_dir"); .HasColumnName("working_dir");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null); b.ToTable("lists", (string)null);
}); });
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.Property<bool>("WorkdaysOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("workdays_only");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -183,38 +250,6 @@ namespace ClaudeDo.Data.Migrations
b.ToTable("subtasks", (string)null); b.ToTable("subtasks", (string)null);
}); });
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("tags", (string)null);
b.HasData(
new
{
Id = 1L,
Name = "agent"
},
new
{
Id = 2L,
Name = "manual"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -225,6 +260,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("agent_path"); .HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType") b.Property<string>("CommitType")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -285,6 +324,13 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("planning_finalized_at"); .HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId") b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("planning_session_id"); .HasColumnName("planning_session_id");
@@ -327,6 +373,9 @@ namespace ClaudeDo.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId") b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id"); .HasDatabaseName("idx_tasks_list_id");
@@ -465,36 +514,6 @@ namespace ClaudeDo.Data.Migrations
b.ToTable("worktrees", (string)null); b.ToTable("worktrees", (string)null);
}); });
modelBuilder.Entity("list_tags", b =>
{
b.Property<string>("list_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("list_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("list_tags", (string)null);
});
modelBuilder.Entity("task_tags", b =>
{
b.Property<string>("task_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("task_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("task_tags", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{ {
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
@@ -519,6 +538,11 @@ namespace ClaudeDo.Data.Migrations
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{ {
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks") .WithMany("Tasks")
.HasForeignKey("ListId") .HasForeignKey("ListId")
@@ -557,36 +581,6 @@ namespace ClaudeDo.Data.Migrations
b.Navigation("Task"); b.Navigation("Task");
}); });
modelBuilder.Entity("list_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
.WithMany()
.HasForeignKey("list_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("task_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("task_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{ {
b.Navigation("Config"); b.Navigation("Config");

View File

@@ -11,8 +11,13 @@ public sealed class AppSettingsEntity
public int DefaultMaxTurns { get; set; } = 100; public int DefaultMaxTurns { get; set; } = 100;
public string DefaultPermissionMode { get; set; } = "auto"; public string DefaultPermissionMode { get; set; } = "auto";
public int MaxParallelExecutions { get; set; } = 1;
public string WorktreeStrategy { get; set; } = "sibling"; public string WorktreeStrategy { get; set; } = "sibling";
public string? CentralWorktreeRoot { get; set; } public string? CentralWorktreeRoot { get; set; }
public bool WorktreeAutoCleanupEnabled { get; set; } public bool WorktreeAutoCleanupEnabled { get; set; }
public int WorktreeAutoCleanupDays { get; set; } = 7; public int WorktreeAutoCleanupDays { get; set; } = 7;
// JSON array of parent folders remembered by the repo-import modal.
public string? RepoImportFolders { get; set; }
} }

View File

@@ -0,0 +1,11 @@
namespace ClaudeDo.Data.Models;
public static class CommitTypeRegistry
{
public static readonly IReadOnlyList<string> Types = new[]
{
"chore", "feat", "fix", "refactor", "docs", "test", "ci", "perf", "style", "build",
};
public const string DefaultType = "chore";
}

View File

@@ -6,10 +6,10 @@ public sealed class ListEntity
public required string Name { get; set; } public required string Name { get; set; }
public required DateTime CreatedAt { get; init; } public required DateTime CreatedAt { get; init; }
public string? WorkingDir { get; set; } public string? WorkingDir { get; set; }
public string DefaultCommitType { get; set; } = "chore"; public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType;
public int SortOrder { get; set; }
// Navigation properties // Navigation properties
public ListConfigEntity? Config { get; set; } public ListConfigEntity? Config { get; set; }
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>(); public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
} }

View File

@@ -0,0 +1,12 @@
namespace ClaudeDo.Data.Models;
public static class ModelRegistry
{
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku" };
public const string DefaultAlias = "sonnet";
public const string PlanningAlias = "opus";
public const string ListDefaultSentinel = "(default)";
public const string TaskInheritSentinel = "(inherit)";
}

View File

@@ -0,0 +1,11 @@
namespace ClaudeDo.Data.Models;
public static class PermissionModeRegistry
{
public static readonly IReadOnlyList<string> Modes = new[]
{
"auto", "bypassPermissions", "acceptEdits", "plan", "default",
};
public const string DefaultMode = "auto";
}

View File

@@ -0,0 +1,14 @@
namespace ClaudeDo.Data.Models;
public sealed class PrimeScheduleEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public TimeSpan TimeOfDay { get; set; }
public bool WorkdaysOnly { get; set; } = true;
public bool Enabled { get; set; } = true;
public DateTimeOffset? LastRunAt { get; set; }
public string? PromptOverride { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@@ -1,11 +0,0 @@
namespace ClaudeDo.Data.Models;
public sealed class TagEntity
{
public long Id { get; init; }
public required string Name { get; set; }
// Navigation properties
public ICollection<ListEntity> Lists { get; set; } = new List<ListEntity>();
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
}

View File

@@ -2,15 +2,19 @@ namespace ClaudeDo.Data.Models;
public enum TaskStatus public enum TaskStatus
{ {
Manual, Idle,
Queued, Queued,
Running, Running,
Done, Done,
Failed, Failed,
Planning, Cancelled,
Planned, }
Draft,
Waiting, public enum PlanningPhase
{
None,
Active,
Finalized,
} }
public sealed class TaskEntity public sealed class TaskEntity
@@ -19,14 +23,16 @@ public sealed class TaskEntity
public required string ListId { get; init; } public required string ListId { get; init; }
public required string Title { get; set; } public required string Title { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
public TaskStatus Status { get; set; } = TaskStatus.Manual; public TaskStatus Status { get; set; } = TaskStatus.Idle;
public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None;
public string? BlockedByTaskId { get; set; }
public DateTime? ScheduledFor { get; set; } public DateTime? ScheduledFor { get; set; }
public string? Result { get; set; } public string? Result { get; set; }
public string? LogPath { get; set; } public string? LogPath { get; set; }
public required DateTime CreatedAt { get; init; } public required DateTime CreatedAt { get; init; }
public DateTime? StartedAt { get; set; } public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; } public DateTime? FinishedAt { get; set; }
public string CommitType { get; set; } = "chore"; public string CommitType { get; set; } = CommitTypeRegistry.DefaultType;
public string? Model { get; set; } public string? Model { get; set; }
public string? SystemPrompt { get; set; } public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; } public string? AgentPath { get; set; }
@@ -45,7 +51,6 @@ public sealed class TaskEntity
// Navigation properties // Navigation properties
public ListEntity List { get; set; } = null!; public ListEntity List { get; set; } = null!;
public WorktreeEntity? Worktree { get; set; } public WorktreeEntity? Worktree { get; set; }
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>(); public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>(); public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -22,7 +23,7 @@ public sealed class AppSettingsRepository
return row; return row;
} }
public async Task UpdateAsync(AppSettingsEntity updated, CancellationToken ct = default) private async Task<AppSettingsEntity> GetOrCreateTrackedRowAsync(CancellationToken ct)
{ {
var row = await _context.AppSettings var row = await _context.AppSettings
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct); .FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
@@ -31,12 +32,19 @@ public sealed class AppSettingsRepository
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }; row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
_context.AppSettings.Add(row); _context.AppSettings.Add(row);
} }
return row;
}
public async Task UpdateAsync(AppSettingsEntity updated, CancellationToken ct = default)
{
var row = await GetOrCreateTrackedRowAsync(ct);
row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty; row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty;
row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel; row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel;
row.DefaultMaxTurns = updated.DefaultMaxTurns; row.DefaultMaxTurns = updated.DefaultMaxTurns;
row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode) row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode)
? "auto" : updated.DefaultPermissionMode; ? "auto" : updated.DefaultPermissionMode;
row.MaxParallelExecutions = updated.MaxParallelExecutions < 1 ? 1 : updated.MaxParallelExecutions;
row.WorktreeStrategy = string.IsNullOrWhiteSpace(updated.WorktreeStrategy) ? "sibling" : updated.WorktreeStrategy; row.WorktreeStrategy = string.IsNullOrWhiteSpace(updated.WorktreeStrategy) ? "sibling" : updated.WorktreeStrategy;
row.CentralWorktreeRoot = string.IsNullOrWhiteSpace(updated.CentralWorktreeRoot) row.CentralWorktreeRoot = string.IsNullOrWhiteSpace(updated.CentralWorktreeRoot)
? null : updated.CentralWorktreeRoot; ? null : updated.CentralWorktreeRoot;
@@ -45,4 +53,25 @@ public sealed class AppSettingsRepository
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
public async Task<List<string>> GetRepoImportFoldersAsync(CancellationToken ct = default)
{
var json = await _context.AppSettings.AsNoTracking()
.Where(s => s.Id == AppSettingsEntity.SingletonId)
.Select(s => s.RepoImportFolders)
.FirstOrDefaultAsync(ct);
if (string.IsNullOrWhiteSpace(json)) return new List<string>();
try { return JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>(); }
catch (JsonException) { return new List<string>(); }
}
public async Task SetRepoImportFoldersAsync(IEnumerable<string> folders, CancellationToken ct = default)
{
var list = folders.ToList();
var row = await GetOrCreateTrackedRowAsync(ct);
row.RepoImportFolders = list.Count == 0 ? null : JsonSerializer.Serialize(list);
await _context.SaveChangesAsync(ct);
}
} }

View File

@@ -0,0 +1,18 @@
namespace ClaudeDo.Data.Repositories;
public enum DiscardPlanningResult
{
/// <summary>Planning state cleared, children handled.</summary>
Discarded,
/// <summary>Parent not found or not in <c>PlanningPhase.Active</c>.</summary>
NotInPlanning,
/// <summary>At least one child is <c>Queued</c> and the caller did not opt in to auto-dequeue.</summary>
BlockedByQueuedChildren,
/// <summary>At least one child is <c>Running</c>; user must cancel it before discarding.</summary>
BlockedByRunningChildren,
}
public readonly record struct DiscardPlanningOutcome(
DiscardPlanningResult Result,
int QueuedChildrenCount,
int RunningChildrenCount);

View File

@@ -33,39 +33,19 @@ public sealed class ListRepository
public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default) public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default)
{ {
return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct); return await _context.Lists.OrderBy(l => l.SortOrder).ThenBy(l => l.CreatedAt).ToListAsync(ct);
} }
public async Task<List<TagEntity>> GetTagsAsync(string listId, CancellationToken ct = default) public async Task ReorderAsync(IReadOnlyList<string> orderedListIds, CancellationToken ct = default)
{ {
return await _context.Lists var idSet = orderedListIds.ToHashSet();
.Where(l => l.Id == listId) var entities = await _context.Lists.Where(l => idSet.Contains(l.Id)).ToListAsync(ct);
.SelectMany(l => l.Tags) for (int i = 0; i < orderedListIds.Count; i++)
.ToListAsync(ct);
}
public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default)
{
var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
if (list is null) return;
var tag = await _context.Tags.FindAsync([tagId], ct);
if (tag is not null && !list.Tags.Any(t => t.Id == tagId))
{ {
list.Tags.Add(tag); var e = entities.FirstOrDefault(x => x.Id == orderedListIds[i]);
await _context.SaveChangesAsync(ct); if (e is not null) e.SortOrder = i;
}
}
public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default)
{
var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
if (list is null) return;
var tag = list.Tags.FirstOrDefault(t => t.Id == tagId);
if (tag is not null)
{
list.Tags.Remove(tag);
await _context.SaveChangesAsync(ct);
} }
await _context.SaveChangesAsync(ct);
} }
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default) public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)

View File

@@ -0,0 +1,58 @@
// src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class PrimeScheduleRepository
{
private readonly ClaudeDoDbContext _context;
public PrimeScheduleRepository(ClaudeDoDbContext context) => _context = context;
public async Task<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default)
{
var rows = await _context.PrimeSchedules.AsNoTracking()
.OrderBy(s => s.StartDate)
.ToListAsync(ct);
return rows.OrderBy(s => s.StartDate).ThenBy(s => s.TimeOfDay).ToList();
}
public async Task<PrimeScheduleEntity?> GetAsync(Guid id, CancellationToken ct = default) =>
await _context.PrimeSchedules.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct);
public async Task UpsertAsync(PrimeScheduleEntity entity, CancellationToken ct = default)
{
var existing = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == entity.Id, ct);
if (existing is null)
{
_context.PrimeSchedules.Add(entity);
}
else
{
existing.StartDate = entity.StartDate;
existing.EndDate = entity.EndDate;
existing.TimeOfDay = entity.TimeOfDay;
existing.WorkdaysOnly = entity.WorkdaysOnly;
existing.Enabled = entity.Enabled;
existing.PromptOverride = entity.PromptOverride;
}
await _context.SaveChangesAsync(ct);
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var row = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == id, ct);
if (row is null) return;
_context.PrimeSchedules.Remove(row);
await _context.SaveChangesAsync(ct);
}
public async Task UpdateLastRunAsync(Guid id, DateTimeOffset when, CancellationToken ct = default)
{
var row = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == id, ct);
if (row is null) return;
row.LastRunAt = when;
await _context.SaveChangesAsync(ct);
}
}

View File

@@ -1,28 +0,0 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class TagRepository
{
private readonly ClaudeDoDbContext _context;
public TagRepository(ClaudeDoDbContext context) => _context = context;
public async Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default)
{
return await _context.Tags.OrderBy(t => t.Id).ToListAsync(ct);
}
public async Task<long> GetOrCreateAsync(string name, CancellationToken ct = default)
{
var existing = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
if (existing is not null)
return existing.Id;
var tag = new TagEntity { Name = name };
_context.Tags.Add(tag);
await _context.SaveChangesAsync(ct);
return tag.Id;
}
}

View File

@@ -27,6 +27,9 @@ public sealed class TaskRepository
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default) public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
{ {
var tracked = _context.Tasks.Local.FirstOrDefault(t => t.Id == entity.Id);
if (tracked is not null && !ReferenceEquals(tracked, entity))
_context.Entry(tracked).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
_context.Tasks.Update(entity); _context.Tasks.Update(entity);
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
@@ -88,7 +91,7 @@ public sealed class TaskRepository
#region Status transitions #region Status transitions
public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default) internal async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
{ {
await _context.Tasks await _context.Tasks
.Where(t => t.Id == taskId) .Where(t => t.Id == taskId)
@@ -97,7 +100,7 @@ public sealed class TaskRepository
.SetProperty(t => t.StartedAt, startedAt), ct); .SetProperty(t => t.StartedAt, startedAt), ct);
} }
public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default) internal async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{ {
await _context.Tasks await _context.Tasks
.Where(t => t.Id == taskId) .Where(t => t.Id == taskId)
@@ -107,7 +110,7 @@ public sealed class TaskRepository
.SetProperty(t => t.Result, result), ct); .SetProperty(t => t.Result, result), ct);
} }
public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default) internal async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{ {
await _context.Tasks await _context.Tasks
.Where(t => t.Id == taskId) .Where(t => t.Id == taskId)
@@ -124,7 +127,7 @@ public sealed class TaskRepository
.ExecuteUpdateAsync(s => s.SetProperty(t => t.LogPath, logPath), ct); .ExecuteUpdateAsync(s => s.SetProperty(t => t.LogPath, logPath), ct);
} }
public async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default) internal async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
{ {
var resultText = "[stale] " + reason; var resultText = "[stale] " + reason;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@@ -141,7 +144,7 @@ public sealed class TaskRepository
await _context.Tasks await _context.Tasks
.Where(t => t.Id == taskId) .Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s .ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Manual) .SetProperty(t => t.Status, TaskStatus.Idle)
.SetProperty(t => t.StartedAt, (DateTime?)null) .SetProperty(t => t.StartedAt, (DateTime?)null)
.SetProperty(t => t.FinishedAt, (DateTime?)null) .SetProperty(t => t.FinishedAt, (DateTime?)null)
.SetProperty(t => t.Result, (string?)null), ct); .SetProperty(t => t.Result, (string?)null), ct);
@@ -168,53 +171,6 @@ public sealed class TaskRepository
#endregion #endregion
#region Tags
public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default)
{
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
if (task is null) return;
var tag = await _context.Tags.FindAsync([tagId], ct);
if (tag is not null && !task.Tags.Any(t => t.Id == tagId))
{
task.Tags.Add(tag);
await _context.SaveChangesAsync(ct);
}
}
public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default)
{
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
if (task is null) return;
var tag = task.Tags.FirstOrDefault(t => t.Id == tagId);
if (tag is not null)
{
task.Tags.Remove(tag);
await _context.SaveChangesAsync(ct);
}
}
public async Task<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
{
return await _context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags)
.ToListAsync(ct);
}
public async Task<List<TagEntity>> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default)
{
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);
}
#endregion
#region Planning #region Planning
public async Task<List<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct = default) public async Task<List<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct = default)
@@ -230,13 +186,18 @@ public sealed class TaskRepository
string parentId, string parentId,
string title, string title,
string? description, string? description,
IReadOnlyList<string>? tagNames,
string? commitType, string? commitType,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct); // AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which
// bypasses the change tracker; a tracked Find would return stale data.
var parent = await _context.Tasks.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
if (parent is null) if (parent is null)
throw new InvalidOperationException($"Parent task {parentId} not found."); throw new InvalidOperationException($"Parent task {parentId} not found.");
if (parent.PlanningPhase == PlanningPhase.None)
throw new InvalidOperationException(
$"Parent task {parentId} is not in a planning phase; cannot attach children.");
var maxSort = await _context.Tasks var maxSort = await _context.Tasks
.Where(t => t.ListId == parent.ListId) .Where(t => t.ListId == parent.ListId)
@@ -249,33 +210,36 @@ public sealed class TaskRepository
ListId = parent.ListId, ListId = parent.ListId,
Title = title, Title = title,
Description = description, Description = description,
Status = TaskStatus.Draft, Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType, CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType,
ParentTaskId = parentId, ParentTaskId = parentId,
SortOrder = (maxSort ?? -1) + 1, SortOrder = (maxSort ?? -1) + 1,
}; };
_context.Tasks.Add(child); _context.Tasks.Add(child);
if (tagNames is not null && tagNames.Count > 0)
{
foreach (var tagName in tagNames.Distinct(StringComparer.OrdinalIgnoreCase))
{
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == tagName, ct);
if (tag is null)
{
tag = new TagEntity { Name = tagName };
_context.Tags.Add(tag);
await _context.SaveChangesAsync(ct);
}
child.Tags.Add(tag);
}
}
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
return child; return child;
} }
public async Task UpdateChildAsync(
string taskId,
string? title,
string? description,
string? commitType,
TaskStatus? status,
CancellationToken ct = default)
{
var task = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (title is not null) task.Title = title;
if (description is not null) task.Description = description;
if (commitType is not null) task.CommitType = commitType;
if (status.HasValue) task.Status = status.Value;
await _context.SaveChangesAsync(ct);
}
public async Task UpdatePlanningTaskAsync( public async Task UpdatePlanningTaskAsync(
string taskId, string taskId,
string? title, string? title,
@@ -299,15 +263,28 @@ public sealed class TaskRepository
CancellationToken ct = default) CancellationToken ct = default)
{ {
var affected = await _context.Tasks var affected = await _context.Tasks
.Where(t => t.Id == taskId && t.Status == TaskStatus.Manual) .Where(t => t.Id == taskId
&& t.Status == TaskStatus.Idle
&& t.PlanningPhase == PlanningPhase.None)
.ExecuteUpdateAsync(s => s .ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Planning) .SetProperty(t => t.PlanningPhase, PlanningPhase.Active)
.SetProperty(t => t.PlanningSessionToken, sessionToken), ct); .SetProperty(t => t.PlanningSessionToken, sessionToken), ct);
if (affected == 0) return null; if (affected == 0) return null;
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct); return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
} }
public async Task SetPlanningSessionTokenAsync(
string taskId,
string sessionToken,
CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.PlanningSessionToken, sessionToken), ct);
}
public async Task UpdatePlanningSessionIdAsync( public async Task UpdatePlanningSessionIdAsync(
string parentId, string parentId,
string sessionId, string sessionId,
@@ -329,51 +306,9 @@ public sealed class TaskRepository
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct); .FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
} }
public async Task<int> FinalizePlanningAsync( public async Task<DiscardPlanningOutcome> DiscardPlanningAsync(
string parentId,
bool queueAgentTasks,
CancellationToken ct = default)
{
using var tx = await _context.Database.BeginTransactionAsync(ct);
var parent = await _context.Tasks
.AsNoTracking()
.Include(t => t.List).ThenInclude(l => l.Tags)
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
if (parent is null || parent.Status != TaskStatus.Planning)
throw new InvalidOperationException($"Task {parentId} is not in Planning state.");
var listHasAgentTag = parent.List.Tags.Any(t => t.Name == "agent");
var drafts = await _context.Tasks
.Include(t => t.Tags)
.Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft)
.ToListAsync(ct);
int count = 0;
foreach (var draft in drafts)
{
var childHasAgentTag = draft.Tags.Any(t => t.Name == "agent");
var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag);
draft.Status = shouldQueue ? TaskStatus.Queued : TaskStatus.Manual;
count++;
}
var finalizedAt = DateTime.UtcNow;
await _context.Tasks
.Where(t => t.Id == parentId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Planned)
.SetProperty(t => t.PlanningFinalizedAt, finalizedAt)
.SetProperty(t => t.PlanningSessionToken, (string?)null), ct);
await _context.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return count;
}
public async Task<bool> DiscardPlanningAsync(
string parentId, string parentId,
bool dequeueQueuedChildren,
CancellationToken ct = default) CancellationToken ct = default)
{ {
using var tx = await _context.Database.BeginTransactionAsync(ct); using var tx = await _context.Database.BeginTransactionAsync(ct);
@@ -381,26 +316,151 @@ public sealed class TaskRepository
var parent = await _context.Tasks var parent = await _context.Tasks
.AsNoTracking() .AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == parentId, ct); .FirstOrDefaultAsync(t => t.Id == parentId, ct);
if (parent is null || parent.Status != TaskStatus.Planning) if (parent is null || parent.PlanningPhase != PlanningPhase.Active)
{ {
await tx.RollbackAsync(ct); await tx.RollbackAsync(ct);
return false; return new DiscardPlanningOutcome(DiscardPlanningResult.NotInPlanning, 0, 0);
} }
var children = await _context.Tasks
.Where(t => t.ParentTaskId == parentId)
.Select(t => new { t.Id, t.Status })
.ToListAsync(ct);
var runningCount = children.Count(c => c.Status == TaskStatus.Running);
if (runningCount > 0)
{
await tx.RollbackAsync(ct);
return new DiscardPlanningOutcome(DiscardPlanningResult.BlockedByRunningChildren, 0, runningCount);
}
var queuedIds = children.Where(c => c.Status == TaskStatus.Queued).Select(c => c.Id).ToList();
if (queuedIds.Count > 0)
{
if (!dequeueQueuedChildren)
{
await tx.RollbackAsync(ct);
return new DiscardPlanningOutcome(DiscardPlanningResult.BlockedByQueuedChildren, queuedIds.Count, 0);
}
await _context.Tasks
.Where(t => queuedIds.Contains(t.Id))
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Idle)
.SetProperty(t => t.BlockedByTaskId, (string?)null), ct);
}
// Terminal children (Done/Failed/Cancelled) stay attached to the parent even
// though its PlanningPhase will be reset to None. The lineage is preserved as
// historical context; the UI nests them under their parent regardless of phase.
// Idle children created during this planning session are dropped.
await _context.Tasks await _context.Tasks
.Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft) .Where(t => t.ParentTaskId == parentId
&& t.Status == TaskStatus.Idle
&& t.PlanningPhase == PlanningPhase.None)
.ExecuteDeleteAsync(ct); .ExecuteDeleteAsync(ct);
await _context.Tasks await _context.Tasks
.Where(t => t.Id == parentId) .Where(t => t.Id == parentId)
.ExecuteUpdateAsync(s => s .ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Manual) .SetProperty(t => t.Status, TaskStatus.Idle)
.SetProperty(t => t.PlanningPhase, PlanningPhase.None)
.SetProperty(t => t.PlanningSessionId, (string?)null) .SetProperty(t => t.PlanningSessionId, (string?)null)
.SetProperty(t => t.PlanningSessionToken, (string?)null) .SetProperty(t => t.PlanningSessionToken, (string?)null)
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct); .SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct);
await tx.CommitAsync(ct); await tx.CommitAsync(ct);
return true; return new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, queuedIds.Count, 0);
}
/// <summary>
/// Dequeues child tasks whose parent is missing or no longer in a planning phase:
/// sets <c>Status</c> from <c>Queued</c> to <c>Idle</c> and clears
/// <c>BlockedByTaskId</c>. <c>ParentTaskId</c> stays intact — the child remains
/// part of its (former) planning chain for historical context. Returns the
/// number of rows dequeued. Idempotent.
/// </summary>
internal async Task<int> DequeueOrphanedChildrenAsync(CancellationToken ct = default)
{
var orphanIds = await _context.Tasks
.Where(t => t.ParentTaskId != null && t.Status == TaskStatus.Queued)
.Where(t => !_context.Tasks.Any(p =>
p.Id == t.ParentTaskId && p.PlanningPhase != PlanningPhase.None))
.Select(t => t.Id)
.ToListAsync(ct);
if (orphanIds.Count == 0) return 0;
return await _context.Tasks
.Where(t => orphanIds.Contains(t.Id))
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Idle)
.SetProperty(t => t.BlockedByTaskId, (string?)null), ct);
}
/// <summary>
/// Restores a planning-session lineage that lost its <c>parent_task_id</c> links.
/// Given a candidate parent task and a single unambiguous orphan chain in the
/// same list (linked via <c>BlockedByTaskId</c>), re-attaches the chain members
/// to the parent, marks the parent as <c>Finalized</c>, and dequeues queued
/// chain members. No-op if conditions are not met. Returns the number of
/// re-attached children (0 if skipped).
/// </summary>
internal async Task<int> RestorePlanningLineageAsync(string parentId, CancellationToken ct = default)
{
var parent = await _context.Tasks.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
if (parent is null) return 0;
if (parent.PlanningPhase != PlanningPhase.None) return 0;
if (parent.Status is TaskStatus.Done or TaskStatus.Failed or TaskStatus.Cancelled) return 0;
// Candidates: unattached tasks in the same list, excluding the parent itself.
var candidates = await _context.Tasks.AsNoTracking()
.Where(t => t.ListId == parent.ListId && t.ParentTaskId == null && t.Id != parent.Id)
.ToListAsync(ct);
// A chain is a maximal linear sequence linked via BlockedByTaskId. Find heads
// (BlockedByTaskId == null) that have at least one successor.
var bySource = candidates
.Where(c => c.BlockedByTaskId != null)
.ToLookup(c => c.BlockedByTaskId!);
var heads = candidates
.Where(c => c.BlockedByTaskId == null && bySource[c.Id].Any())
.ToList();
// Bail unless exactly one chain anchors a successor — anything else is
// ambiguous and we refuse to guess.
if (heads.Count != 1) return 0;
var chain = new List<TaskEntity> { heads[0] };
var current = heads[0];
while (true)
{
var next = bySource[current.Id].FirstOrDefault();
if (next is null) break;
chain.Add(next);
current = next;
}
var chainIds = chain.Select(c => c.Id).ToList();
await _context.Tasks
.Where(t => t.Id == parentId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.PlanningPhase, PlanningPhase.Finalized), ct);
await _context.Tasks
.Where(t => chainIds.Contains(t.Id))
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ParentTaskId, (string?)parentId), ct);
// Dequeue queued chain members; blocked_by stays intact so chain order is
// preserved for manual re-queueing.
await _context.Tasks
.Where(t => chainIds.Contains(t.Id) && t.Status == TaskStatus.Queued)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Idle), ct);
return chainIds.Count;
} }
public async Task TryCompleteParentAsync( public async Task TryCompleteParentAsync(
@@ -408,7 +468,7 @@ public sealed class TaskRepository
CancellationToken ct = default) CancellationToken ct = default)
{ {
var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct); var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct);
if (parent is null || parent.Status != TaskStatus.Planned) return; if (parent is null || parent.PlanningPhase != PlanningPhase.Finalized) return;
var children = await _context.Tasks var children = await _context.Tasks
.Where(t => t.ParentTaskId == parentId) .Where(t => t.ParentTaskId == parentId)
@@ -431,43 +491,4 @@ public sealed class TaskRepository
} }
#endregion #endregion
#region Queue selection
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
{
// Atomic queue claim: UPDATE + RETURNING in one statement prevents TOCTOU races.
// Uses raw SQL because EF cannot express UPDATE...RETURNING.
// Includes both task-level and list-level "agent" tag so lists tagged "agent"
// automatically enqueue all their tasks without per-task tagging.
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — use the same format for comparison.
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
var result = await _context.Tasks.FromSqlRaw("""
UPDATE tasks SET status = 'running'
WHERE id = (
SELECT t.id FROM tasks t
WHERE t.status = 'queued'
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
AND (
EXISTS (
SELECT 1 FROM task_tags tt
JOIN tags tg ON tg.id = tt.tag_id
WHERE tt.task_id = t.id AND tg.name = 'agent'
)
OR EXISTS (
SELECT 1 FROM list_tags lt
JOIN tags tg ON tg.id = lt.tag_id
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
)
)
ORDER BY t.sort_order ASC, t.created_at ASC
LIMIT 1
)
RETURNING *
""", nowStr).ToListAsync(ct);
return result.FirstOrDefault();
}
#endregion
} }

View File

@@ -15,7 +15,7 @@ public static class DefaultListsSeeder
{ {
ctx.Lists.Add(new ListEntity ctx.Lists.Add(new ListEntity
{ {
Id = Guid.NewGuid().ToString("N"), Id = Guid.NewGuid().ToString(),
Name = name, Name = name,
CreatedAt = now, CreatedAt = now,
}); });

View File

@@ -203,24 +203,28 @@ public partial class App : Application
sc.AddSingleton<IInstallerPage, InstallPageViewModel>(); sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>). // Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
// Double-registered as both IInstallStep and concrete type so Task 15's Update pipeline // Double-registered as both IInstallStep and concrete type so the Update pipeline
// can pull them out individually via GetRequiredService<T>(). // can pull them out individually via GetRequiredService<T>().
sc.AddSingleton<DownloadAndExtractStep>(); sc.AddSingleton<DownloadAndExtractStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>()); sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
sc.AddSingleton<IInstallStep, WriteConfigStep>(); sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>(); sc.AddSingleton<IInstallStep, InitDatabaseStep>();
sc.AddSingleton<IInstallStep, RegisterServiceStep>(); sc.AddSingleton<RegisterMcpStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartServiceStep>()); sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterMcpStep>());
sc.AddSingleton<RegisterAutostartStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterAutostartStep>());
sc.AddSingleton<IInstallStep, CreateShortcutsStep>(); sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>(); sc.AddSingleton<WriteUninstallRegistryStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteUninstallRegistryStep>());
sc.AddSingleton<WriteInstallManifestStep>(); sc.AddSingleton<WriteInstallManifestStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>()); sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
// Start the worker last in the fresh pipeline (binaries + task must exist first).
sc.AddSingleton<StartWorkerStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartWorkerStep>());
// Stop — NOT registered as IInstallStep (not part of default FreshInstall pipeline). // Stop — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
// Pulled by Update flow + Repair/Uninstall. // Pulled by Update flow + Repair/Uninstall.
sc.AddSingleton<StopServiceStep>(); sc.AddSingleton<StopWorkerStep>();
// StartServiceStep is also registered as IInstallStep above (fresh-install pipeline).
sc.AddSingleton<StartServiceStep>();
// Runners // Runners
sc.AddSingleton<UninstallRunner>(); sc.AddSingleton<UninstallRunner>();

View File

@@ -0,0 +1,26 @@
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);
}
}

View File

@@ -5,6 +5,23 @@ using ClaudeDo.Data;
namespace ClaudeDo.Installer.Core; namespace ClaudeDo.Installer.Core;
internal static class JsonConfigFile
{
public static T LoadOrDefault<T>(string fileName, JsonSerializerOptions readOpts) where T : new()
{
var path = Path.Combine(Paths.AppDataRoot(), fileName);
if (!File.Exists(path)) return new();
return JsonSerializer.Deserialize<T>(File.ReadAllText(path), readOpts) ?? new();
}
public static void Save<T>(string fileName, T value, JsonSerializerOptions writeOpts)
{
var dir = Paths.AppDataRoot();
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, fileName), JsonSerializer.Serialize(value, writeOpts));
}
}
/// <summary> /// <summary>
/// Mirrors ClaudeDo.Worker.Config.WorkerConfig JSON shape. /// Mirrors ClaudeDo.Worker.Config.WorkerConfig JSON shape.
/// Keep in sync with src/ClaudeDo.Worker/Config/WorkerConfig.cs. /// Keep in sync with src/ClaudeDo.Worker/Config/WorkerConfig.cs.
@@ -47,21 +64,9 @@ public sealed class InstallerWorkerConfig
}; };
public static InstallerWorkerConfig Load() public static InstallerWorkerConfig Load()
{ => JsonConfigFile.LoadOrDefault<InstallerWorkerConfig>("worker.config.json", ReadOpts);
var path = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
if (!File.Exists(path)) return new();
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallerWorkerConfig>(json, ReadOpts) ?? new();
}
public void Save() public void Save() => JsonConfigFile.Save("worker.config.json", this, WriteOpts);
{
var dir = Paths.AppDataRoot();
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "worker.config.json");
var json = JsonSerializer.Serialize(this, WriteOpts);
File.WriteAllText(path, json);
}
} }
/// <summary> /// <summary>
@@ -85,25 +90,9 @@ public sealed class InstallerAppSettings
public static InstallerAppSettings Load() public static InstallerAppSettings Load()
{ {
var path = Path.Combine(Paths.AppDataRoot(), "ui.config.json"); try { return JsonConfigFile.LoadOrDefault<InstallerAppSettings>("ui.config.json", ReadOpts); }
if (!File.Exists(path)) return new(); catch { return new(); }
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallerAppSettings>(json, ReadOpts) ?? new();
}
catch
{
return new();
}
} }
public void Save() public void Save() => JsonConfigFile.Save("ui.config.json", this, WriteOpts);
{
var dir = Paths.AppDataRoot();
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "ui.config.json");
var json = JsonSerializer.Serialize(this, WriteOpts);
File.WriteAllText(path, json);
}
} }

View File

@@ -23,7 +23,6 @@ public sealed class InstallContext
public int SignalRPort { get; set; } = 47_821; public int SignalRPort { get; set; } = 47_821;
public int QueueBackstopIntervalMs { get; set; } = 30_000; public int QueueBackstopIntervalMs { get; set; } = 30_000;
public string ClaudeBin { get; set; } = "claude"; public string ClaudeBin { get; set; } = "claude";
public string ServiceAccount { get; set; } = "CurrentUser";
public bool AutoStart { get; set; } = true; public bool AutoStart { get; set; } = true;
public int RestartDelayMs { get; set; } = 5000; public int RestartDelayMs { get; set; } = 5000;
@@ -33,4 +32,8 @@ public sealed class InstallContext
// InstallPage // InstallPage
public bool CreateDesktopShortcut { get; set; } = true; public bool CreateDesktopShortcut { get; set; } = true;
// WelcomePage — register the external MCP endpoint with the Claude CLI.
public bool RegisterMcpWithClaude { get; set; } = true;
public int ExternalMcpPort { get; set; } = 47_822;
} }

View File

@@ -0,0 +1,49 @@
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);
}
}

View File

@@ -9,9 +9,9 @@ namespace ClaudeDo.Installer.Core;
public sealed class UninstallRunner public sealed class UninstallRunner
{ {
private readonly InstallContext _context; private readonly InstallContext _context;
private readonly StopServiceStep _stopService; private readonly StopWorkerStep _stopService;
public UninstallRunner(InstallContext context, StopServiceStep stopService) public UninstallRunner(InstallContext context, StopWorkerStep stopService)
{ {
_context = context; _context = context;
_stopService = stopService; _stopService = stopService;
@@ -27,16 +27,18 @@ public sealed class UninstallRunner
// 2) Stop service. If stop fails we MUST abort — deleting a service whose // 2) Stop service. If stop fails we MUST abort — deleting a service whose
// process is still running leaves orphan locked binaries under the install dir // process is still running leaves orphan locked binaries under the install dir
// which Directory.Delete will silently skip. // which Directory.Delete will silently skip.
progress.Report("Stopping worker service..."); progress.Report("Stopping worker...");
var stopResult = await _stopService.ExecuteAsync(_context, progress, ct); var stopResult = await _stopService.ExecuteAsync(_context, progress, ct);
if (!stopResult.Success) if (!stopResult.Success)
return StepResult.Fail( return StepResult.Fail(
$"Cannot uninstall: worker service did not stop cleanly. {stopResult.ErrorMessage} " + $"Cannot uninstall: worker did not stop cleanly. {stopResult.ErrorMessage} " +
"Kill the worker manually and re-run uninstall."); "Kill the worker manually and re-run uninstall.");
// 3) Unregister service. // 3) Best-effort removal of the legacy scheduled task and Windows service
progress.Report("Unregistering service..."); // (older installs; current installs autostart via a Startup-folder shortcut).
await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct); progress.Report("Removing legacy autostart task...");
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{StopWorkerStep.LegacyTaskName}\" /F", null, progress, ct);
await ProcessRunner.RunAsync("sc.exe", "delete ClaudeDoWorker", null, progress, ct);
// 3b) Remove Apps & Features registry entry (best-effort). // 3b) Remove Apps & Features registry entry (best-effort).
progress.Report("Removing Add/Remove Programs entry..."); progress.Report("Removing Add/Remove Programs entry...");
@@ -57,6 +59,7 @@ public sealed class UninstallRunner
TryDeleteFile(Path.Combine( TryDeleteFile(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu), Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
"Programs", "ClaudeDo.lnk")); "Programs", "ClaudeDo.lnk"));
TryDeleteFile(AutostartShortcut.PathIn(AutostartShortcut.DefaultStartupDir));
// 5) Delete install directory. Track success so we can report partial state. // 5) Delete install directory. Track success so we can report partial state.
var failures = new List<string>(); var failures = new List<string>();

View File

@@ -10,6 +10,18 @@ using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Installer.Pages.InstallPage; namespace ClaudeDo.Installer.Pages.InstallPage;
public partial class StepViewModel : ObservableObject
{
public string Name { get; }
[ObservableProperty] private StepStatus _status = StepStatus.Pending;
[ObservableProperty] private bool _isExpanded;
public ObservableCollection<string> Messages { get; } = [];
public StepViewModel(string name) => Name = name;
}
public partial class InstallPageViewModel : ObservableObject, IInstallerPage public partial class InstallPageViewModel : ObservableObject, IInstallerPage
{ {
private readonly InstallContext _context; private readonly InstallContext _context;
@@ -42,20 +54,23 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
Steps.Clear(); Steps.Clear();
if (_context.Mode == InstallerMode.Update) if (_context.Mode == InstallerMode.Update)
{ {
Steps.Add(new StepViewModel("Stop Worker Service")); Steps.Add(new StepViewModel("Stop Worker"));
Steps.Add(new StepViewModel("Download and Extract")); Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Start Worker Service")); Steps.Add(new StepViewModel("Register Autostart"));
Steps.Add(new StepViewModel("Start Worker"));
Steps.Add(new StepViewModel("Write Install Manifest")); Steps.Add(new StepViewModel("Write Install Manifest"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
} }
else else
{ {
Steps.Add(new StepViewModel("Download and Extract")); Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Write Configuration")); Steps.Add(new StepViewModel("Write Configuration"));
Steps.Add(new StepViewModel("Initialize Database")); Steps.Add(new StepViewModel("Initialize Database"));
Steps.Add(new StepViewModel("Register Windows Service")); Steps.Add(new StepViewModel("Register Autostart"));
Steps.Add(new StepViewModel("Create Shortcuts")); Steps.Add(new StepViewModel("Create Shortcuts"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs")); Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
Steps.Add(new StepViewModel("Write Install Manifest")); Steps.Add(new StepViewModel("Write Install Manifest"));
Steps.Add(new StepViewModel("Start Worker"));
} }
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -69,6 +84,15 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
{ {
if (IsInstalling) return; if (IsInstalling) return;
// Reset per-step state so a re-run starts clean instead of appending
// output to the previous run's messages.
foreach (var s in Steps)
{
s.Messages.Clear();
s.Status = StepStatus.Pending;
s.IsExpanded = false;
}
IsInstalling = true; IsInstalling = true;
IsComplete = false; IsComplete = false;
HasErrors = false; HasErrors = false;
@@ -81,7 +105,11 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
var step = Steps.FirstOrDefault(s => s.Name == p.StepName); var step = Steps.FirstOrDefault(s => s.Name == p.StepName);
if (step is null) return; if (step is null) return;
step.Status = p.Status; // Status and output lines arrive on two separate Progress<T> channels, so a
// trailing "Running" line-message can be delivered after the step's terminal
// Done/Failed. Never let that downgrade a completed step back to Running.
if (!(step.Status is StepStatus.Done or StepStatus.Failed && p.Status is StepStatus.Running))
step.Status = p.Status;
if (p.Message is not null) if (p.Message is not null)
{ {
// Messages starting with "\r" overwrite the previous line (live progress). // Messages starting with "\r" overwrite the previous line (live progress).
@@ -116,10 +144,16 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
{ {
steps = new IInstallStep[] steps = new IInstallStep[]
{ {
_serviceProvider.GetRequiredService<StopServiceStep>(), _serviceProvider.GetRequiredService<StopWorkerStep>(),
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(), _serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
_serviceProvider.GetRequiredService<StartServiceStep>(), // Migrates the legacy service away and (re)registers the logon task.
_serviceProvider.GetRequiredService<RegisterAutostartStep>(),
_serviceProvider.GetRequiredService<RegisterMcpStep>(),
_serviceProvider.GetRequiredService<StartWorkerStep>(),
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(), _serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
// Refresh the bundled uninstaller exe + Add/Remove-Programs version so a
// manual update also renews the installer that bootstraps future updates.
_serviceProvider.GetRequiredService<WriteUninstallRegistryStep>(),
}; };
} }
else else

View File

@@ -1,17 +0,0 @@
using System.Collections.ObjectModel;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Installer.Pages.InstallPage;
public partial class StepViewModel : ObservableObject
{
public string Name { get; }
[ObservableProperty] private StepStatus _status = StepStatus.Pending;
[ObservableProperty] private bool _isExpanded;
public ObservableCollection<string> Messages { get; } = [];
public StepViewModel(string name) => Name = name;
}

View File

@@ -9,8 +9,8 @@
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel MaxWidth="520"> <StackPanel MaxWidth="520">
<TextBlock Text="Worker Service" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/> <TextBlock Text="Worker" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Configure the ClaudeDo Worker background service." <TextBlock Text="Configure the ClaudeDo background worker."
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
TextWrapping="Wrap"/> TextWrapping="Wrap"/>
@@ -33,18 +33,11 @@
<Separator Margin="0,4,0,12"/> <Separator Margin="0,4,0,12"/>
<Label Content="Service Account"/> <TextBlock Text="The worker runs as you (the logged-in user) via a per-user logon task, so it can use your Claude CLI authentication."
<StackPanel Margin="0,0,0,12"> Foreground="{StaticResource TextDimBrush}" FontSize="11" Margin="0,0,0,12"
<RadioButton Content="Local System (recommended)" TextWrapping="Wrap"/>
IsChecked="{Binding IsLocalSystem}" Margin="0,0,0,4"/>
<RadioButton Content="Current User"
IsChecked="{Binding IsCurrentUser}"/>
<TextBlock Text="Running as current user requires 'Log on as a service' privilege."
Foreground="{StaticResource TextDimBrush}" FontSize="11" Margin="20,2,0,0"
TextWrapping="Wrap"/>
</StackPanel>
<CheckBox Content="Start service automatically" IsChecked="{Binding AutoStart}" Margin="0,0,0,12"/> <CheckBox Content="Start worker automatically at logon" IsChecked="{Binding AutoStart}" Margin="0,0,0,12"/>
<Label Content="Restart Delay (ms)"/> <Label Content="Restart Delay (ms)"/>
<TextBox Text="{Binding RestartDelayMs, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/> <TextBox Text="{Binding RestartDelayMs, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>

View File

@@ -21,8 +21,6 @@ public partial class ServicePageViewModel : ObservableObject, IInstallerPage
[ObservableProperty] private int _signalRPort = 47_821; [ObservableProperty] private int _signalRPort = 47_821;
[ObservableProperty] private int _queueBackstopIntervalMs = 30_000; [ObservableProperty] private int _queueBackstopIntervalMs = 30_000;
[ObservableProperty] private string _claudeBin = "claude"; [ObservableProperty] private string _claudeBin = "claude";
[ObservableProperty] private bool _isLocalSystem = true;
[ObservableProperty] private bool _isCurrentUser;
[ObservableProperty] private bool _autoStart = true; [ObservableProperty] private bool _autoStart = true;
[ObservableProperty] private int _restartDelayMs = 5000; [ObservableProperty] private int _restartDelayMs = 5000;
[ObservableProperty] private string? _validationError; [ObservableProperty] private string? _validationError;
@@ -43,7 +41,6 @@ public partial class ServicePageViewModel : ObservableObject, IInstallerPage
_context.SignalRPort = SignalRPort; _context.SignalRPort = SignalRPort;
_context.QueueBackstopIntervalMs = QueueBackstopIntervalMs; _context.QueueBackstopIntervalMs = QueueBackstopIntervalMs;
_context.ClaudeBin = ClaudeBin; _context.ClaudeBin = ClaudeBin;
_context.ServiceAccount = IsCurrentUser ? "CurrentUser" : "LocalSystem";
_context.AutoStart = AutoStart; _context.AutoStart = AutoStart;
_context.RestartDelayMs = RestartDelayMs; _context.RestartDelayMs = RestartDelayMs;
_context.SignalRUrl = $"http://127.0.0.1:{SignalRPort}/hub"; _context.SignalRUrl = $"http://127.0.0.1:{SignalRPort}/hub";

View File

@@ -32,6 +32,14 @@
<TextBlock Text="{Binding InstallError}" Foreground="{StaticResource ErrorBrush}" FontSize="11" <TextBlock Text="{Binding InstallError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
Visibility="{Binding InstallError, Converter={StaticResource NullToCollapsedConverter}}"/> Visibility="{Binding InstallError, Converter={StaticResource NullToCollapsedConverter}}"/>
<CheckBox Content="Register MCP server with Claude"
IsChecked="{Binding RegisterMcp}"
Margin="0,24,0,0"/>
<TextBlock Text="Runs 'claude mcp add' so Claude can view and manage your ClaudeDo tasks. You can change this later."
TextWrapping="Wrap" FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"
Margin="0,4,0,0"/>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</UserControl> </UserControl>

View File

@@ -24,6 +24,7 @@ public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
[ObservableProperty] private string _heading = "Install ClaudeDo"; [ObservableProperty] private string _heading = "Install ClaudeDo";
[ObservableProperty] private string _subheading = "Set the installation directory and continue."; [ObservableProperty] private string _subheading = "Set the installation directory and continue.";
[ObservableProperty] private bool _installDirEditable = true; [ObservableProperty] private bool _installDirEditable = true;
[ObservableProperty] private bool _registerMcp = true;
public WelcomePageViewModel(InstallContext context) public WelcomePageViewModel(InstallContext context)
{ {
@@ -62,6 +63,7 @@ public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
public Task ApplyAsync() public Task ApplyAsync()
{ {
_context.InstallDirectory = InstallDirectory; _context.InstallDirectory = InstallDirectory;
_context.RegisterMcpWithClaude = RegisterMcp;
return Task.CompletedTask; return Task.CompletedTask;
} }

View File

@@ -1,7 +1,4 @@
using System.IO; using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Text;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps; namespace ClaudeDo.Installer.Steps;
@@ -23,7 +20,7 @@ public sealed class CreateShortcutsStep : IInstallStep
"Programs"); "Programs");
Directory.CreateDirectory(startMenuDir); Directory.CreateDirectory(startMenuDir);
var startMenuPath = Path.Combine(startMenuDir, "ClaudeDo.lnk"); var startMenuPath = Path.Combine(startMenuDir, "ClaudeDo.lnk");
CreateShortcut(startMenuPath, appExe, workingDir, "ClaudeDo Task Manager"); ShortcutFactory.CreateShortcut(startMenuPath, appExe, workingDir, "ClaudeDo Task Manager");
progress.Report($"Created Start Menu shortcut: {startMenuPath}"); progress.Report($"Created Start Menu shortcut: {startMenuPath}");
// Desktop shortcut (optional) // Desktop shortcut (optional)
@@ -32,7 +29,7 @@ public sealed class CreateShortcutsStep : IInstallStep
var desktopPath = Path.Combine( var desktopPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory), Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
"ClaudeDo.lnk"); "ClaudeDo.lnk");
CreateShortcut(desktopPath, appExe, workingDir, "ClaudeDo Task Manager"); ShortcutFactory.CreateShortcut(desktopPath, appExe, workingDir, "ClaudeDo Task Manager");
progress.Report($"Created Desktop shortcut: {desktopPath}"); progress.Report($"Created Desktop shortcut: {desktopPath}");
} }
@@ -44,48 +41,5 @@ public sealed class CreateShortcutsStep : IInstallStep
} }
} }
private 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);
}
#region COM Interop for IShellLink
[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);
}
#endregion
} }

View File

@@ -0,0 +1,53 @@
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();
}
}

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