Compare commits

...
449 Commits
Author SHA1 Message Date
mika kuns 0e314e44dd Merge remote-tracking branch 'origin/main' 2026-08-17 11:35:02 +02:00
Mika Kuns 9c4f0d1872 feat(claude-do): merge [D4] Restliche MCP-Long-Runner versorgen + Progress-Regel im
ClaudeDo-Task: 8e7f70d8-e821-45c8-835b-7743c201f599
2026-08-17 10:00:35 +02:00
Mika Kuns 23aab26daf feat(worker): report progress for continue_merge and unit-merge verify gate
continue_merge and the parent/children unit-merge drain (PlanningMergeOrchestrator)
re-run the post-merge verify gate but never forwarded their IProgress token into it,
so a slow verify command on either path went silent past Claude Code's 300s MCP
idle-abort even though D1-D3 already fixed this for merge_task/review_task's
childless path. list_worktrees also gets elapsed-time progress: many tracked
worktrees means many concurrent git subprocess spawns.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Stattdessen liest und schreibt die Ui das Feld dire

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Ausgangslage (verifiziert 2026-08-05)

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

- `MissionControlViewModel.OpenAdHocConPtySessio

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- installer moves its CWD to %TEMP% at startup, and both relaunch sites
  in the UI pass an explicit WorkingDirectory
- cache the release zip in %TEMP%\ClaudeDo-download-cache and reuse it on
  a retry while its SHA-256 still matches, so a failed attempt no longer
  costs another full download; drop it after a successful install, delete
  a mismatching one, prune zips of other versions
- roll back a half-done stash: a leftover app.bak was deleted as a stale
  stash on the next attempt, and that copy was the only one left
- name the blocked path in the error message
2026-08-04 17:09:10 +02:00
mika kuns ab56644ddc fix(ui): clear task selection when switching lists
Switching lists rebuilt Tasks.Items from scratch but left SelectedTask pointing
at a row from the previous list, so the detail pane kept showing that task even
though it is not in the visible list. Drop the selection when the list actually
changes; a reload of the same list (worker refresh, reconnect) keeps it so a
live update never yanks the detail pane away.
2026-08-04 17:09:10 +02:00
ClaudeDo CI 71050e2634 docs(changelog): update for v2.5.0 2026-07-29 15:02:02 +00:00
521 changed files with 60642 additions and 3993 deletions
+3
View File
@@ -1,4 +1,7 @@
{
"worktree": {
"baseRef": "head"
},
"permissions": {
"allow": [
"Bash(git add:*)",
+228 -1
View File
@@ -1,5 +1,232 @@
# Changelog
## v2.9.0 — 2026-08-08
### Features
- default to the Git tab for manual and interactive tasks (549aee0)
- let resizable modals snap to the screen edges (b06bbc2)
- add fable to the cost-ascending model list (15958b9)
- split throttle thresholds per bucket, add draggable gauge markers (7eeb8f5)
- add persisted side-by-side and wrap toggles to the diff viewer (cf80fe3)
- sync vertical scrolling across the split panes (14e0cff)
- tint diff rows and changed words via background renderers (861ba12)
- draw old and new line numbers in a custom margin (e299421)
- add AvaloniaEdit-based diff control with TextMate highlighting (ffc60c4)
- add filler, gap and word-diff brushes (e2d987e)
- persist diff view mode and wrap preference in ui config (5982775)
- highlight changed words inside paired diff lines (ebbee60)
- align parsed diff lines into side-by-side rows (3cd0f08)
### Fixes
- label split panes, unify the mode switch, sync split scrolling (9437121)
- install background renderers on attach so themed brushes resolve (2f3f938)
- resolve themed brushes and stop recomputing alignment on layout toggle (54b179f)
- close TOCTOU race in slot-failure broadcast poll (c6d1fff)
- broadcast TaskUpdated after online-inbox import (3acb1cb)
- broadcast WorktreeUpdated when a worktree is created (a7a3545)
- fail the task when a queue slot runner throws (c1184ad)
- drop stale delta refreshes so the newest task state wins (ac099dd)
- retry the task delta refresh instead of swallowing the error (cba7d01)
- give both processes a SQLite busy timeout (f62dbb9)
- correct five prompt claims that contradicted the tool allowlists (315bea7)
- correct the list-handler wait cap and wait through WaitingForChildren (c4e4e09)
- make template token substitution order-independent (028ac57)
- stop a dispatcher exception from killing the whole app (17e6cd6)
- strip trailing separator from the list repo in ConPTY launch args (4a7b00e)
### Refactoring
- drop rendering helpers orphaned by DiffLinesView (51371cf)
- render planning mode per file and retire DiffLinesView (f7fa829)
- rewrite external MCP tool descriptions for trigger clarity (c792765)
- split the list-handler prompt per session phase (a7ff1b3)
### Documentation
- record the shared diff plumbing and the modal chrome rules (af0f318)
- pin the review-merge verified-against commit (4e44251)
- implementation plan for handler-run task links (231b063)
- record that the sqlite busy-timeout finding was wrong, drop stale RunCreated mention (1f8f3ef)
- design for linking a handler run to the tasks it processed (fb29e8a)
- drop task 7, the handler-task broadcast already exists at the hub (1e383e1)
- add implementation plan for the side-by-side diff viewer (6e9a7ce)
- step-by-step plan for phase 1 reactivity fixes (8acf9f8)
- design for side-by-side diff view with syntax and word highlighting (292d171)
- design for UI reactivity and task-list performance (482d7ec)
- document the ConPTY trailing-separator quoting trap (d5730a8)
- update for v2.8.0 (f3e6655)
## v2.8.0 — 2026-08-06
### Features
- persist interactive session id so a closed/aborted ConPTY session can be resumed (1a988ff)
- repo scan discovers nested repos in subfolders (7affb4c)
- warn when the running worker predates the selected repo's merged HEAD (8bc7bc0)
- add treatWaitingForChildrenAsBusy to wait_for_task_change (af16830)
- warn on near-duplicate titles in add_task/batch_add_tasks (3e07536)
- add Diagnose section to SettingsWindow (7e1b117)
- raise wait_for_task_change timeout, expose queue slot state (d43b5fc)
- add get_effective_run_config MCP tool (a768bc4)
- hand off to a fresh session after Phase 2 (4ef0127)
- add copy-last-40-lines button to log visualizer (8c251c7)
- make max-turns ceiling editable in General tab (2975f90)
- add SystemCheckPage with blocking-error gating (5cc1ec9)
- pull in preflight check implementations as prerequisite for SystemCheckPage (05be07b)
- add Claude CLI preflight checks (found, version, login, auto-mode) (d743a9d)
- add Git, GitIdentity, Port, and WriteAccess preflight checks (45bc324)
- add check abstraction and EnvironmentCheckService (8b26e23)
- clamp max-turns to a configurable ceiling (08ac8bf)
- make overview panes and queue strip individually resizable (3ea48ff)
- add reading-discipline section to default system prompt (f02b7d6)
### Fixes
- honor RunCancellationRegistry.Register's return value at both dispatch sites (109e85d)
- treat an immediate ConPTY exit as a start failure (4a28bfe)
- surface merge-drain cancel rejection in task-row quick cancel (27d50b8)
- close interactive-session gate bypasses in reset-and-retry and plan queueing (1660210)
- surface worker-offline failures when deleting a task (a78cc52)
- bound the ConPTY launch-gate wait so a hung launch can't freeze other panes (4310f88)
- reparse-check repo-scan root folder and move scan off UI thread (b97f55b)
- serialize ConPTY env launch and close open-path dedupe races (176ba78)
- gate Mission Control submit-for-review, add retry, fix event leak (57c61a2)
- block cancelling a task while its unit merge is draining (79f90a9)
- filter the transliterated 'fuer' stopword, not the untransliterated 'fur' (efee147)
- claim Running before creating run resources (774f9d3)
- gate queueing on an open interactive ConPTY session (d84607f)
- route details-pane task delete through worker to advance blocked parents (9e2a15e)
- advance planning chains and parents on stale-Running recovery (58741c2)
- keep ConPTY sessions alive across Mission Control layout rebuilds (aac84e4)
- suppress auto-open conflict resolver during MCP-driven merges (5d1d2d8)
- release the ShellLink COM object so reading a .lnk doesn't lock it (c6213d7)
- surface empty review ranges and blocked children over MCP (e59f6c2)
- clean up three review leftovers from the list-handler run (b54a133)
- make list_tasks/batch_get_tasks lean by default (f8c48e2)
- restore ForegroundHelper.AllowAny call before wt.exe launch (46c664a)
- skip rewriting the autostart shortcut when already current (445242c)
- remove duplicate list-settings context menu entry (68aa2f5)
- read the persisted MCP port on update instead of the wizard default (3462ca1)
- show the MCP registration step in the progress list (e717c90)
- refresh the run session id live, close the stale handoff pane (f106c89)
- stop NumericUpDown from writing null into non-nullable settings (56f7d64)
- apply the verify gate to worktree-less approvals and report it everywhere (091aca5)
- recover the planning session id from the on-disk transcript (b679126)
- make Files-tab system prompts view-only, drop external-editor edit path (6887b1d)
- broadcast TaskUpdated on queue-claimed task start (8602010)
- remove duplicate Let-Claude entry point and icon collision (df16989)
- filter EF Core/ASP.NET Core noise out of log ring buffer (29bd1b5)
- clarify worktree commits aren't auto-commits (a7d95a0)
- resolve claude CLI shims (.cmd/.bat) not just .exe on PATH (e88f9d0)
- stop 429s with an activity-dependent poll cadence + manual refresh (2700c3d)
- pass FakeTranscriptUsageReader to TaskRunner in FailureDiagnosisTests (f6cb825)
- stop on-disk prompt overrides from freezing forever (b153869)
- surface the real reason a Claude run failed instead of a generic exit-code message (2ebdadf)
- record real raw token usage per run, not the uncached remainder (7d3d6d7)
### Performance
- stop echoing task description from writing MCP tools (ecba129)
### Documentation
- document the ConPTY env race and open-path dedupe fixes (4d9ceef)
- reflect claim-before-create ordering in worker-task-pipeline (42a234b)
- document the queueing gate on open ConPTY sessions (ed7cd7d)
- document interactive session id persistence and resume precedence (6118698)
- document stale-Running chain/parent advance fix (1649291)
- document the kill-on-detach gotcha and ConPtyPaneHost reparenting (0d1e3b9)
- point both drift checks at the merged state (0878bae)
- point external-mcp drift check at the merged state (03c4ed4)
- evaluate planning-chain fork-base options, recommend fork-from-predecessor (db4177d)
- record the verify-gate reach and the NumericUpDown null trap (0f187d8)
- mark the usage-monitor pass done and drop the light-theme checks (730ecb1)
- record the findings from the 2026-08-06 visual pass (b6ecbb1)
- re-verify the 2026-07-24 findings and drop the fixed ones (b5a8d58)
- drop the verified 2026-07-27 visual-pass block, keep its design notes (e113987)
- mark the MaxTurnsCeiling editor as shipped (4309c4c)
- drop the stale reserved-slot comment now that Claude Help Me shipped (41c2de5)
- document Environment Checks + note unmerged prerequisite gap (a6af90f)
- break growing enumerations into one entry per line (beef57f)
- root-cause --permission-mode auto + CLI/.NET preflight research (dce42a9)
- rename ConPTY session UI text to interactive session (b1e4e54)
- update for v2.7.0 (0e0438d)
## v2.7.0 — 2026-08-05
### Features
- add usage monitor modal with gauges and model/task usage analysis (8103006)
- add usage pill to footer and mission control header (7cbd4e6)
- expose usage/model-usage hub surface and persist run model (d4cd202)
- add roadblock reply field to the ROADBLOCK card (d2ca7fb)
- record merge commit SHA and add revert_merge tool (10e561f)
- let CreateChildTask set maxTurns on child tasks (65db1cd)
- add post-merge verification gate for list merges (c9ba1e2)
- add preview_merge and preview_merge_set MCP tools (394feba)
- add splitting, turns-preflight, merge-order rules to list-handler prompt (8ed6e08)
- add wait_for_task_change MCP tool (194ce58)
- render task descriptions into the list-handler brief (b38b085)
- add TranscriptUsageReader for per-model token usage (e926f4d)
- add OAuth usage client + poller (20d17c6)
- add usage gate thresholds and task run model column (bedd2de)
- give the list handler its own review task (c07c1f7)
- clear focus from textboxes on Escape in the main window (52c2186)
- show newest log entries first in Log Visualizer (47edab5)
### Fixes
- install a real localizer in UsagePillViewModelTests (677a4c1)
- avoid int CommandParameter cast crash in usage monitor presets (57326f1)
- transport ConPTY task brief via file, not CLI argument (3972ce5)
- drop notification for the removed CanPickUpInTerminal property (eee87f0)
- normalize model alias before preset lookup, wire DefaultMaxTurns as fallback (7d636c6)
- only ask list-handler dedupe questions when a candidate exists (fd50a4f)
### Refactoring
- remove pick-up-in-terminal, keep ConPTY as the single session entry (87de53e)
### Documentation
- mark the 2026-08-05 list-handler run complete (032debc)
- document usage monitor & gate across CLAUDE.md files (cf9dd1c)
- update for v2.6.0 (342a061)
## v2.6.0 — 2026-08-04
### Fixes
- unbreak the update path and cache the download (63d8b5c)
- clear task selection when switching lists (ab56644)
### Documentation
- update for v2.5.0 (71050e2)
## v2.5.0 — 2026-07-29
### Features
- move a task to another list via drag & drop (92ce7a4)
- sidebar shows queued+running, no auto-monitor seeding (44cdad3)
- expose maxParallelExecutions in get_app_settings (e653677)
- accent color presets in Settings → General (149e2ad)
- allow Done via update_task_status with worktree guard (d569313)
- add Let Claude handle it broom button to tasks header (7d6cb2b)
### Fixes
- stop swallowing mutating hub call failures (b5464fc)
- pin LogRingBuffer clock in Does_not_throw_when_detached (58f8b11)
- propagate HubException from ApproveReviewAsync so blocked merges surface errors (e1807fd)
### Documentation
- update for v2.4.0 (3fbbd7a)
## v2.4.0 — 2026-07-27
### Features
@@ -509,7 +736,7 @@
- initialize Localizer at app startup from config/OS culture (f529a5f)
- add Language preference and Save() to AppSettings (6a85d82)
- add Avalonia loc:Tr markup extension and LocalizedString (35ad171)
- seed en.json and wire locale copy to app output (3c40bb5)
- seed en.json and wire locale copy to app output (3c40bb5e)
- add CultureResolver for OS-culture mapping (d95d55e)
- add Localizer with fallback chain and change event (d22b50e)
- add LocaleStore folder discovery (a83a0c4)
+6 -3
View File
@@ -11,10 +11,13 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner
- **ClaudeDo.Localization** — `locales/en.json` + `locales/de.json` and the lookup service
- **ClaudeDo.Releases** — Gitea release client (`IReleaseClient`), used by the Ui update check and the Installer
- **ClaudeDo.Installer** — WPF (`UseWPF`) setup app; install/update/uninstall step pipeline
- **tests/** — six xUnit projects (Worker, Data, Ui, Localization, Installer, Releases); Worker.Tests run real SQLite and real git
Each project has its own `CLAUDE.md` — those are the living per-project docs.
Per-project `CLAUDE.md` files exist for **App, Data, Installer, Ui, Worker, and Worker.Tests**
those are the living per-project docs. Localization, Releases, and the other five test projects
have none; this file plus the code is all there is for them.
## Tech Stack
@@ -39,7 +42,7 @@ Each project has its own `CLAUDE.md` — those are the living per-project docs.
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A task that spawns/has children passes through WaitingForChildren first, then surfaces for review once every child is terminal — this is the single parent model for both planning and improvement parents (planning/improvement *children* themselves go straight to Done, only the parent is reviewed). From review you can approve, reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel. Approve is the single review+merge action: a childless task merges its own worktree then Done (conflicts keep it in WaitingForReview); a task with children drives the unit merge (parent worktree if any + each Done child in order, with conflict continue/abort). Tasks with no active worktree (sandbox run) approve straight to Done. In the detail pane, **Approve & Merge is gated behind opening the diff**: when there is something to inspect (worktree diff / merged range / children combined diff) the button stays disabled until the diff or combined-diff viewer has been opened once, and re-locks per run (any state change resets it); tasks with nothing to inspect are never gated. The row-level quick-approve in the task list is an intentional bypass.
- Task status flow: `Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled`; a task with children passes through `WaitingForChildren` first. **Approve is the single review+merge action** (no separate "Merge all"), and in the detail pane it's gated behind opening the diff. Full transition table → `src/ClaudeDo.Worker/CLAUDE.md`; merge/review/gate mechanics → `docs/explore-notes/review-merge.md`.
- Worktree state flow: Active -> Merged | Discarded | Kept
- The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed
- Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)
@@ -84,4 +87,4 @@ dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
- `docs/improvement-plan.md` — improvement snapshot from 2026-04-13 (historical)
- `docs/prompts-inventory.md`, `docs/mailbox-proposal.md` — reference material (mailbox integration is parked)
- `CHANGELOG.md` — Keep a Changelog format, maintained on release
- `docs/explore-notes/` — distilled maps of complex subsystems from deep exploration (detail too fine for a CLAUDE.md). **Before** deep-exploring a subsystem, check for a matching note first; **after** a deep explore, distill durable findings back and bump its "verified against" commit. Always verify against current code before trusting. See `docs/explore-notes/README.md`.
- `docs/explore-notes/` — distilled maps of complex subsystems (detail too fine for a CLAUDE.md, read on demand). **Before** deep-exploring a subsystem, check for a matching note first; **after** a deep explore, distill durable findings back and bump its "verified against" commit. Always verify against current code before trusting. See `docs/explore-notes/README.md`. Current notes: `worker-task-pipeline`, `usage-monitoring`, `external-mcp`, `review-merge`, `conpty-sessions`, `installer-preflight`.
+411 -88
View File
@@ -1,106 +1,429 @@
# ClaudeDo
A desktop task management app that executes tasks autonomously via [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) in isolated git worktrees.
A Windows desktop app that turns your to-do list into a work queue for [Claude Code](https://docs.anthropic.com/en/docs/claude-code).
Queue up coding tasks, and ClaudeDo picks them up one by one — each running in its own worktree so your main branch stays clean.
Write down what you want done. ClaudeDo picks the task up, runs Claude in an isolated git
worktree, and hands you back a diff to review. Your main branch is never touched until you
approve.
## Architecture
It looks and feels like a normal task app — lists, My Day, stars, due dates — except every
task can also be *executed*.
Two-process system communicating over SignalR:
---
| Project | Role |
|---|---|
| **ClaudeDo.App** | Avalonia desktop entry point, DI container setup |
| **ClaudeDo.Ui** | Views, ViewModels, SignalR client (MVVM) |
| **ClaudeDo.Data** | SQLite data layer, repositories, models, GitService |
| **ClaudeDo.Worker** | ASP.NET Core hosted service, task queue, Claude CLI runner |
## Contents
```
┌────────────────┐ SignalR ┌────────────────┐
│ ClaudeDo.App │◄───────────►│ ClaudeDo.Worker │
│ (Avalonia) │ 127.0.0.1 │ (ASP.NET Core) │
│ │ :47821 │ │
│ ┌────────────┐│ │ ┌────────────┐ │
│ │ Ui ││ │ │ TaskQueue │ │
│ │(ViewModels)││ │ │ Claude CLI │ │
│ └────────────┘│ │ └────────────┘ │
└───────┬────────┘ └───────┬────────┘
│ │
└──────────────┬───────────────┘
┌───────┴───────┐
│ ClaudeDo.Data │
│ (SQLite) │
└───────────────┘
```
- [Is this for you?](#is-this-for-you)
- [Requirements](#requirements)
- [Install](#install)
- [The window](#the-window)
- [The core loop](#the-core-loop)
- [Lists and repositories](#lists-and-repositories)
- [Writing a task](#writing-a-task)
- [Ways to run a task](#ways-to-run-a-task)
- [Watching work happen](#watching-work-happen)
- [Reviewing and merging](#reviewing-and-merging)
- [Resolving conflicts](#resolving-conflicts)
- [Worktrees](#worktrees)
- [Staying inside your usage limits](#staying-inside-your-usage-limits)
- [Your daily rhythm](#your-daily-rhythm)
- [Settings](#settings)
- [Letting Claude drive ClaudeDo](#letting-claude-drive-claudedo)
- [Updates](#updates)
- [Where your data lives](#where-your-data-lives)
- [Keyboard shortcuts](#keyboard-shortcuts)
- [Troubleshooting](#troubleshooting)
- [For developers](#for-developers)
## Tech Stack
---
- .NET 8.0
- Avalonia 12.0.0 (Fluent theme)
- SQLite (WAL mode) via Entity Framework Core (EF Core + Migrations)
- SignalR for real-time IPC between UI and Worker
- CommunityToolkit.Mvvm for source-generated MVVM
- Git worktrees for task isolation
## Is this for you?
## Prerequisites
ClaudeDo is built for one person running many small-to-medium coding jobs across several
repositories, mostly unattended.
- [.NET 8.0 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
It fits when you:
- have a backlog of contained changes ("rename this", "add that endpoint", "fix this bug")
- want them worked on while you do something else
- still want to read every diff before it lands on `main`
It is *not* a CI system, not a team tool, and not a chat window. There are no pull
requests, no reviewers but you, and no cloud component (unless you deliberately turn on the
optional online inbox).
## Requirements
- Windows 10/11
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code), installed and signed in
(ClaudeDo runs `claude` as *you* — it never handles your credentials)
- Git
- [.NET 8 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/8.0) — the installer
itself needs it, so install it first if the installer refuses to start
## Getting Started
## Install
Run `ClaudeDo.Installer.exe`. It downloads the current release, writes its config, and
starts the background worker.
The wizard asks for:
| Page | What it decides |
|---|---|
| **Welcome** | Install folder, and whether Claude may manage your tasks via MCP (see [below](#letting-claude-drive-claudedo)) |
| **Data paths** | Where the database, logs, sandboxes and worktrees live |
| **Worker** | Port, path to the `claude` binary, and whether the worker starts at logon |
Re-running the installer later gives you **Update**, **Repair** and **Uninstall** instead
of the full wizard. Uninstall asks separately before deleting your tasks and settings.
ClaudeDo has two parts: the window you see, and a background **worker** that does the
actual running. The worker starts at logon and keeps going even when the window is closed —
so a long task finishes whether you are watching or not.
## The window
Three panes ("islands"), plus a footer:
```
┌──────────────┬────────────────────────────┬───────────────────────────┐
│ LISTS │ TASKS │ DETAILS │
│ │ │ │
│ My Day │ ▸ Add a task… ENTER │ Fix login redirect │
│ Important │ │ ───────────────────── │
│ Planned │ OVERDUE │ Steps ▢ ▢ ▣ │
│ │ ● Fix login redirect │ Details (markdown) │
│ Queue 3 │ │ Files (drop here) │
│ Running 1 │ TASKS │ │
│ Review 2 │ ● Add CSV export RUNNING │ Worktree · Diff · Merge │
│ │ ● Bump deps QUEUED │ │
│ MY LISTS │ ● Write changelog │ Output │ Git │ Session │
│ LagerApp 7 │ ● Call the tax guy MANUAL │ ┌─────────────────────┐ │
│ LogX 2 │ │ │ live Claude output │ │
│ ClaudeDo 12 │ │ └─────────────────────┘ │
├──────────────┴────────────────────────────┴───────────────────────────┤
│ ● Online 5h 41% · 7d 22% worker log line… logs │
└───────────────────────────────────────────────────────────────────────┘
```
- **Lists** (left) — smart lists, virtual work lists, and your own lists (one per repo)
- **Tasks** (middle) — the selected list, grouped into Overdue / Tasks / Completed
- **Details** (right) — everything about the selected task, including its live output
- **Footer** — worker connection, usage pill, and the latest worker log line (clickable)
On a narrow window the side panes fold away automatically.
The grid icon in the title bar opens **Mission Control**, a separate window for watching
several running sessions at once.
## The core loop
```
write it down → queue it → Claude runs it → you review → merge
(Idle) (Queued) (Running) (Waiting for (Done)
Review)
```
1. **Capture.** Type into the add box. That is it — a task starts out as a plain reminder.
2. **Queue.** Right-click → *Send to queue*. The worker claims queued tasks in order, as
many at a time as *Max parallel executions* allows.
3. **Run.** ClaudeDo creates a git worktree off your repo (branch `claudedo/<id>`), runs
Claude there with your task as the prompt, and streams the output into the detail pane.
4. **Review.** On success the task lands in **Waiting for Review** with a committed
worktree and a diff. Nothing has touched your working copy.
5. **Merge.** *Approve & merge* merges the branch into the target you pick, then marks the
task Done. Or reject it with feedback, park it, or cancel it.
A task that fails is marked **Failed** and keeps its worktree and its log, so you can look
at what happened and either continue the same session or reset and retry from scratch.
## Lists and repositories
A list becomes *runnable* by giving it a **working directory** — a git repo. Tasks in that
list get worktrees off that repo; tasks in a list without a working directory can still be
run, but in a scratch sandbox with no git.
- **Add repos as lists** (`Repositories` menu, or the folder icon) scans folders you point
it at and creates one list per git repo it finds. This is the fastest way to get started.
- **List settings** (right-click a list) sets the name, working directory, default commit
type, the agent defaults for that list, and an optional verify command.
- **Manual list** — mark a list as reminders-only. New tasks in it start out manual, so
automation never touches them. Good for a "Errands" or "Phone calls" list.
- Drag a task onto another list to move it. ClaudeDo refuses to move a running task and
warns you when the two lists point at different repos.
**Smart lists** are always there:
| List | Contains |
|---|---|
| **My Day** | What you (or the daily prep) picked for today, plus a pinned Notes row |
| **Important** | Starred tasks |
| **Planned** | Everything with a date |
| **Queue / Running / Review** | Live work — waiting, in flight, and awaiting your review |
## Writing a task
The detail pane is where a one-line reminder becomes a brief Claude can act on:
- **Title + Details** — markdown, with an edit/preview toggle. This is the prompt.
- **Steps** — a checklist. Handy for you, and included when you copy the task out.
- **Files** — drag files onto the pane (or use *Add file…*) to attach reference material.
Attachments are handed to Claude as read-only paths, not pasted into the prompt.
- **Star / Schedule / Add to My Day** — the usual task-app affordances.
- **Agent settings** — per-task overrides for model, turn budget, extra system prompt,
agent file and skills. Anything you do not override shows an *inherited* badge pointing at
the list or global default.
- **Refine** — hand the task to Claude to sharpen it: it rewrites the title and details into
a proper brief. Useful when you captured something in three words.
- **Mark as manual** — a MANUAL badge; the queue, the daily prep and every automation skip
it. Use it for things only you can do.
## Ways to run a task
| Way | What it does | Use it when |
|---|---|---|
| **Send to queue** | Worker picks it up in order | The normal path |
| **Run now** | Skips the queue, starts immediately | You want this one first |
| **Continue** | Resumes the last session with a follow-up | "Almost right, now also…" |
| **Reset & retry** | Throws the worktree away, re-queues from scratch | The run went sideways |
| **Open ConPTY session** | Opens the *real* Claude terminal for this task in Mission Control | You want to drive it yourself, with the task as the starting brief |
| **Open planning session** | An interactive session whose job is to break the task into subtasks | The work is too big for one run |
| **Let Claude handle it** | Hands a whole list over in one go | You have a pile of small tasks |
### Planning sessions
A planning session is a conversation whose output is *structure*, not code. Claude creates
child tasks under the parent. You then:
1. **Finalize plan** — children are chained (each waits for the previous one) but stay Idle.
2. **Queue plan** — when you are happy with the list, this queues them all.
3. The parent waits in **Waiting for Subtasks** until every child is terminal, then surfaces
for review once — you review and merge the *whole unit*, not each child.
A parked planning session is remembered; the next launch offers to resume, finalize or
discard it.
### "Let Claude handle it"
Right-click a list → *Let Claude handle it*. You get a checkbox list of that list's open
tasks. Confirm, and one Claude session works the selection end to end: it reads them,
de-duplicates overlaps, enhances thin descriptions, queues the work, and merges the results.
It shows up as its own MANUAL task so you can see what a given run covered, and you review
the combined diff when it is finished.
## Watching work happen
- **Detail pane, three tabs** — *Output* (live Claude stream), *Git* (worktree, diff,
merge), *Session* (result, token/turn counts, subtask outcomes).
- **Mission Control** — one tile per running or interactive session, in Focus or Overview
mode. ConPTY tiles are the real Claude TUI, embedded: keyboard, colors, `/` commands, all
of it. *New session* opens an ad-hoc one that belongs to no task.
- **Roadblocks** — when a run gets stuck it reports a roadblock instead of silently failing.
The Session tab shows it as its own card with a reply box: answer the question and the
same session picks up where it stopped.
- **Claude is asking** — an interactive session can put a question in front of you mid-run;
answer it inline in Mission Control and the run continues.
- **Footer log strip** — the worker's latest event. Failures of your own actions flash here
too, instead of vanishing. Click it for the last 30 minutes of worker logs, with a
warnings-and-errors filter.
## Reviewing and merging
When a task reaches **Waiting for Review** you get four actions:
| Action | Result |
|---|---|
| **Approve & merge** | Merges the work into the target branch, then Done |
| **Reject** | Asks for feedback and re-runs the same session with it |
| **Park** | Back to Idle so you can rewrite the task yourself |
| **Cancel** | Done with it; the worktree stays until you clean it up |
**You have to look before you approve.** When there is a diff to inspect, *Approve & merge*
stays disabled until you have opened the diff viewer once. It re-locks after any new run.
(The quick-approve on the task row itself is a deliberate bypass for when you already know.)
The diff viewer shows a file tree on the left and the diff on the right, and can show a
dirty worktree, a branch against its base, or a commit range. For a parent with children,
*Review combined diff* shows per-subtask diffs plus a combined preview of the whole unit.
**Verify command** (optional, per list) — a command that must exit 0 after a merge lands
before the task is allowed to reach Done. Typically a build or a test run. If it fails, the
merge stays (nothing is rewritten behind your back) but the task is held out of Done and the
failure output is reported.
## Resolving conflicts
If a merge conflicts, ClaudeDo opens a three-pane merge editor in-app:
```
┌──────────────────┬──────────────────┬──────────────────┐
│ MAIN │ RESULT │ INCOMING │
│ merge target │ (editable) │ task branch │
│ │ ▐ │ │
│ › │ ← you build │ ‹ │
│ │ the result │ │
└──────────────────┴──────────────────┴──────────────────┘
```
- Whole files, syntax-highlighted, scrolling in sync
- Each conflict starts *empty*. The gutter arrows toggle each side in or out — take main,
incoming, both (in the order you click), or neither
- Only conflict regions are editable in the middle pane; type your own resolution if
neither side is right
- `F8` / `Shift+F8` jump between conflicts; the ruler beside the result pane maps every
conflict in the file
- A counter tells you how many files still need attention. *Resolve & continue* is disabled
until they are all done, and *Abort merge* always leaves the target branch as it was
Binary conflicts cannot be resolved here — ClaudeDo says so and lists the paths.
## Worktrees
Every run gets its own worktree, so parallel tasks never fight over your checkout.
The **Worktrees** overview (per list or global) lists them with state, diff size, age and
outcome. From there you can show a diff, jump to the task, merge, batch-merge a selection
into one target, mark one *Kept* or *Discarded*, or force-remove a leftover. Worktrees whose
directory is gone from disk are flagged as *phantom*.
Finished worktrees are auto-cleaned after a number of days you choose in Settings.
## Staying inside your usage limits
The footer pill shows your Claude usage: `5h 41% · 7d 22%`. Click it for the **Usage
Monitor**:
- Gauges for the 5-hour session window and the 7-day windows, each with a reset countdown
- **Models** — token usage per model over a range you pick, split into ClaudeDo's own
consumption and everything else
- **Tasks** — your biggest consumers, with run counts and totals
And the part that matters when you are asleep: the **usage limit stop**. Set a percentage
per window in Settings, and once usage reaches it the queue simply stops picking up new
tasks. Running tasks finish normally, and the queue resumes by itself as the window rolls
over. `0` turns a stop off. Manual actions — *Run now*, *Continue*, interactive and planning
sessions — are never blocked.
## Your daily rhythm
- **My Day** — the day's shortlist. *Clear day* empties it.
- **Prime Claude** (Settings → Prime Claude) — schedules for the days and times you pick.
At that time ClaudeDo runs a short prep session: Claude looks at your idle tasks, picks an
effort-aware subset up to your daily cap, and fills My Day. It also warms your usage
window for the day. Only runs while ClaudeDo is open; if you start the app within 30
minutes of a scheduled time it fires right away. *Plan day* runs it on demand.
- **Notes** — a pinned row in My Day: dated bullet notes with a day navigator, for the
thoughts that are not tasks.
- **Weekly report** (`Help` menu) — pick a range (defaults to "since your standup weekday")
and Claude writes up what actually happened, from your task and session history. Paths you
do not want in reports can be excluded in Settings.
## Settings
Most settings exist at three levels — **global → list → task** — and the more specific one
wins. Overridden fields carry an *override* badge with a one-click reset; inherited ones say
where they come from.
**General**
- Default instructions applied to every task
- Default model, and a **per-model table** of reasoning effort and turn budget — so `haiku`
can be cheap and short while `opus` gets room to think
- Permission mode for autonomous runs
- Max parallel executions
- Usage limit stops (see above)
- Session skills applied to every task
- Report exclusions, standup weekday
- Accent color (Moss / Peat / Sea) and language (English / German)
**Worktrees** — sibling or central placement, auto-cleanup age, and the manual cleanup and
force-remove-all buttons.
**Files** — open the prompt templates (system, planning, retry, daily prep, weekly report)
in your editor, and restore the bundled default agent files.
**Skills** — install a skill from a git URL, update it, remove it. Skills selected globally,
per list and per task combine.
**Prime Claude** — the schedules and the daily task cap.
**Online Inbox** (optional, off by default) — mirrors your idle backlog to a server so you
can capture tasks from your phone. Disabled means zero network traffic. Enabling it needs a
URL plus a sign-in; the refresh token is stored encrypted on your machine, never in a config
file.
## Letting Claude drive ClaudeDo
If you allowed it during install, ClaudeDo registers itself as an MCP server with the Claude
CLI. Any Claude session on your machine can then read and manage your tasks: list and create
tasks, add subtasks, queue and cancel runs, read logs and diffs, merge, review, manage lists
and per-list config.
Concretely, you can sit in a normal Claude Code session and say "put these five things in
the ClaudeDo backlog for the LagerApp list", or "check whether the CSV export task is done".
The MCP surface is deliberately narrow on the dangerous end: it can start and observe work,
but it cannot rewrite your app settings, and a task with an active worktree cannot be forced
to Done behind your back.
## Updates
ClaudeDo checks for new releases and shows a banner when one is out. *Update now* relaunches
the installer, which stops the worker, swaps the binaries and starts it again. Your database,
worktrees and settings are preserved. You can also check manually via `Help → Check for
updates`.
## Where your data lives
Everything is under `%USERPROFILE%\.todo-app`:
| Path | What |
|---|---|
| `todo.db` | Your tasks, lists, runs and worktree records (SQLite) |
| `ui.config.json` | Window and UI settings |
| `worker.config.json` | Worker settings — paths, port, worktree strategy |
| `logs/` | Worker and task logs |
| `agents/` | Your agent definition files |
| `attachments/` | Files you attached to tasks |
`Help → About` links straight to these folders. Nothing leaves your machine unless you turn
on the online inbox.
## Keyboard shortcuts
| Key | Action |
|---|---|
| `Ctrl+K` | Focus search |
| `Ctrl+N` | Focus the add-task box |
| `Enter` | Add the task |
| `Esc` | Leave the current text field / close a dialog |
| `F8` / `Shift+F8` | Next / previous conflict (merge editor) |
## Troubleshooting
**"Worker not reachable"** — the background worker is not running. The dialog offers to
start it; `Worker → Restart worker` does the same later. If it keeps happening, re-run the
installer and choose *Repair*.
**A task failed immediately** — usually the `claude` CLI is not signed in, or the list's
working directory is not a git repo. Check the Output tab and the footer log.
**A task is stuck in Running after a crash** — the worker sweeps orphaned runs to Failed on
startup. Restart the worker.
**Nothing is being picked up** — check the usage pill: if a usage stop is active the queue is
paused on purpose. Also check whether the tasks are MANUAL, blocked by a predecessor, or
scheduled for later.
## For developers
Architecture, build commands and per-project docs live in `CLAUDE.md` (root and one per
project under `src/`). Short version: .NET 8, Avalonia UI, SQLite via EF Core, and a
SignalR-connected worker process on `127.0.0.1:47821`.
```bash
# Build
dotnet build src/ClaudeDo.App
dotnet build src/ClaudeDo.Worker
# Run tests
dotnet test tests/ClaudeDo.Worker.Tests
# Run the app
dotnet run --project src/ClaudeDo.App
```
## How It Works
1. Create a task in the UI and tag it with **"agent"** to mark it for automated execution.
2. The Worker picks up queued tasks and runs each one via Claude CLI in an isolated git worktree.
3. When done, the worktree can be merged, kept for review, or discarded.
**Task status flow:** `Manual | Queued → Running → Done | Failed`
**Worktree state flow:** `Active → Merged | Discarded | Kept`
## Configuration
All data and config lives under `~/.todo-app/`:
| File | Purpose |
|---|---|
| `todo.db` | SQLite database |
| `ui.config.json` | UI settings |
| `worker.config.json` | Worker settings (worktree strategy, etc.) |
| `logs/` | Application logs |
## Project Structure
```
ClaudeDo.slnx
├── src/
│ ├── ClaudeDo.App/ # Desktop entry point
│ ├── ClaudeDo.Ui/ # Views & ViewModels
│ ├── ClaudeDo.Data/ # Data access layer
│ └── ClaudeDo.Worker/ # Background task runner
├── tests/
│ └── ClaudeDo.Worker.Tests/
├── schema/
│ └── schema.sql # Database schema
└── docs/
├── plan.md # Architecture & design spec
├── open.md # Verification checklist & backlog
└── improvement-plan.md # Prioritized improvements
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
## License
+12
View File
@@ -10,6 +10,18 @@ These sit **between** the CLAUDE.md files and the code:
too fine-grained for a CLAUDE.md but stable enough to be worth caching. Read on demand.
- **code** — the only source of truth.
## Index
| Note | Covers |
|---|---|
| [worker-task-pipeline](worker-task-pipeline.md) | `TaskRunner` end-to-end: config resolution, worktree, CLI invocation, streaming, commit |
| [usage-monitoring](usage-monitoring.md) | OAuth usage endpoint, gate, throttle, per-run token accounting, usage pill/modal |
| [external-mcp](external-mcp.md) | The `claudedo` MCP tool surface + its two test-enforced conventions |
| [review-merge](review-merge.md) | Approve=merge-unit, verify gate, `MergeCommit`/revert, diff stack, conflict resolver |
| [conpty-sessions](conpty-sessions.md) | Interactive/planning/list-handler launch specs + the arg-flattening gotcha |
| [installer-preflight](installer-preflight.md) | CLI version/login/auto-mode research, the `ExecutableResolver`/shim root cause, and the Installer's `Checks/`+`SystemCheckPage` implementation status |
| [list-virtualization-spike](list-virtualization-spike.md) | Phase 2a gate spike: virtualized `ListBox` + the existing ghost-drag `InputHitTest` model, verified headless |
## Rules
- **Only stable structure.** Flows, responsibilities, entry points, invariants, relative
+303
View File
@@ -0,0 +1,303 @@
# ConPTY interactive sessions & launch specs
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `79b3580` (2026-08-11).
> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/Planning src/ClaudeDo.Worker/Hub src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
Covers `InteractiveLaunchSpecService` and the four kinds of embedded ConPTY session the UI
process hosts (real `claude` TUI in a Mission Control tile).
Autonomous queue tasks are **not** covered here — they stay on the stream-json path via
`TaskRunner`. See [worker-task-pipeline.md](worker-task-pipeline.md).
## The four session kinds
| Kind | Hub spec method | Notes |
|---|---|---|
| Task session | `GetInteractiveLaunchSpec` | Effort from the task/list model preset |
| Ad-hoc | `GetAdHocLaunchSpec` | Effort from the global default |
| Planning | (planning start/resume) | Effort from `PlanningAlias`; uses `--permission-mode default`, **not** `plan` |
| List handler | `GetMergeHelperLaunchSpec` / `GetMergeHelperHandoffLaunchSpec` | Model + effort fixed per role (`ModelRegistry.HandlerTriageAlias`/`HandlerWaitAlias`/`HandlerMergeAlias`), **not** from list config; `--permission-mode` via `PermissionModeResolver` (unattended) |
## ⚠️ Gotcha: never pass task free-text as a CLI argument
**No ConPTY path ever passes task free-text (title / description / brief) as a CLI argument.**
Every one of them writes it to a file first and hands `claude` a single-line kickoff pointing at
that file, exposed via `--add-dir`.
Two independent reasons:
1. The ConPTY host flattens `Args` into **one command line** to spawn the process, and `claude`
re-splits that line on whitespace. Any token starting with `-` in real task text (e.g. `->`,
`--abort`) is then misread as an unknown option.
2. A raw multi-line positional prompt truncates at its **first newline** regardless.
A fresh task session's brief lives at `~/.todo-app/task-sessions/<taskId>/brief.md`
(`InteractiveLaunchSpecService.BuildFreshTaskArgsAsync`). A task with neither title nor
description skips the file **and** the positional arg entirely — it still gets `--session-id`.
## Argument ordering
Every spec passes `--effort <level>` from the relevant model's preset. It **leads** the args —
except for a fresh task session with a brief, where `--add-dir <sessionDir>` must come first so
`--effort` (a single-value flag) can sit directly before the positional kickoff.
`--model` is deliberately **NOT** forced on an interactive session — the user can still switch
models in the TUI.
### ⚠️ Gotcha: no directory arg may end in a separator
`PtyTerminalSession` hands `Args` to `TerminalControl.Args`, which the library flattens into ONE
Windows command line with each token quoted. Windows argv rules read `\"` as an *escaped* quote,
so a token like `"C:\repo\"` never closes and every following argument is swallowed by the
preceding **variadic** flag. A list working dir stored as `C:\Dev\Repos\Bandel.Hub\` therefore
fed `--add-dir` the repo, `--append-system-prompt-file`, its value **and** the positional kickoff:
the CLI warned `brief.md is not a directory` and the session opened with no prompt at all
(2026-08-06). `BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` run the repo through
`TrimTrailingSeparator`; session dirs the worker builds never carry one.
The same data bit the UI's "Open in terminal" on 2026-08-10 (`wt -d "C:\…\StaplerTracking\"`
`Could not access starting directory "C:\…\StaplerTracking""`), so the fix moved to the write side:
**`ListRepository.AddAsync`/`UpdateAsync` normalize `WorkingDir` via `Paths.TrimTrailingSeparator`**,
which covers every writer (UI create, repo import, hub `UpdateList`, MCP `CreateList`/`UpdateList`) —
the UI and worker keep their own defensive trim for rows written before that. Anything new that puts
a **user-supplied** path on a command line should use `Paths.TrimTrailingSeparator` and, on the
`ProcessStartInfo` side, `ArgumentList` rather than an interpolated `Arguments` string.
Diagnosing this from code or a PowerShell repro is a dead end — PowerShell quotes correctly, so
every repro passes. Read the real command line instead:
`Get-CimInstance Win32_Process -Filter "Name = 'claude.exe'"`.
## Resuming a task session (`TaskEntity.InteractiveSessionId`)
`claude --session-id <uuid>` lets the caller pre-assign a conversation's session id instead of
waiting for the CLI to generate one. `BuildForTaskAsync` uses this so a closed or aborted
interactive task session can be resumed even if it never got far enough to write anything to its
own transcript:
1. **Resume check.** If the task isn't on a freshly (re)created worktree, `BuildForTaskAsync`
picks a session to resume with `task.InteractiveSessionId ?? run?.SessionId` — this task's own
last *interactive* conversation takes precedence over the latest *autonomous* run's session,
since they're distinct conversations even against the same worktree. A task that has only ever
run autonomously still resumes into that run's session the first time it's opened interactively
(this is the pre-existing behavior `run?.SessionId` alone used to provide).
2. **Fresh path.** If neither is available (never run any way, or `isFreshWorktree`), a new
`Guid.NewGuid()` is generated and persisted to `TaskEntity.InteractiveSessionId` via
`TaskRepository.SetInteractiveSessionIdAsync`**before** the `LaunchSpec` is returned, i.e.
before the ConPTY host ever spawns `claude`. `BuildFreshTaskArgsAsync` then passes it as
`--session-id <guid>`, placed as the single-value flag directly before the positional kickoff
(or, with no brief, right after `--effort`).
3. **Fresh worktree wins.** `isFreshWorktree` forces `run` to `null` *and* is checked before
reading `task.InteractiveSessionId`, so a recreated worktree never resumes a stale id from
either source — it always takes the fresh path, which overwrites the stale
`InteractiveSessionId` with the new one.
Net effect: reopening an interactive session for a task (pane closed, process killed, whatever)
resumes the same claude conversation, because the id was committed to the DB before the previous
launch even started.
## List handler ("Let Claude handle it")
`BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` resolve `--permission-mode` via
`PermissionModeResolver` (currently always `auto`, since none of the handler roles run on haiku)
so the session runs unattended. The `--allowedTools` allowlist is the security boundary:
`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task` (`Task` lets the Merge
role delegate diff reviews to sonnet subagents).
The run now spans up to **five** phase-scoped sessions instead of two, chained via
`handoff_list_handler(taskId, survivingTaskIds, nextPhase)`: opus Triage → sonnet Wait → opus
Merge → (only if Merge started reruns) sonnet Wait(final) → opus Merge(final). `nextPhase` (`wait`
| `merge` | `wait_final` | `merge_final`, validated by `MergeHelperPhase.Validate`) picks both the
system prompt (`PromptKind.MergeHelperWait`/`MergeHelperMerge`) and the model
(`ModelRegistry.HandlerWaitAlias`/`HandlerMergeAlias`) for the next session; the `_final` variants
share the same prompt/model as their non-final counterpart and differ only in one extra line
rendered into the handoff kickoff file (`PromptKind.MergeHelperHandoff`) marking the final round
and forbidding further reruns.
Each handoff hands the SAME handler task id to a NEW tile, and `MissionControlViewModel.
OpenMergeHelperHandoffConPtySessionAsync` closes the outgoing phase's pane (`CloseConPtySession`,
which disposes its `PtyTerminalSession` and kills the underlying `claude` process) before opening
the next one — every tile is a live process with the full `mcp__claudedo__*` surface, and the
outgoing session is only ever told to end its turn, never to exit. This keeps the one-pane-per-
`TaskId` invariant that `ConPtySessions` relies on elsewhere (`OpenConPtySessionAsync`'s dedupe,
`OnPaneSubmitForReview`) — both use a plain `FirstOrDefault(s => s.TaskId == taskId)`, not a
"newest wins" lookup.
`MCP_TOOL_TIMEOUT` is 200 s here — `TaskWaitMcpTools` clamps its own timeout to 170 s to stay
comfortably under it (see [external-mcp.md](external-mcp.md)).
### The host task and its commit range
The handler run **owns a real ClaudeDo task**, created by `CreateMergeHelperTask` (hub) →
`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync`, called by the UI *before* it opens
the tile:
- One new task per run in that list, `Idle` + `IsManual=true` (never queued).
- Title/description localized via `missionControl.mergeHelperTaskTitle` /
`mergeHelperTaskDescriptionHeader`.
- `TaskEntity.HandlerBaseCommit` stamped to the list repo's current HEAD.
The host task **never gets a worktree of its own** — the handler commits straight into the
list's working dir and merges the tasks it handles itself. Consequences:
- `SubmitTaskForReview` branches on whether the task has a `WorktreeEntity`: with one, it
commits the worktree; without one, it stamps `HandlerHeadCommit` to the list repo's current
HEAD. Both paths then flip the task `Idle`/`Failed``WaitingForReview`.
- `GetTaskDiff` and the UI's `DetailsIslandViewModel` / `MergeSectionViewModel` fall back to the
`HandlerBaseCommit`..`HandlerHeadCommit` range whenever `Worktree` is null.
### UI flow
`MergeHelperSelectionModalViewModel` — checkbox picker over one list's non-terminal, non-manual
tasks, pre-ticking the actionable ones. **List-scoped only** (`Configure(listId, listName)`, no
global scope). Opened from the list row's context menu, which is hidden when the list has no
working dir.
On confirm: `ListsIslandViewModel` raises `LetClaudeHandleRequested` → shell →
`MissionControlViewModel.OpenMergeHelperConPtySessionAsync`, which calls
`CreateMergeHelperTaskAsync` and then opens a **task-based** tile (deduped by `TaskId` like
`OpenConPtySessionAsync`, **not** `CreateAdHoc`) running the five-phase handler prompt.
## Tile lifecycle
`ConPtyPaneViewModel` resolves its own launch spec — the ctor takes a descriptor **factory**,
the host wires handlers and then calls `Start()`. So the tile appears **immediately** with its
spinner while the worker is still preparing the worktree. A failed launch keeps the tile with an
inline error banner instead of the tile never appearing.
`SubmitForReviewCommand.CanExecute` also gates on `Terminal.IsStarting` / `StartError` /
`HasExited` (not just `IsTaskBased`) — a starting or dead pane can't offer a review it would only
have the worker reject, and `MissionControlViewModel.OnPaneSubmitForReview` sets the pane's
`IsSubmitPending` flag for the duration of the round trip so a rapid double-click can't race two
`SubmitTaskForReviewAsync` calls. A failed launch also offers `RetryCommand` (visible whenever
`HasExited && StartError != null`) — it swaps in a fresh `InteractiveTerminalViewModel` and calls
`Start()` again on the **same** pane/`TaskId` dedupe slot, since `PtyTerminalSession` throws on a
second `StartAsync` call and can't be restarted in place.
### ⚠️ Gotcha: the terminal library kills its child on visual-tree detach
`Iciclecreek.Avalonia.Terminal`'s `TerminalView.OnDetachedFromLogicalTree` calls
`CleanupProcess()` (kills the PTY child) unless `BeginReparent()` suppressed it — and Mission
Control detaches pane views routinely (`RebuildOverviewGrid` recreates everything on any pane
add/remove/column change; focus-mode tab switches re-present content). Two-part defense (since
`aac84e4`):
1. `PtyTerminalSession.StartAsync` puts the control in **permanent reparent mode** right after
`LaunchProcess()``EndReparent` is deliberately never called. Teardown is explicit only:
`ConPtyPaneViewModel.Dispose``Terminal.Kill()` (pane close, VM disposal via DI on exit).
2. `ConPtyPaneHost` (the DataTemplate content for a pane) reparents **one long-lived
`ConPtyPaneView` per pane VM** (`ConditionalWeakTable`, view pins its own `DataContext`)
instead of letting the template instantiate a fresh view — a fresh view would render a dead,
empty terminal because the running session is bound to the original `TerminalControl`.
Hosts only steal the view while `IsEffectivelyVisible`; the layout toggle posts a reclaim
pass (`MissionControlView.ReclaimVisiblePaneHosts`) so the now-visible layout re-steals.
`Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner — used for a starting pane
(`InteractiveTerminalViewModel.IsStarting`) and in place of the refine button while
`TaskRowViewModel.IsRefining`.
### ⚠️ Gotcha: env-var launch race across sessions
`PtyTerminalSession.StartAsync` applies `TerminalLaunchDescriptor.Env` via
`Environment.SetEnvironmentVariable` onto the **whole UI process** (Porta.Pty has no per-launch
env seam — it always inherits the calling process's environment), then calls
`TerminalControl.LaunchProcess()`. Two sessions starting back-to-back (e.g. planning sessions for
two different tasks) could interleave: task B's `SetEnvironmentVariable` calls could land between
task A's env-set and its `LaunchProcess()` fork, so task A's `claude` process inherits B's env
(e.g. `CLAUDEDO_PLANNING_TOKEN`) and fails its own MCP auth. Fixed by serializing the
set-env-then-launch critical section behind a process-wide `static SemaphoreSlim(1,1)` in
`PtyTerminalSession`. Env leakage onto the whole process *after* a launch has forked remains a
documented limitation — only the fork-time race is closed.
### ⚠️ Gotcha: open-path dedupe races
`MissionControlViewModel.OpenConPtySessionAsync` / `OpenPlanningConPtySessionAsync` dedupe by
`TaskId` against `ConPtySessions`, but the check ran before an **awaited** DB title lookup and
only `AddConPtyPane` registers the pane — two rapid invocations for the same task (e.g. a
double-click) could both pass the dedupe check before either pane existed, opening two panes.
`OpenMergeHelperConPtySessionAsync` was worse: it awaits `CreateMergeHelperTaskAsync` (which mints
a brand-new task id every call) *before* any `TaskId` dedupe is even possible, so a double-trigger
always minted two host tasks in the DB.
Fixed with synchronous, pre-await claims: `_pendingTaskOpens` (shared by the two `TaskId`-keyed
open paths) and `_pendingMergeHelperLists` (keyed by `listId`, guarding the whole method since
there's no `TaskId` yet to dedupe on) are `HashSet<string>` fields checked-and-added at method
entry, before any `await`, and released in a `finally`. A second overlapping call for the same key
bails out immediately instead of racing past the collection-based dedupe.
## Focus / key handling
`InteractiveTerminalView` lives in `MissionControlWindow`, so the `FocusClearing` Escape handler
(scoped to `MainWindow` via `AddClassHandler<MainWindow>`) never runs there — **Escape always
reaches the PTY**. See the note in `src/ClaudeDo.Ui/CLAUDE.md`.
`TaskRowViewModel.HasInteractiveSession` shows an accent "Interactive" chip instead of "Parked";
tapping it jumps to that Mission Control pane. `TasksIslandViewModel.SyncInteractiveSessions`
mirrors Mission Control's open panes onto the rows.
### Queueing is gated on an open session (UI-only, since `d84607f`)
A task-based session leaves the row `Idle` (sessions never write `Status`), so nothing on the
worker side distinguishes it from a plain idle task. `TaskRowViewModel.CanSendToQueue` and
`MissionControlViewModel.EnqueueTaskAsync` (drag-to-queue onto the Command Center window) both
check for an open session before queueing — the row via `HasInteractiveSession`, the drag path via
`ConPtySessions.Any(s => s.TaskId == taskId)` (Mission Control's own authoritative pane list,
since the mirrored bool on the row could lag). Queueing a task open in a hand-driven ConPTY pane
would otherwise let the picker spawn an autonomous `claude` process into the same worktree the
user is editing. Both enqueue paths (`TasksIslandViewModel.SendToQueueAsync` and
`MissionControlViewModel.EnqueueTaskAsync`) also route through `IWorkerClient.SetTaskStatusAsync`
(hub `SetTaskStatus``TaskStateService.EnqueueAsync`) instead of a raw EF write, so the
manual/draft-child guards apply on both paths too.
## System-prompt matrix (autonomous vs. interactive)
Autonomous and interactive sessions do **not** share a system prompt. Per start path:
| Start path | Entry point | System prompt |
|---|---|---|
| Autonomous run/continue/retry | `TaskRunner.ResolveConfigAsync``ClaudeArgsBuilder.Build` | `--append-system-prompt <text>`, recomputed and re-sent on **every** invocation including a `--resume` continue (`PromptKind.System` + improvement/list/task overrides) |
| Interactive task session, fresh | `InteractiveLaunchSpecService.BuildForTaskAsync``BuildFreshTaskArgsAsync` | none — no `--append-system-prompt(-file)` at all |
| Interactive task session, resume | `InteractiveLaunchSpecService.BuildForTaskAsync``WindowsTerminalLauncher.BuildResumeArgs` | none — only `--resume <id>` (+ `--effort`) |
| Ad-hoc directory session | `InteractiveLaunchSpecService.BuildForDirectoryAsync` | none |
| Planning session start | `InteractiveLaunchSpecService.BuildPlanningStart``WindowsTerminalLauncher.BuildPlanningStartArgs` | `--append-system-prompt-file <path>` (`PromptKind.Planning`) |
| Planning session resume | `InteractiveLaunchSpecService.BuildPlanningResume``WindowsTerminalLauncher.BuildPlanningResumeArgs` | none — only `--permission-mode default --allowedTools <planning allowlist> --resume <id>` |
| List handler ("Let Claude handle it"), triage | `InteractiveLaunchSpecService.BuildForMergeHelperAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelperTriage` — phases 02 only), always fresh — this path never resumes |
| List handler, post-handoff (wait/wait_final) | `InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelperWait` — phase 3 only), fresh session dir, same handler task id |
| List handler, post-handoff (merge/merge_final) | `InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelperMerge` — phases 45 only), fresh session dir, same handler task id |
So every interactive resume (task session and planning) drops the system prompt entirely — it's
not that they inherit the autonomous one, it's that **no** `claude` process on any resume path
ever passes `--append-system-prompt(-file)`.
### Does `--resume` bring back a prior `--append-system-prompt`? No.
Checked by reading real session transcripts (`~/.claude/projects/<cwd>/<sessionId>.jsonl`) for
several autonomous ClaudeDo task runs, including ones with multiple invocations (initial run +
`ContinueAsync`/retry on the same session id, confirmed via that project's `task_runs` history).
Grepped for the `PromptKind.System` default text ("You are completing one well-defined task
autonomously...") and for any `"type":"system"` entry or `message.role == "system"` anywhere in
those files: the prompt text only ever showed up as ordinary tool-result content (e.g. a task
that happened to read `PromptFiles.cs`'s own source), never as a persisted system/config entry.
No session transcript — autonomous or interactive — carries a system-role message or a
per-session record of the CLI flags it was launched with; there is no sidecar file next to the
`.jsonl` either. The system prompt is purely a per-process request parameter the CLI builds fresh
from that invocation's own flags, never replayed from a resumed session's history. This matches
why `TaskRunner.ContinueAsync` (autonomous) explicitly re-resolves and re-passes
`--append-system-prompt` on every continue instead of relying on `--resume` to carry it —
if inheritance worked, that re-resolution would be redundant.
**Conclusion: no leak.** An interactive resume (task or planning) does not pick up the autonomous
run's `--append-system-prompt` text — including the "commit your work" / `CLAUDEDO_BLOCKED`
instructions from `PromptKind.System`. It simply runs with the `claude` CLI's own baseline system
prompt, same as every other path in this table that passes no system-prompt flag. No code change
needed here.
## Related hub methods
`GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`, `GetMergeHelperLaunchSpec`,
`CreateMergeHelperTask`, `SubmitTaskForReview`.
Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`,
`FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`,
`GetPlanningAggregate`, `BuildPlanningIntegrationBranch`.
+255
View File
@@ -0,0 +1,255 @@
# External MCP tool surface
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `38af549` (2026-08-11).
> Drift check: `git log --oneline 38af549..HEAD -- src/ClaudeDo.Worker/External`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
Covers `src/ClaudeDo.Worker/External/` — the always-on MCP tools ClaudeDo exposes to general
Claude sessions. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`.
Server name `claudedo`, registered globally by the installer's `RegisterMcpStep`, so callers
need no `--mcp-config`.
**Scope boundary:** these tools cover *starting* and *observing* sessions plus task/list CRUD
and git/merge operations. They deliberately do **not** expose multi-turn control, planning
session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key` header.
## Task ID resolution and numbering
Every tool parameter accepting a task id — `taskId`, `parentId`, `taskIds` arrays, and similar —
is wired through `TaskIdResolver`, which resolves:
- `#123` (with or without the `#` prefix) → look up by `TaskEntity.Number`
- Bare GUID string → use as-is
- Unknown number → `InvalidOperationException` (`no task with number 123`)
This is **not** ambiguous: a GUID is never all-digits, so a pure-integer parameter is always a
number, not a partial GUID. All task-returning tools (`GetTask`, `ListTasks`, `AddTask`,
`BatchGetTasks`, etc.) stamp the resolved task's `Number` in the DTO — both `TaskDto` and the
lean `TaskRefDto` carry an `int Number` field. **Branch names and worktree paths** (`claudedo/{id}`)
continue to use the GUID; the number is a display alias, never the identity.
Every tool description carries a shared boilerplate clause (defined in `McpToolDocs.TaskNumberHint`)
instructing the agent to **refer to tasks as `#<number>` when reporting results to the user**
without this the agent sees the number in every payload but never learns to speak it.
## Conventions
### Test-enforced
1. **Every optional/filter parameter needs a C# default value** (e.g. `string? status = null`).
The MCP schema only marks a parameter optional when it has one — nullability alone does
not do it. `ExternalMcpToolSchemaTests` guards this by reflection.
### Not test-enforced, but strongly observed
2. **No tool returns bare `Task` or a nullable payload directly.** An MCP client cannot tell
an empty/omitted response apart from a dropped one.
- *Write* tools return a small confirmation record — `{ ok/deleted/removed/reset/started:
true, <id>, ... }` (`DeleteListResult`, `RunTaskNowResult`, `ResetFailedTaskResult`,
`RemoveAttachmentResult`; `SetListConfigResult` / `SetTaskConfigResult` additionally echo
the resulting config so the caller can see which fields were set vs. cleared to null).
- *Read* tools that may have nothing to return use an explicit `Found` / `Available` flag
alongside the nullable payload (`TaskConfigResult`, `BatchGetTaskResult`, `TaskLogResult`).
- The same flag-alongside-nullable-payload idiom also covers "which of two shapes did you
get": `ListTasks`/`BatchGetTasks` take `includeDescription` (default `false`) and return
`ListTasksResult`/`BatchGetTaskResult`, where exactly one of the lean (`TaskRefDto`) and
full (`TaskDto`, incl. Description/Result) fields is populated per the flag — keeps a
list of verbosely-described tasks from blowing past the response size limit by default.
3. **Description style is documented in `McpToolDocs`** (same folder) and shared boilerplate
lives there as `const` strings. Rules: the first sentence says what the tool does *and* when
to reach for it (MCP clients rank tools by that text, so the trigger must not sit behind
return-shape prose); parameters are documented with `[Description]` **on the parameter**, not
in the tool description; result fields appear only where the caller must branch on them
before calling (`isEmpty`, `truncated`, `conflicts`, `available`); no design rationale or
"since this feature was introduced" history.
4. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so
`InvalidOperationException` / `ArgumentException` messages survive as `McpException` —
otherwise the SDK's catch-all replaces any non-`McpException` with a generic
*"An error occurred invoking 'X'."*
## Tool classes
### `ExternalMcpService` — task CRUD, execution, git
Task: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`,
`UpdateTaskStatus`, `ReviewTask`, `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`.
(`GetTaskStatusValues` was removed — a whole tool entry for static reference text. `GetTask`'s
description is now the canonical place for what each status means.)
Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`,
`PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, `CleanupTaskWorktree`.
Daily prep: `GetDailyPrepCandidates`, `SetMyDay`.
### Other classes
| Class | Tools |
|---|---|
| `BatchMcpTools` | `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees` |
| `ListMcpTools` | `CreateList`, `UpdateList`, `DeleteList` |
| `ConfigMcpTools` | `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`, `GetEffectiveRunConfig` |
| `RunHistoryMcpTools` | `ListRuns`, `GetRun`, `GetTaskLog` |
| `AgentMcpTools` | `ListAgents` |
| `LifecycleMcpTools` | `ResetFailedTask` |
| `AppSettingsMcpTools` | `GetAppSettings` (read-only) |
| `TaskWaitMcpTools` | `WaitForTaskChange` |
| `QueueStateMcpTools` | `GetQueueState` |
| `AttachmentMcpTools` | `AddTaskAttachment`, `ListTaskAttachments`, `RemoveTaskAttachment` |
## Per-tool behaviour worth knowing
**`ListTasks`** — `includeDescription=false` (default) returns lean `TaskRefDto` references in
`tasks` (`tasksFull` null); `includeDescription=true` returns full `TaskDto`s (incl.
Description/Result) in `tasksFull` instead (`tasks` null). Filtering by `createdBy`/`status`
happens before the lean/full projection either way.
**`UpdateTaskStatus`** accepts `Idle` / `Queued` / `Cancelled` / `Done` only.
- `Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)` — the
**only** caller that opts into cancelling from `Idle`. `PlanningChainCoordinator` relies on
`Idle` staying a no-op there by default, because a child parked back to `Idle` mid-chain is
a manual opt-out signal.
- `Done` goes through `TaskStateService.ForceSetStatusAsync` (the same unconditional write the
UI's "set status freely" affordance uses) but is **refused** for a task with an active
worktree, since that would skip `review_task`'s merge.
**`AddTask`** — always creates the task; also returns `possibleDuplicates` (up to 3, id/title/status
only, no descriptions) — open (non-terminal) tasks in the *same list* whose normalized title
overlaps strongly with the new one. Cheap word-overlap heuristic (`ExternalMcpService`'s
`FindPossibleDuplicatesAsync`/`NormalizeTitleWords`), no embeddings/LLM call, no blocking —
the caller just gets a heads-up to relay. `BatchAddTasks` carries the same field per item.
**`ReviewTask`** — `approve` / `reject_rerun` / `reject_park` / `cancel` for a
`WaitingForReview` task. Approve is review+merge exactly like the hub's `ApproveReview`: unit
merge for parents, worktree merge into optional `targetBranch` for childless tasks. Conflicts
are reported in `ReviewTaskResult`.
- A parent's approve also returns `emptyChildren`: the `Done` children about to be unit-merged
whose own review range contributed nothing (computed the same way as `PreviewMerge`'s
`isEmpty`, before the merge starts so it reflects what's about to be approved). Surfaces a
child that reported `CLAUDEDO_BLOCKED` and committed no code — previously that child reached
`Done` and merged silently with `changedFileCount: 0`, indistinguishable from a small-but-real
change. `TaskRefDto.roadblockCount` (on every task-returning tool, stamped by `TaskRunner` from
`result.Blocks.Count`) is the MCP-visible signal for *why* a child is empty.
**`PreviewMerge`** — non-destructive `git merge-tree --write-tree` mergeability check for one
task's worktree branch against `targetBranch` (default: the repo's current branch). Returns
status / conflictFiles / changedFileCount / `behind` / `isEmpty`. Unlike
`TaskMergeService.PreviewAsync`'s silent *"unavailable"*, this **throws a clear error** when the
task has neither an active worktree nor a handler commit range, or the list's working dir is
missing.
- `isEmpty` = the review range contributed nothing — zero files changed against the worktree's
base commit, or (for a worktree-less list-handler host task) `HandlerBaseCommit ==
HandlerHeadCommit`. Distinguishes a genuinely empty branch from one that merely made a small
change (`changedFileCount: 0` alone reads as "tiny", not "nothing to review") — the gap that
let two blocked planning children reach `Done` with unmerged empty branches unnoticed.
- A worktree-less handler task has no separate branch to `merge-tree`-preview (its commits
already sit in `list.WorkingDir`) — `PreviewMergeCoreAsync` falls back to a synthetic `clean`
preview over its own `HandlerBaseCommit..HandlerHeadCommit` diff-stat instead of throwing
"has no worktree".
**`PreviewMergeSet`** — same preview for a batch (each entry also carries `isEmpty`), plus a
file→tasks overlap report built from each task's own diff-stat. ⚠️ That overlap report is a
**same-file-name hint only** — it is blind to cross-file collisions (e.g. the CS0103 case that
motivated it). A task that fails to preview gets `error` set and is excluded from the overlap
instead of aborting the batch.
**`RevertMerge`** — undoes a previously merged task's merge commit on `targetBranch` via
`git revert -m 1`. Always a **new commit**, never a reset/rewrite, because the target working
directory is shared with other concurrent sessions.
- Requires the task to be `Done` with a `Merged` worktree carrying a recorded
`WorktreeEntity.MergeCommit`. A task merged before that field existed has none and is
**refused rather than guessed** via `git log`.
- On success the task returns to `WaitingForReview` and the worktree moves to `Kept` — not
`Active` (its directory/branch are typically already gone from the original merge's cleanup)
and not `Merged`/`Discarded` (`WorktreeMaintenanceService` sweeps those).
- A conflicting revert is aborted immediately; no half-resolved state is left in the tree.
**`BatchMcpTools`** — best-effort loops over the `ExternalMcpService` single-entity methods.
**Sequential**, because the scoped `DbContext` is not thread-safe. Merge/review stay
single-task. Every tool returns a per-item result array (`{ id/index, ok, error?, … }`) — a
failing item never aborts the rest — and rejects batches over **100 items**.
`BatchGetTasks` mirrors `ListTasks`'s `includeDescription` flag (default `false`): a found item's
`BatchGetTaskResult` carries `task` (lean `TaskRefDto`) or `taskFull`, never both. `taskFull` is
`BatchTaskDetailDto` — a batch-only shape, **not** `TaskDto` (`get_task` is untouched) — whose
Description/Result are cut to `descriptionMaxChars` (default 1500, was unlimited) with
`*Truncated`/`*FullLength` flagging it, and which `fields` (an optional name allow-list, e.g.
`['title','roadblockText']`) can narrow further; unrequested fields come back `null`.
`roadblockText` pulls just the bullet lines after `TaskRunner.ComposeReviewResult`'s roadblock
marker out of `Result`, without needing the rest of it. The whole per-call response is also
capped (`BatchMcpTools.MaxResponseChars`) — over that, the call throws naming which parameter to
adjust instead of shipping an oversized payload (the incident that prompted this: 8 tasks'
full Description/Result serialized to a single 51k-char line).
**`GetTaskLog`** — latest run's log, tail-capped at 256 KB.
**`WaitForTaskChange(taskIds, timeoutSeconds = 60, treatWaitingForChildrenAsBusy = false)`** —
blocks until any given task leaves `Queued`/`Running`, or times out. Returns immediately for a
task already outside those two (unknown ids reported as status `"NotFound"`, also immediate).
- Implemented as an **async DB poll** (short-lived `DbContext` per check, 500 ms delay, no
held connection, no busy loop) rather than hooking `HubBroadcaster` — deliberately isolated
so it can't regress the existing broadcast callers.
- `timeoutSeconds` is clamped server-side to `TaskWaitMcpTools.MaxTimeoutSeconds` (900 s),
comfortably under the `MCP_TOOL_TIMEOUT` (930 s) every ClaudeDo-owned claude launcher sets —
`ClaudeProcess` for headless queue runs, `InteractiveLaunchSpecService` for every embedded
ConPTY session (list handler, planning, interactive resume) — so the tool reports
`timedOut: true` instead of racing the client's own abort. A caller running claude with a
different (or default: 60 s) `MCP_TOOL_TIMEOUT` will still see its own client-side timeout
fire first; the server has no way to detect or compensate for that.
- **`treatWaitingForChildrenAsBusy` pitfall (default `false`, backward-compatible):** a planning
parent with children goes `Running` → `WaitingForChildren` while children are still working,
and by default that already counts as "changed" (it's outside `Queued`/`Running`) — so waiting
on a parent returns immediately even though the unit isn't done. Set the flag to keep polling
through `WaitingForChildren`; the call then only reports changed once the parent reaches
`WaitingForReview` or a terminal status. Does not list or watch the parent's children —
callers still need their own ids for that.
- Replaced the list handler's old "sleep + poll `get_task` in a loop" Phase 3 instruction.
**`GetQueueState()`** — read-only snapshot so a caller doesn't have to infer queue state from
`maxParallelExecutions` or repeated `wait_for_task_change` rounds:
`{ configuredSlots, effectiveSlots, activeSlots: [{ slot, taskId, startedAt }], waitingTaskIds }`.
- `configuredSlots`/`effectiveSlots` reuse `QueueService.GetSlotCountsAsync` — the same
configured-vs-throttled computation `QueueService.ExecuteAsync` uses each tick (see
[usage-monitoring](usage-monitoring.md) for the throttle staging) — so this tool can't drift
from the queue's actual refill decision.
- `activeSlots` reuses `QueueService.GetActive()` (already the source for the Hub's `GetActive`):
`slot` is `"queue"` for a normal queue slot or `"override"` for the single
`run_task_now`/`continue_task` slot.
- `waitingTaskIds` is a fresh read-only query mirroring `QueuePicker.ClaimNextAsync`'s
eligibility filter and order (`Queued`, unblocked, non-manual, due, `sort_order` then
`created_at`) — it does not claim or mutate anything.
**`AttachmentMcpTools`** — re-attaching the same `fileName` overwrites. Add/remove refuse on a
`Running` task.
**`GetDailyPrepCandidates`** — Idle, non-blocked tasks in a git repo **not** excluded by
`AppSettings.ReportExcludedPaths` and not already `IsMyDay`, plus the current Idle MyDay tasks
and `maxTasks` (= `DailyPrepMaxTasks`). Repo-exclusion logic lives in the `DailyPrepFilter`
helper in the same file.
**`SetMyDay`** — sets `IsMyDay` (+ optional `SortOrder`). A server-side cap-guard rejects
turning on MyDay beyond `DailyPrepMaxTasks` open (Idle) MyDay tasks.
**`GetEffectiveRunConfig`** — read-only report of what a task will *actually* run with (model,
max turns, effort, permission mode, agent path, whether a system prompt is set, skill names),
each with its source (`task`/`list`/`preset`/`global`); max turns additionally reports the raw
requested value and whether it was clamped to `AppSettings.MaxTurnsCeiling`. Unlike
`GetAppSettings`/`GetTaskConfig` (raw, possibly-unused config values), this goes through the same
`EffectiveRunConfigResolver.Resolve` that `TaskRunner` itself runs with — see
[worker-task-pipeline](./worker-task-pipeline.md)'s model/effort/max-turns section — so it can't
drift from the real run. Reads (not writes) `AppSettingsRepository.GetAsync`, which backfills
`model_presets` on first read after a null column; that backfill is pre-existing shared behavior,
not a new side effect introduced by this tool.
## Model / max-turns on task creation
Task-generating tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an
optional `model`, alias-validated via `ModelRegistry.NormalizeAlias` (`haiku`/`sonnet`/`opus`,
blank = inherit), so Claude can assign the cheapest capable model at creation time — the
planning/system/improvement prompts instruct it to do so, using
`ModelRegistry.ByCostAscending` as the cost order.
Planning's `CreateChildTask` **additionally** accepts an optional `maxTurns` (positive int;
`0`/negative rejected with `ArgumentException`, null = inherit list/global default) so the
planner can raise the turn budget for a subtask it knows will run long.
`SuggestImprovement` and `AddTask` do **not** expose it.
+252
View File
@@ -0,0 +1,252 @@
# Installer preflight: CLI version gate & environment checks
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `bdee731` (2026-08-05).
> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs src/ClaudeDo.Worker/ClaudeDo.Worker.csproj src/ClaudeDo.App/ClaudeDo.App.csproj .gitea/workflows/release.yml src/ClaudeDo.Installer/Checks src/ClaudeDo.Data/Environment/ExecutableResolver.cs`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
## Implementation status (as of 2026-08-05)
The research below (§15) led to an implementation, but it is **not on `main` yet** — it exists
on two unmerged task branches:
- `claudedo/06aca9b3afec4b939f59b627bfe21737``src/ClaudeDo.Installer/Checks/*`
(`GitCheck`, `GitIdentityCheck`, `WriteAccessCheck`, `PortCheck`, `ClaudeCliCheck`,
`ClaudeVersionCheck`, `ClaudeAuthCheck`, `PermissionModeAutoCheck`, `EnvironmentCheckService`,
`ClaudeCliLookup`) plus `SystemCheckPage` (the Fresh-Install wizard page hosting them) and its
own copy of `src/ClaudeDo.Data/Environment/ExecutableResolver.cs`.
- `claudedo/40272c0bb3b14562b59c022d09c382b6` — the original `ExecutableResolver.cs`, plus wiring
it into `ClaudeDo.Worker`'s `ClaudeCliPreflight` and `ClaudeProcess` (the actual root-cause fix:
both used to spawn `claude` with `UseShellExecute = false` and no `.cmd`/`.bat` shim resolution,
so an npm-installed `claude.cmd` was invisible to the Worker even though it worked in a shell).
The two branches were authored independently and each vendored its own copy of
`ExecutableResolver.cs` (identical except `06aca9b3` adds a `FallbackDirectories()` diagnostic
helper); merging both cleanly requires picking one copy, not literally running `git merge` twice.
Two planned follow-up tasks — a "Claude Help Me" button and a Config-mode Diagnose section —
never got past a blocked first step, precisely because this prerequisite work wasn't on `main`
when they ran. See `Environment Checks` in `src/ClaudeDo.Installer/CLAUDE.md` and `docs/open.md`
for the current gap and the manual verification checklist.
What's confirmed as **matching the research below**: `ClaudeVersionCheck.MinimumVersion` is
`2.1.220`, exactly the "verified-floor, not a proven minimum" constant from §3. `ClaudeAuthCheck`
uses `claude auth status --json` exactly as recommended in §4, parsing only the `loggedIn` field.
`PermissionModeAutoCheck` is the static "is `auto` listed in `--help`" check recommended in §2 —
real org/model/plan eligibility is deliberately **not** checked, matching the recommendation not
to build that (a real task run surfaces a startup rejection fast enough on its own). No .NET
Desktop Runtime check was implemented as an `IEnvironmentCheck` (§5's registry-key detection
remains a documented-but-unbuilt option, not currently gating anything).
---
Pure research, no code changed. Answers the five questions from the "Root Cause
`--permission-mode auto`" task. Sources: the locally installed CLI (`claude --version` /
`--help`), the official docs at `code.claude.com` (fetched 2026-08-05), and this repo's own
csproj/workflow files.
## 1. Why can `--permission-mode auto` fail?
**It is very rarely a CLI-version problem.** Per the official permission-modes doc
(`code.claude.com/docs/en/permission-modes#eliminate-prompts-with-auto-mode`), auto mode
requires **all** of:
- **Plan**: any plan (Free/Pro/Max/Team/Enterprise) qualifies in principle.
- **Organization**: on Team/Enterprise, on by default; an admin can disable it org-wide via
`permissions.disableAutoMode: "disable"` in managed settings. When disabled this way, the CLI
**"rejects `--permission-mode auto` at startup"** (verbatim from the doc) — not a runtime
fallback, a hard reject.
- **Model**: on the Anthropic API / Claude Platform on AWS — Opus 4.6+, Sonnet 4.6+, or Fable 5.
On Bedrock / Vertex / Foundry / signed-in gateway sessions — only Sonnet 5, Opus 4.7+, Fable 5.
Older models (Sonnet 4.5, Opus 4.5, Haiku, claude-3-*) are **not supported on any provider**.
A per-org `availableModels` restriction that only allows an old model would silently make
auto mode unavailable even on a Team/Enterprise org that hasn't touched `disableAutoMode`.
- **Provider opt-in (historical)**: on Bedrock/Vertex/Foundry/gateway, CLI **v2.1.158v2.1.206**
required `CLAUDE_CODE_ENABLE_AUTO_MODE=1`; **v2.1.207** removed that requirement. This does
**not** apply to a normal claude.ai / Console (Anthropic API) login — only to those four
provider types.
A separate, easy-to-hit trap: `defaultMode: "auto"` in **project or local** settings
(`.claude/settings.json`, `.claude/settings.local.json`) is silently **ignored** since v2.1.142
— it must live in `~/.claude/settings.json` (user scope). A repo that ships `defaultMode: auto`
in its own `.claude/settings.json` will start in Manual mode with **no error at all**. This
doesn't apply to ClaudeDo's case (it passes `--permission-mode auto` as an explicit CLI flag,
not via a checked-in settings file), but is worth knowing if the failure mode was "silently
starts in Manual" rather than "CLI errors out".
Quoted from the docs, verbatim: *"If Claude Code reports auto mode as unavailable, one of these
requirements is unmet; this is not a transient outage."*
**Not verifiable**: which of the above actually hit the colleague — we have no diagnostic from
their machine (no `claude auth status --json` output, no `claude --version`, no org name). Do
not guess; if this recurs, capture `claude auth status --json` and `claude --version` from the
affected machine before further debugging.
## 2. How to reliably detect whether `auto` is supported
**Static, cheap, always safe (no API call):**
- `claude --version` — semver string, e.g. `2.1.220 (Claude Code)`.
- `claude --help` — the `--permission-mode <mode>` line lists the accepted enum. Confirmed by
testing an invalid value locally:
```
$ claude -p "test" --permission-mode bogus
error: option '--permission-mode <mode>' argument 'bogus' is invalid. Allowed choices are
acceptEdits, auto, bypassPermissions, manual, dontAsk, plan.
```
This is validated by the CLI's arg parser (Commander.js) **before any network call** — exits
1 immediately. So checking that `auto` is one of the listed choices is a legitimate, free,
fast static check — but it only proves the *flag* is recognized, **not** that auto mode is
actually usable (org/model/plan gating happens later, at session start, not at arg-parse time).
- `claude auth status --json` — cheap, local/fast, **does not send a prompt**. Returns:
```json
{ "loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty",
"email": "...", "orgId": "...", "orgName": "...", "subscriptionType": "team" }
```
This is the answer to question 4 (see below) and also gives `subscriptionType`/`orgName` —
useful context but **still not a direct "is auto mode eligible" answer** (doesn't report the
active model or `disableAutoMode` policy).
**No cheap subcommand exists for full eligibility.** Checked `claude auto-mode --help`: it only
has `config` (effective auto-mode *rule* config — allow/deny lists, not eligibility),
`defaults` (same, shipped defaults), `critique`, and `reset`. None report plan/model/org
eligibility. `claude doctor` (non-interactive) does **not** report auto-mode eligibility either
— it explicitly says *"For a full setup checkup that can also fix issues, run `/doctor` in a
session"*; the in-session `/doctor` slash command is the one the docs say proposes
`defaultMode: auto` when eligible, but that requires an interactive session, not a scriptable
preflight.
**Dynamic (real probe) is the only way to fully confirm eligibility**, and per the docs an
org-disabled or otherwise-ineligible account **rejects at startup** (fast, before any model
turn) — so a probe doesn't have to be a full expensive run. A minimal probe such as
`claude -p "ok" --permission-mode auto --max-turns 1 --output-format json` would fail fast on
ineligibility (reject at startup) but still costs one real turn + tokens on the success path,
and still requires a working prompt/response round trip on the happy path. **Recommendation**:
don't build this into an automated preflight; a static version+flag check plus `auth status`
covers the reliably-detectable ground, and a real first task run will surface an auto-mode
rejection immediately and cheaply (fails at startup, not mid-task) if it's actually unavailable.
**Implemented as:** `PermissionModeAutoCheck` (Warning) — the static flag-listed check only.
## 3. Minimum CLI version for the flags ClaudeDo uses
Flags used (from `ClaudeArgsBuilder` per `src/ClaudeDo.Worker/CLAUDE.md`): `--permission-mode
auto`, `--effort`, `--agents`, `--json-schema`, `--append-system-prompt`, `--output-format
stream-json --verbose`, `--resume`, and (installer) `claude mcp add --transport http --scope
user`.
**Not verifiable precisely.** All eight of these are foundational, long-established flags.
The official changelog (`raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md`)
only retains roughly the last ~40 entries (oldest visible: `2.1.181`); auto mode itself, and
`--json-schema`/`--resume`/`mcp add --transport` all clearly predate that window (the earliest
found reference is a *bug fix* mentioning "sessions created before v2.1.85" for `--resume`,
implying `--resume` existed well before 2.1.85). There is no accessible source that pins an
"introduced in vX" date for any of these eight flags — guessing one would violate the task's
explicit instruction not to invent a source-less root cause.
What **is** sourced, from the docs fetched 2026-08-05:
- `--json-schema` + invalid-schema handling: before v2.1.205, an invalid schema was silently
ignored (returned unstructured text); v2.1.205 made it a hard `Error: --json-schema is not a
valid JSON Schema` exit. Not a "does it exist" gate, but changes error-handling behavior
ClaudeDo might currently rely on failing loudly.
- `--output-format stream-json --verbose` + `system/init.capabilities` array requires v2.1.205+
(absent before). Not currently consumed by ClaudeDo per the Worker CLAUDE.md's stream handling
description, so not a hard requirement today.
- `system/init.mcp_server_errors` field requires v2.1.219+. Not currently consumed by ClaudeDo.
- Manual-mode label/`manual` alias requires v2.1.200+ — irrelevant, ClaudeDo passes `auto`
explicitly, never `manual`.
- Auto mode's Bedrock/Vertex/Foundry/gateway opt-in-env-var requirement was removed in v2.1.207
— irrelevant for a direct claude.ai/Console login (this machine: `apiProvider: "firstParty"`).
**Decided constant** (see below) is therefore **the newest version we can positively confirm
works end-to-end on this machine** (`2.1.220`), not a proven theoretical minimum — because no
lower true minimum is derivable from available sources without guessing.
**Implemented as:** `ClaudeVersionCheck.MinimumVersion = new Version(2, 1, 220)` (Error).
## 4. Detecting "CLI is logged in" without sending a prompt
`claude auth status --json` (confirmed working, instant, no API/model call):
```json
{ "loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty",
"email": "...", "orgId": "...", "orgName": "...", "subscriptionType": "team" }
```
This is the CLI's own maintained answer — cheaper and more robust than parsing
`.credentials.json` directly (format may be internal/undocumented and is a hard-blocked file
for this task's own tooling; the docs page confirms it lives at
`%USERPROFILE%\.claude\.credentials.json` on Windows but say nothing about its schema being a
stable public contract). `claude auth status --text` is available for a human-readable variant;
`--json` is the default and the right one for a preflight to parse.
**Implemented as:** `ClaudeAuthCheck` (Error) — parses only the `loggedIn` boolean; any other
field, or a non-zero exit code, or unparseable JSON, becomes `Unknown` rather than `Failed`.
## 5. .NET runtimes required by the published `app\` / `worker\` artifacts
From `.gitea/workflows/release.yml` (the only build/publish pipeline in this repo) and the two
csproj files:
- **`ClaudeDo.App`** (`net8.0`, Avalonia, `WinExe`) — published via
`dotnet publish ... -r win-x64 --self-contained true`. **Self-contained**: bundles its own
.NET 8 runtime. **No .NET runtime needs to be pre-installed** on the target machine for the
app itself.
- **`ClaudeDo.Worker`** (`net8.0`, `Microsoft.NET.Sdk.Web`, ASP.NET Core) — same treatment:
`-r win-x64 --self-contained true`. Also fully self-contained; no ASP.NET Core runtime needs
to be pre-installed.
- **`ClaudeDo.Installer`** (`net8.0-windows`, WPF) is the one exception: published
`--self-contained false -p:PublishSingleFile=true`**framework-dependent**. The csproj
comment explains why: *"the WPF runtime pack isn't distributed for cross-compile on Linux CI,
which made self-contained bundles crash on startup with AV in the apphost."* The target
machine **must** have the **.NET 8 Desktop Runtime (x64)** installed before running
`ClaudeDo.Installer.exe` — this is the actual runtime-preflight gap, not the app/worker.
**How to check on a target machine**:
- `dotnet --list-runtimes` — look for a `Microsoft.WindowsDesktop.App 8.0.x` line (Desktop
Runtime, required by the installer). Requires the `dotnet` CLI itself to be on PATH; not
guaranteed present on a fresh machine that never installed the SDK, only the runtime — in
that case `dotnet` may not exist at all even though the runtime DLLs do.
- Registry fallback (works even without the `dotnet` CLI on PATH):
`HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App`
each installed version is a subkey/value here. This is the standard documented detection
mechanism for .NET Desktop Runtime presence on Windows and is what most installer-detection
tooling (e.g. Squirrel, WiX bundles) uses instead of shelling out to `dotnet`.
- **Not independently verified in this task**: the exact registry key shape wasn't inspected
live (would require reading `HKLM\SOFTWARE\dotnet\...` on this machine, which is standard
.NET installer-detection convention, but out of scope to screenshot/dump here since the task
is docs-only and this is a well-documented, non-project-specific Windows convention).
**Not implemented** as an `IEnvironmentCheck` — none of the shipped checks verify the Desktop
Runtime; if the Installer itself is running at all, .NET 8 Desktop Runtime is implicitly
present (framework-dependent publish would otherwise fail to launch).
## Beschlossene Konstanten
| Constant | Value | Confidence |
|---|---|---|
| Minimum CLI version | `2.1.220` | **Verified-floor, not a proven minimum.** This is the newest version confirmed installed and working end-to-end on a dev machine for every flag ClaudeDo uses. No lower true minimum could be sourced (see §3) — treat any lower value as a guess. |
| Credentials file path | `%USERPROFILE%\.claude\.credentials.json` (Windows) | Sourced from official docs; content/schema not inspected (hard-blocked secrets file). |
| Login-check command | `claude auth status --json` | Verified locally, instant, no model call. Fields: `loggedIn`, `authMethod`, `apiProvider`, `email`, `orgId`, `orgName`, `subscriptionType`. |
| App/Worker runtime requirement | **None** — self-contained win-x64 publish | Sourced from `.gitea/workflows/release.yml`. |
| Installer runtime requirement | **.NET 8 Desktop Runtime (x64)** must be pre-installed | Sourced from `.gitea/workflows/release.yml` comment + `ClaudeDo.Installer.csproj`. |
## Erkennungsstrategie pro Check
| Check | Type | Command | Expected pass output | Implemented as |
|---|---|---|---|---|
| git present + version | static | `git --version` (via `ExecutableResolver`) | resolves, exit 0 | `GitCheck` (Error) |
| git identity set | static | `git config --get user.name` / `user.email` | both non-empty | `GitIdentityCheck` (Warning) |
| install dir + data dir writable | static | probe-file write/delete | succeeds | `WriteAccessCheck` (Error) |
| SignalR/ExternalMcp ports free | static | `TcpListener` bind probe + owning-process lookup | free, or owned by running `ClaudeDo.Worker` | `PortCheck` (Warning) |
| CLI present + version | static | `claude --version` | `X.Y.Z (Claude Code)`; parse and compare `X.Y.Z >= 2.1.220` | `ClaudeCliCheck` (Error) / `ClaudeVersionCheck` (Error) |
| `auto` recognized as a flag value | static | `claude --help` (or trigger the parse error path) | `--permission-mode <mode>` help text lists `auto` among the choices | `PermissionModeAutoCheck` (Warning) |
| CLI logged in | static/cheap | `claude auth status --json` | exit 0, `loggedIn: true` | `ClaudeAuthCheck` (Error) |
| Auto mode actually eligible (org/model/plan) | **not statically detectable** | none exists | N/A — see §2; don't build this, let a real task run surface a fast startup rejection instead | not implemented (by design) |
| .NET Desktop Runtime present (installer only) | static | `dotnet --list-runtimes` (if `dotnet` on PATH) or registry `HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App` | a `Microsoft.WindowsDesktop.App 8.0.x` entry exists | not implemented (see §5) |
| App/Worker runtime present | **not needed** | — | self-contained, nothing to check | not implemented (not needed) |
## Not verifiable (explicit)
- The actual root cause on the colleague's machine — no diagnostic data was captured from it.
- Exact stderr/exit-code wording the CLI prints when auto mode is rejected at startup for an
ineligible org/model (docs state the *behavior* — "rejects `--permission-mode auto` at
startup" — but not the literal message; this machine's account is eligible, so it couldn't be
reproduced locally).
- A true (not just "newest confirmed") minimum SemVer for `--effort`, `--agents`,
`--json-schema`, `--append-system-prompt`, `--resume`, `claude mcp add --transport http
--scope user` — all predate the retrievable changelog window.
- Live inspection of the `HKLM\SOFTWARE\dotnet\Setup\InstalledVersions` registry shape on this
machine (documented Windows convention, not independently screenshotted here).
@@ -0,0 +1,114 @@
# List virtualization spike (Phase 2a gate)
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `6a2a19c` (2026-08-10).
> Drift check: `git log --oneline 6a2a19c..HEAD -- src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs src/ClaudeDo.Ui/Views/Controls/TaskDragController.cs`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
Gate task for [ui-reaktivitaet-und-listen-performance-design](../superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md)
Phase 2, Risk 1. Answers the five open questions before any rework of the real
`TasksIslandView`/`TasksIslandViewModel`. No production code changed for this spike.
## Method
A throwaway Avalonia-headless xUnit test (`Avalonia.Headless` + `Avalonia.Themes.Fluent`,
temporarily added to `ClaudeDo.Ui.Tests`, removed again after this report was written — not
committed) built a plain `ListBox` with an explicit `VirtualizingStackPanel` and 1000 mixed
items (≈14% "complex" two-line/badge rows, mirroring the 68px/90110px split from the design
doc), then drove it programmatically: forced layout/render passes, scrolled the `ScrollViewer`,
called the same `TopLevel.InputHitTest` the production drag code uses, and counted realized
`ListBoxItem` containers via the visual tree. No screenshots — every number below came from an
actual headless run of that harness on 2026-08-10.
One harness-only wrinkle: `InputHitTest` walks the **compositor's rendered scene**, not just the
layout tree, so the test had to call `window.CaptureRenderedFrame()` after each layout-affecting
change before hit-testing. A real windowed app renders every frame continuously, so this isn't a
production concern — noted here only so the method is reproducible.
## Q1 — Does a virtualized `ListBox` actually bound the number of realized containers at 1000 items?
**Geht.** Realized `ListBoxItem` count stayed at 2223 across the whole scroll range (top, 25%,
50%, 75%, bottom) with 1000 source items in the list:
```
realized containers @ top: 22
realized containers @ 25% scroll: 22
realized containers @ 50% scroll: 22
realized containers @ 75% scroll: 23
realized containers @ 100% scroll: 22
```
Matches the design doc's estimate (~19 visible + overscan ≈ 2225) almost exactly. The
`ListBox`/`VirtualizingStackPanel` combination genuinely recycles containers instead of building
one per item.
## Q2 — Does the existing ghost-drag `InputHitTest` model survive container recycling?
**Geht.** Scrolled from top to 60% (forcing recycling), then called `TopLevel.InputHitTest` at the
*same screen point* both times — exactly what `RowButtonAt`/`ListItemUnder` in
`TasksIslandView.axaml.cs` do live during a drag:
```
hit-test @ top, screen point (450,600): Task[8]
hit-test @ 60% scroll, same screen point: Task[577]
expected DataContext at that point (geometry): Task[577]
```
The hit-test result matches the geometrically-correct row after recycling, and correctly changed
(it did *not* keep returning the stale top-of-list row). The production pattern —
`topLevel.InputHitTest(pt)` then walk ancestors to the row `Button`/`ListBoxItem` — is
recycling-safe: it's a live geometric query against whatever is currently realized, with no
per-row state to go stale.
## Q3 — Variable row heights (68px vs 90110px): does the scrollbar/`Extent` estimate drift?
**Geht, mit einer Einschränkung.** Measured actual template heights in this harness: header
57px, simple task row 53px, complex (two-line + badges) row 90px. Hand-summing all 1000 rows by
their real measured height gives 58275px; `VirtualizingStackPanel`'s reported `Extent.Height` was
58228px — **0.1% error**. Avalonia's own extent bookkeeping for variable-height virtualized rows
is not the "gross estimate that drifts" risk the design doc worried about.
The real risk is a **hand-rolled** pixel↔index conversion. Scrolling to `index * 68px` (a naive
"assume every row is 68px" offset) while targeting logical row #700 actually landed on row
**#790** — a 90-row drift, because ~14% of the preceding rows were taller. Anything that
computes a scroll offset from `index * fixedRowHeight` (a natural shortcut for a "scroll to
selected row" or a custom auto-scroll speed curve) will misfire. The existing
`ScrollSelectedIntoView` in `TasksIslandView.axaml.cs` already avoids this trap — it finds the
realized control by `DataContext` and calls `BringIntoView()`, not index-times-height math. Phase
2b must keep using `ListBox.ScrollIntoView(item)` / `Control.BringIntoView()`, never index
arithmetic, for any "jump to row" behavior.
## Q4 — Is auto-scroll-while-dragging feasible with the existing central-pointer-handler model?
**Geht.** Auto-scroll doesn't exist today and has to be built, but the mechanism it needs — the
same UI-thread pointer-moved handler (or a `DispatcherTimer` it starts) mutating
`ScrollViewer.Offset` directly while a drag is in progress — was exercised directly: nudging
`Offset` by 400px in one synchronous step kept the realized-container count constant (22 before,
22 after) and an immediate subsequent `InputHitTest` at the same screen point resolved correctly
to the new row under the cursor (`Task[388]`), with no async delay or stale state. Since
`TasksIslandView`'s drag handlers already run on the UI thread via tunnelled pointer events, a
`DispatcherTimer` (or a direct `Offset` nudge inside `OnPointerMovedDrag` when the cursor is near
the list's top/bottom edge) has no threading obstacle. This still needs to be built in Phase 2b;
this spike only confirms the approach isn't blocked by virtualization/recycling.
## Q5 — Mixed item types (header rows vs. task rows) via a template selector, under virtualization
**Geht.** Used `ListBox.DataTemplates` with two `FuncDataTemplate<T>` entries keyed by type
(`HeaderRow` / `TaskRow`) instead of an explicit `IDataTemplate.Match` selector — Avalonia's
implicit per-type template lookup is the idiomatic equivalent and needs no selector class. Both
types showed up among the realized containers throughout scrolling
(`Q5 distinct realized DataContext types: HeaderRow, TaskRow`), confirming the design's flat
`Rows` (`HeaderRow | TaskRowViewModel`) collection will virtualize and template correctly.
## Recommendation for Phase 2b
**Geht — proceed**, with two carry-overs into the implementation:
1. Any "scroll to row" logic (selection scroll, auto-scroll speed/targeting) must go through
`ScrollIntoView`/`BringIntoView` against the realized control, never `index * rowHeight` math
(Q3).
2. Auto-scroll is new work, not a retrofit risk — build it as an `Offset` nudge from the existing
UI-thread drag handlers (Q4).
No evidence surfaced against the Phase 2 design as written. The `ITaskListFilter` risk (#3 in the
design doc) is unrelated to virtualization and out of this spike's scope.
+280
View File
@@ -0,0 +1,280 @@
# Review, merge & conflict resolution
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against `fc9df7f` (2026-08-11), which added the conventional merge-commit default
> and the `MergeProgress` broadcast.
> Planning mode renders per file and `DiffLinesView` is retired.
> Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/Lifecycle src/ClaudeDo.Worker/State src/ClaudeDo.Worker/Planning src/ClaudeDo.Ui/ViewModels/Conflicts src/ClaudeDo.Worker/External`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
Covers the review→merge path: `TaskStateService` review transitions, `TaskMergeService`,
`PlanningMergeOrchestrator`, the post-merge verify gate, and the UI conflict resolver.
## Approve = merge the whole unit
`ApproveReview` (hub) and `review_task` approve (MCP) are the **single** review+merge action.
There is no separate "Merge all" entry.
- **Task with children** → drives `PlanningMergeOrchestrator`: merges the parent worktree if
`Active`, then each `Done` child in order, then sets the parent `Done`. A mid-merge conflict
pauses for `ContinuePlanningMerge` / `AbortPlanningMerge`.
- **Childless task** → `TaskMergeService.ApproveAndMergeAsync`. A conflict keeps the task in
`WaitingForReview`.
- **No active worktree** (sandbox run) → straight to `Done`.
Review transitions all live in `TaskStateService`: `SubmitForReviewAsync`,
`SubmitForChildrenAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`,
`ClearReviewFeedbackAsync`.
`ReviewFeedback` (nullable string on `TaskEntity`) is the reviewer's rejection comment: set by
`RejectToQueueAsync`, consumed and cleared by `QueueService` on the next re-run, where it
becomes the next-turn prompt of the resumed Claude session.
## Unified parent model
Every parent — planning **or** improvement — flows
`… → WaitingForChildren → WaitingForReview → Done`, advanced by the single
`TaskStateService.TryAdvanceParentAsync`. It surfaces any `WaitingForChildren` parent for
review once all children are terminal; failed/cancelled children are **annotated on the
result, not wedged**.
- A planning parent enters `WaitingForChildren` at `FinalizePlanningAsync` (or
`WaitingForReview` directly if it has no children).
- An improvement parent enters it from `TaskRunner.HandleSuccess` when its run spawned children.
- Planning/improvement **children** go straight to `Done` — no individual review. Only the
parent is reviewed.
A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks) does **not**
advance the parent — the parent stays in `WaitingForChildren` until every child is terminal.
The UI surfaces blocked children on the parent's Session tab (`ChildOutcomes` + a "children
need attention" band) so the roadblock is visible without forcing a transition.
A blocked planning/improvement child still goes straight to `Done` per the unified parent model
above — it committed nothing, but nothing prevents its (empty) branch from being unit-merged
like any other `Done` child once the parent is approved. The MCP surface has no UI equivalent of
`ChildOutcomes`, so `review_task`'s approve on a parent additionally returns `emptyChildren` (the
`Done` children whose review range is empty) and every task-returning tool exposes
`TaskRefDto.roadblockCount` → [external-mcp.md](external-mcp.md) → `ReviewTask`/`PreviewMerge`.
An empty branch is still mergeable by design (some tasks — e.g. an audit — legitimately produce
no diff); this is a visibility fix, not a merge gate.
## Cancel is blocked while a unit merge is draining
`ApproveReview` on a task with children awaits `PlanningMergeOrchestrator.StartAsync` /
`DrainAsync` synchronously — the parent sits in `WaitingForReview` for the whole (potentially
minutes-long, one-child-at-a-time) drain. Without a guard, a concurrent `CancelReview` (UI or
`update_task_status`/`cancel_task` via MCP) could flip the parent to `Cancelled` mid-drain while
the orchestrator kept merging children's worktrees onto the target branch; `FinalizeParentDoneAsync`
then finds the parent no longer `WaitingForReview` and gives up, leaving the merged children's
diffs stranded with no rollback.
`TaskStateService.CancelAsync` now rejects with a `TransitionResult` reason whenever
`PlanningMergeOrchestrator.HasActiveMerge(taskId)` is true for the task being cancelled, before any
DB write. Cycle note: `TaskStateService` can't take a direct constructor dependency on
`PlanningMergeOrchestrator` (which itself depends on `ITaskStateService`), so it takes a lazily-resolved
`Func<IActiveMergeState>` instead — same cycle-breaking shape as the existing `Func<ITaskStateService>`
handed to `PlanningChainCoordinator`. `IActiveMergeState` (`Planning/Interfaces/`) is `PlanningMergeOrchestrator`'s
only public surface `TaskStateService` needs.
The UI mirrors this as polish: `DetailsIslandViewModel.IsMergeDraining` (set on
`PlanningMergeStartedEvent`, cleared on `PlanningMergeAborted`/`PlanningCompleted` for the bound
task) gates `CancelReviewCommand`'s `CanExecute` — same shape as `WorktreesOverviewModalViewModel.IsMerging`
gating `CanMergeAll`. The worker-side guard remains the actual correctness fix; the button gate
just avoids inviting a click the worker would reject. `CancelReviewAsync`'s catch now raises
`ErrorReported` (→ shell `FlashFooterError`) instead of swallowing the rejection silently.
## Merge commit message
`MergeAsync` takes a `commitMessage`, but **every caller passes it blank** and lets
`TaskMergeService.DefaultMergeMessage` build `CommitMessageBuilder.BuildMerge(task.CommitType,
list.Name, task.Title, task.Id)``fix(my-list): merge <title>` + `ClaudeDo-Task:` trailer. Same
`type(scope)` shape as the task's own worktree commits, so merges stay Conventional-Commits-clean.
The only non-blank caller is the user editing the field in the merge modal, which is prefilled from
`GetMergeTargets().DefaultCommitMessage` (the UI can't build it — it knows neither the commit type
nor the list name). Callers used to hand-roll `"Merge task: {title}"` / `"Merge {branch}"` /
`"Merge subtask"`; don't add a fourth.
## Post-merge verify gate
A list can set `ListConfigEntity.VerifyCommand` (List Settings modal → Verification).
Null/blank (the default) = **no gate**, behavior bit-identical to before the feature existed.
When set, `TaskMergeService` runs it via `VerifyCommandRunner` (`cmd.exe /c <command>`,
10-minute fixed timeout, output tail-captured) in `list.WorkingDir` right after a successful
`MergeNoFfAsync` / `ContinueMergeAsync` **and** worktree cleanup, but **before** the task is
allowed to reach `Done`.
| Outcome | Effect |
|---|---|
| Exit 0 | Unchanged flow — worktree `Merged`, task `Done` if it was `WaitingForReview`. |
| Non-zero exit or timeout | The git merge is **deliberately left in place** (no auto-revert — that's a separate, unbuilt feature). The worktree is still marked `Merged` (it's already gone from disk when `removeWorktree` was requested), but the task stays out of `Done`. |
On failure `MergeResult.Status` comes back `TaskMergeService.StatusVerifyFailed`
(`"verify_failed"`) with an output excerpt in `ErrorMessage`. This flows through
`MergeResultDto` (hub) and `ReviewTaskResult` (`review_task`) unchanged, because both already
treat any non-`blocked`/`conflict` status generically. All three UI merge entry points handle it
explicitly (detail-pane Approve, merge modal, worktrees batch) — a generic fallback there showed
the raw status string instead of the failure.
**Worktree-less approvals are gated too.** A task with no active `WorktreeEntity` — a sandbox run,
or a list-handler task that commits straight into `list.WorkingDir` — skips the merge entirely,
but `ApproveAndMergeAsync` still runs the verify command (same per-repo gate, working dir =
`list.WorkingDir`) before the task may reach `Done`. Without that, the run that lands the most on
the target branch at once would be the one run nothing checks.
**The gate is what makes a merge look broken.** A verify command that builds and tests a solution
occupies the whole `MergeTask` hub call — measured at ~5m 46s on this repo. `TaskMergeService`
therefore broadcasts `MergeProgress(taskId, phase, elapsedSeconds)`: `PhaseMerging` before the
per-repo gate wait, `PhaseVerifying` when the verify starts and again on every
`ProgressReportInterval` tick (30 s, shared with the MCP progress reports), plus one
`WorkerLog` Info line so the footer strip shows it too. The merge modal renders that as a spinner
plus phase text; without it the only feedback was the disabled Submit button, which reads as a
dead app for minutes while the merge has in fact already landed.
**Serialization:** a process-wide `ConcurrentDictionary<string, SemaphoreSlim>` keyed by
`list.WorkingDir` serializes `MergeAsync` / `ContinueMergeAsync` (git ops + verify) per repo,
so a verify run can't be interrupted by a second merge landing in the same working dir
mid-build.
### Verify in the preview, not just post-merge
`TaskMergeService.PreviewAsync(taskId, targetBranch, runVerify, ct)` can additionally build/test a
clean `git merge-tree` result *before* anything is merged. It never touches the real working tree:
the tree `PreviewMergeAsync` would write is wrapped in a throwaway commit
(`GitService.CommitTreeAsync`, parent = the target branch's current tip) and checked out into a
detached-HEAD scratch worktree under the OS temp dir (`GitService.WorktreeAddDetachedAsync`), which
is always removed afterward (`finally`, non-cancellable cleanup). No verify command configured, or
`runVerify=false`, reproduces the pre-existing preview exactly — no delay, no scratch worktree.
Wired into `ExternalMcpService`: `preview_merge` (single task) always requests a verify run when the
list has a command configured; `preview_merge_set` only does when its `runVerify` parameter is
explicitly set (default `false`) — a set preview never starts N builds unasked. The Hub's
`PreviewMerge` (the UI's live mergeability indicator) always passes `runVerify: false`, since
running a build on every poll would be a bad regression; `MergePreviewDto` carries the
verify fields anyway so a future UI entry point can opt in. Result fields: `VerifyExitCode` (null =
not run; `-1` = timed out or failed to start; mirrors the post-merge gate's convention),
`VerifyDurationMs`, `VerifyOutputTail` (tail of output, only populated on non-zero exit, via the
same `TailOutput` helper the post-merge gate uses).
## `MergeCommit` and revert
`WorktreeEntity.MergeCommit` (nullable) is the SHA of the merge commit this worktree's branch
produced on the target branch. Stamped by `TaskMergeService` the moment a merge/continue-merge
succeeds, written **only** by `WorktreeRepository.SetMergedAsync` (which atomically sets
`State=Merged` and stamps the SHA in one update).
It is the only thing that makes `revert_merge` possible without heuristically searching
`git log` — see [external-mcp.md](external-mcp.md) → `RevertMerge`. Null for any worktree
merged before the field existed.
## Review gate in the UI
**Approve & Merge is gated behind opening the diff.** When there is something to inspect
(worktree diff / merged range / children combined diff), the button stays disabled until the
diff or combined-diff viewer has been opened once. The gate **re-locks per run** — any state
change resets it. Tasks with nothing to inspect are never gated.
The row-level quick-approve in the task list is an **intentional bypass**.
Implementation: `MergeSectionViewModel` owns merge-target selection, the mergeability
indicator (`MergePreviewPresenter` over `PreviewMergeAsync`), and `OpenDiffAsync` /
`ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and
fire the `DiffViewed` callback. `HasReviewableDiff` reports whether anything is inspectable
and feeds the gate.
## Conflict resolver (in-app Rider-style 3-pane merge editor)
`ConflictResolverViewModel` + `Views/Conflicts/ConflictResolverView`. Handles **both**
single-task and planning unit-merge conflicts.
### Model
Single-task mode starts the conflict merge, then parses each conflicted file into stable and
conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`. Types live in
`ConflictModels`: `MergeFile` / `MergeFileSegment` / `MergeConflictBlock`.
Exposed per active file: `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText`
(reconstructed from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved
conflicts with Ours), plus `ActiveFile` / `SelectFileCommand` (multi-file switcher),
`Current` / `Next` / `Previous` (focused-conflict nav), a per-file `PositionText` readout,
per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on
**every file resolved + no binary**. Each file is written via `WriteConflictResolution`.
**Planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's
mid-merge conflicts **without re-starting the merge** and routes continue/abort to
`ContinuePlanningMerge` / `AbortPlanningMerge`, so a unit-merge conflict re-opens the editor
per subtask via the `PlanningMergeConflict` broadcast.
**Except when an MCP session is driving the merge.** `PlanningMergeOrchestrator.StartAsync` takes
an `externallyDriven` bool (default `false`; `ExternalMcpService.review_task`'s parent-with-children
path passes `true`, since a running Claude session — not the UI — will resolve conflicts via
`continue_merge`/`abort_merge`). The flag rides along on the `PlanningMergeConflict` broadcast
(4th arg); `IslandsShellViewModel.OnPlanningMergeConflict` only auto-opens the resolver when it's
`false` — otherwise it shows a persistent, non-auto-dismissing banner
(`IsExternalMergeBannerVisible`) with a manual "Open resolver" button, cleared on
`PlanningMergeAborted`/`PlanningCompleted`. This exists because two parties (a human and the
driving session) could otherwise end up editing the same shared checkout at once.
`PlanningMergeOrchestrator.GetActiveExternalConflictsAsync` (hub: `GetActiveExternalPlanningMergeConflicts`)
re-derives this state by checking `GitService.IsMidMergeAsync` rather than trusting the in-memory
flag alone, so a UI restart mid-merge (or a stale entry left behind if something resolved the
repo outside the normal Continue/Abort path) can't show a phantom banner — the Ui calls it on
`ConnectionRestoredEvent`. The childless single-task conflict path
(`TaskMergeService.ApproveAndMergeAsync`) has no equivalent broadcast — it only sends the generic
`TaskUpdated` — so it never auto-opened the resolver and needed no change.
### View
Three **AvaloniaEdit** panes showing the whole file: MAIN/ours (read-only) | editable Result |
INCOMING/theirs (read-only). TextMate highlighting by extension (theme `StyleInclude` in
`App.axaml`).
- A code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across
panes. Tints live in `Tokens.axaml` (`Merge*TintBrush`).
- An `IReadOnlySectionProvider` + `TextAnchor` regions keep **only conflict spans** editable in
Result; edits flow back to the block.
- Each unresolved conflict starts **EMPTY** (a thin marker bar).
- The between-pane gutter controls **toggle** each side in/out of the result: ``/`` add
MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a
conflict can take main, incoming, both, or **neither**.
- `FilesSummary` shows how many files still have conflicts. The three panes share a
proportional synced vertical scroll.
- A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the
file proportionally; click a tick to jump. Useful for long files.
### Entry points
Review **Approve** on conflict, and the **Merge** button in the Diff window (a conflicting
`MergeTask` hands off via `RequestConflictResolution`).
## Hub methods
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto`,
`ContinuePlanningMerge` / `AbortPlanningMerge`, `PreviewMerge(taskId, targetBranch) ->
MergePreviewDto`, `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`,
`GetMergeTargets`
- Single-task conflict resolver: `StartConflictMerge`, `GetMergeConflictDocuments`,
`WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` — note the
service-level `TaskMergeService.ContinueMergeAsync` / `AbortMergeAsync` keep their own names.
- Broadcast events: `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`,
`PlanningMergeAborted`, `PlanningCompleted`
## Diff stack (UI)
`UnifiedDiffParser` (static) parses `git diff` output into `DiffFileViewModel`s, detecting
added/deleted/renamed/binary files and per-line numbers. `DiffModels.cs` holds the shared types
(`DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`,
`DiffTreeNodeViewModel`, `DiffTree`).
`DiffViewerViewModel` is one unified read-only viewer with two modes:
- **Files** — dirty worktree / branch-vs-base / commit-range. Loads via `GitService`, folder
file-tree left + per-file diff pane right, Merge button for a live branch source.
- **Planning** — per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + one
editor per file right (`PlanningFiles`), combined integration-branch toggle.
Both modes render through `DiffAlignment` (pure — pairs diff lines into side-by-side rows and
computes word-diff spans) and `DiffTextView` (AvaloniaEdit + TextMate highlighting keyed off the
file extension, unified/split layout, optional line wrap, synced scrolling). Files mode hosts one
`DiffTextView` for the selected file; Planning mode hosts one per file in `PlanningFiles` so each
gets its own grammar (one editor can only carry one TextMate grammar). The split/wrap toggles
persist to `ui.config.json` via `AppSettings` and reach the per-file editors in Planning mode
too.
+181
View File
@@ -0,0 +1,181 @@
# Usage monitoring, gate & throttle
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `f6cb825` (2026-08-05), plus the uncommitted per-bucket-throttle /
> draggable-gauge change of 2026-08-06 (this note already describes that newer state).
> Drift check: `git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/Usage src/ClaudeDo.Worker/Queue src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
Covers `src/ClaudeDo.Worker/Usage/`, the queue's throttle/gate integration, per-run token
accounting, and the UI surfaces (usage pill + usage monitor modal).
## Data source
`GET https://api.anthropic.com/api/oauth/usage` — an **undocumented** Anthropic endpoint,
authenticated with the Bearer access token Claude Code itself keeps fresh at
`~/.claude/.credentials.json`. ClaudeDo reads that token, never refreshes it, never logs it.
Because the endpoint is undocumented and can change without notice, **every consumer
fails open**. That is the single most important invariant here.
## Components (`Usage/`)
| Type | Role |
|---|---|
| `UsageModels` | `UsageBucket` / `UsageLimitRow` / `UsageSnapshot`. `UsageBucket.Utilization` is already a 0100 percent — compare directly against thresholds, don't rescale. |
| `ClaudeOAuthUsageClient` | Reads the token, calls the endpoint. Defensive parsing: missing/null buckets → null, missing `limits` → empty list. |
| `UsageState` | Threadsafe singleton. A failed poll **never** overwrites the last good snapshot — it only sets `LastError`. |
| `UsageMonitorService` | `BackgroundService`; polls on `usage_poll_interval_seconds` (default 60, clamped to min 15 on config load), one poll at startup. Logs a failure at most once per distinct error message. Broadcasts `HubBroadcaster.UsageUpdated` after **every** tick, success or failure. |
| `UsageSnapshotBuilder` | Builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings`. The one shared place for stale/threshold/gate logic — `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` must not diverge. |
| `UsageGate` | Hard pause decision → `UsageGateDecision(IsBlocked, Reason)`. |
| `UsageThrottle` | Pure static staging of parallelism ahead of the gate. |
| `TranscriptUsageReader` | Aggregates token usage from Claude Code transcripts. |
Interfaces in `Usage/Interfaces/`: `IUsageClient`, `ITranscriptUsageReader`, `IUsageGate`.
## The gate (hard pause)
Thresholds: `AppSettings.UsageGateFiveHourPct` / `UsageGateSevenDayPct` (defaults 80/90).
Blocked once `five_hour >= UsageGateFiveHourPct` **or** `seven_day >= UsageGateSevenDayPct`
(`>=`, not `>`). Threshold `0` = that bucket never gates.
What it pauses: **only the queue's slot-fill loop** — new queued tasks don't start.
Unaffected: already-running runs, `RunNow`, `ContinueTask`, interactive ConPTY sessions,
planning sessions, daily prep (all bypass the queue).
**Fail-open**: no snapshot yet, a failed last poll, or an app-settings read error all
resolve to not-blocked.
There is **no persistent pause state**. Recovery is just the queue's 30 s backstop timer
(`queue_backstop_interval_ms`) re-evaluating the gate on its own once usage drops back
under the threshold. A blocked↔free transition is logged and broadcast (`WorkerLog`, Warn
on block / Info on resume) exactly **once per change**, not every tick.
## The throttle (staged parallelism)
`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, fiveHourThresholds, sevenDayPct,
sevenDayThresholds)` — pure static, no state. `UsageThresholds(SoftPct, HardPct, GatePct)` is the
per-bucket triple (same file).
Thresholds are **per bucket** (`usage_throttle_five_hour_{soft,hard}_pct` /
`usage_throttle_seven_day_{soft,hard}_pct`, defaults 50/65 each) because the 5h and 7d windows fill
at very different rates. Each bucket is staged independently and the **strictest** bucket wins —
not "whichever is more utilized", so a bucket that is lower but tightly configured can be the one
that throttles:
| Utilization (per bucket) | That bucket's slots |
|---|---|
| below soft | full configured `max_parallel_executions` |
| `>= softPct` | capped at 2 |
| `>= hardPct` | capped at 1 |
| `>= gatePct` | 0 — same hard block as `UsageGate` |
A threshold of `0` disables that stage for that bucket, and a bucket with no reading (null) never
throttles. The `0` return is deliberately kept in sync with `UsageGate`'s hard block because both
read the same gate thresholds — change one, change both.
Only **new** slot fills are affected; a run already occupying a slot when the stage tightens
runs to completion. Same fail-open policy: no snapshot means no throttling.
The effective stage (configured vs. effective slots + the decisive bucket, `ThrottleBucket`
= `"five_hour"` / `"seven_day"`) rides along on `UsageSnapshotDto` purely for UI display.
It does **not** change what the gate gates on.
## Queue integration (`Queue/QueueService`)
Per loop tick:
1. `GetEffectiveMaxParallelAsync` reads `AppSettings.MaxParallelExecutions` and steps it
down via `UsageThrottle.EffectiveSlots` against the current `UsageState` snapshot.
A missing/failed snapshot fails open to the configured value. A **stage change** (not
every tick) logs once.
2. Separately, `IUsageGate.EvaluateAsync` — if blocked, the slot-fill loop is skipped
entirely for that tick.
## Per-run token accounting
`task_runs` stores four raw token fields: `tokens_in` / `tokens_out` /
`cache_read_tokens` / `cache_write_tokens`.
These are **not** read from the stream-json `result` event's `usage.input_tokens` — that is
only the uncached remainder of a single API call and undercounts the real prompt size by
orders of magnitude once caching kicks in.
Instead `TaskRunner.ApplyUsageAsync` calls
`ITranscriptUsageReader.ReadSessionTotalsAsync(sessionId)` — the session transcript's
cumulative raw totals across every assistant message, located by `{sessionId}.jsonl` — and
stores the **delta** against prior `task_runs` rows sharing the same `session_id`, so a
`--resume`'d run doesn't double-count turns already billed to an earlier run.
A missing/unreadable transcript leaves all four fields `null`; it never fails the run.
### `TranscriptUsageReader` details
Reads `~/.claude/projects/**/*.jsonl`, aggregating by date / model / scope (ClaudeDo vs
Other), deduped by `requestId`, with a per-file length+mtime cache.
`ReadAsync` **skips any file whose mtime predates the window start minus one day** — it cannot hold
a record inside the range, and the full history is large (measured 2026-08-06: 501 files / 230 MB /
77k lines ≈ 1.7 s to parse cold; a 7-day range touches ~190 files / ~106 MB). The one-day slack
absorbs local-vs-UTC skew between mtime and record timestamps. `ReadSessionTotalsAsync` is
unaffected — it looks up a single `{sessionId}.jsonl`.
`<synthetic>`-model lines are skipped **everywhere** — they are not real API calls.
## UI surfaces
- **`UsagePillViewModel`** — one shared instance backs the `UsagePill` control in both the
footer and the Mission Control header. Loads via `GetUsageSnapshotAsync`, updates live off
`IWorkerClient.UsageUpdatedEvent`. Dot state priority is mutually exclusive:
**blocked > stale > warn > normal**. `IsThrottled` (effective slots below configured, and
not gate-blocked) adds a tooltip line naming effective/configured slots + decisive bucket.
The pill's click handler (`IslandsShellViewModel.OpenUsageMonitor`) **shows the window before
loading** (`BeginLoad`) — awaiting the load first made the pill feel like a dead click, because
the first `GetModelUsage` per worker process scans the whole transcript history.
- **Draggable stage markers** — each of the two real gauges carries three markers (soft/hard/gate).
`UsageGaugeBar` (`Views/Controls`) draws them against its own width and does the pointer work;
the math is a pure static, `UsageThresholdDrag` (in the modal VM's file), which keeps
soft ≤ hard ≤ gate and treats a neighbour of `0` as off. Release fires the row's
`CommitCommand` → read-modify-write via `GetAppSettings` + `UpdateAppSettings`, so only the
dragged bucket's three fields change. Plan-dependent `weekly_scoped` gauges are read-only.
- **Legend = numeric editor.** Under each adjustable bar sit three legend rows whose colour swatches
match the markers (soft `TextDimBrush`, hard `StatusReviewBrush`, gate `StatusErrorBrush`), each
with a `NumericUpDown`. `NumericUpDown` has no commit command, so the box's `Tag`
(`soft`/`hard`/`gate`) plus two code-behind handlers (`LostFocus`, Enter) call the row's
`CommitSoft`/`CommitHard`/`CommitGate` command. Those run the typed value through the **same**
`UsageThresholdDrag.Apply` clamp as a drag, so a box can't invert the order and only the edited
stage moves. ⚠️ The `KeepLastNumber` converter is mandatory on those bindings — see the
`NumericUpDown` null gotcha in `src/ClaudeDo.Ui/CLAUDE.md`.
Rows are updated **in place** on each snapshot (keyed by limit kind) so a poll landing mid-drag
doesn't replace the bound instance.
- **`UsageMonitorModalViewModel`** — opened from the pill. Renders one gauge **per row** in
`UsageSnapshotDto.Limits` — deliberately **dynamic**, because the `seven_day_opus` /
`seven_day_sonnet`-style buckets the raw API returns are plan-dependent and come back
`null` on plans that don't have them; a fixed gauge layout would break. Also shows model
usage (`GetModelUsageAsync`, ClaudeDo-vs-Other split per model) and top-task usage
(`GetTaskUsageAsync`) over a 7d/30d preset or custom range.
## Hub surface
- `GetUsageSnapshot() -> UsageSnapshotDto` — percentages/limits/`FetchedAtUtc` are null and
`IsStale=true` when no snapshot has landed yet. `IsStale` also trips on a failed last poll
or a snapshot older than 3× `usage_poll_interval_seconds`.
- `GetModelUsage(from, to)` — thin wrapper over `ITranscriptUsageReader.ReadAsync`.
- `GetTaskUsage(from, to)` — top consumers from `task_runs` joined to task/list, grouped per
task. Null token columns count as **0**, never drop the row. `Model` comes from that task's
most recent run. Sorted by total tokens descending, capped at 100.
- `UsageUpdated` event carries the same `UsageSnapshotDto`.
## Settings columns
`app_settings`: `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` (80/90),
`usage_throttle_five_hour_{soft,hard}_pct` / `usage_throttle_seven_day_{soft,hard}_pct` (50/65 per
bucket). All six clamped 0..100 by `AppSettingsRepository.UpdateAsync`, which does **not** enforce
soft ≤ hard ≤ gate — the ordering is a UI-side drag constraint, and an out-of-order stored config
degrades instead of throwing. Worker config: `usage_poll_interval_active_seconds` /
`usage_poll_interval_idle_seconds`.
The gate percentages are editable in **two** places that both write the same `app_settings` row:
Settings → General (typed) and the usage-monitor gauges (dragged). The throttle stages are
gauge-only — `SettingsModalViewModel` therefore carries them load→save verbatim so saving Settings
can't reset a dragged value.
+58 -5
View File
@@ -1,6 +1,6 @@
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `896d4b5` (2026-07-23).
> Drift check: `git log --oneline 896d4b5..HEAD -- src/ClaudeDo.Worker`
> Last verified against commit `cc90600` (2026-08-06).
> Drift check: `git log --oneline cc90600..HEAD -- src/ClaudeDo.Worker`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
# Worker: Task Execution Pipeline
@@ -28,9 +28,11 @@ How a task moves Queued → Running → terminal, across `src/ClaudeDo.Worker`
5. **Run Preparation**`TaskRunner.RunAsync()` (Runner/TaskRunner.cs)
- Loads task, list config, subtasks, attachments from the DB.
- `StartRunningAsync()` (only if not pre-claimed): atomic claim to Running, **before any
resource is created**. A rejected claim (task already Running) bails out immediately —
no worktree, no MCP token file. Broadcasts TaskStarted.
- `PrepareRunDirectoryAsync()`: worktree (via WorktreeManager) if the list has a WorkingDir,
else sandbox. Generates a per-run MCP token, writes MCP config to disk.
- `StartRunningAsync()` (only if not pre-claimed): atomic Queued → Running. Broadcasts TaskStarted.
6. **Claude Execution**`TaskRunner.RunOnceAsync()` (Runner/TaskRunner.cs)
- Creates a TaskRunEntity, points the task at the run's log path.
@@ -50,9 +52,47 @@ How a task moves Queued → Running → terminal, across `src/ClaudeDo.Worker`
- **Done** CompleteAsync (Running → Done) — top-level success.
- **WaitingForReview** SubmitForReviewAsync (Running → WaitingForReview) — review gate.
- **WaitingForChildren** SubmitForChildrenAsync (Running → WaitingForChildren) — blocks on children.
Advances to WaitingForReview via `TryAdvanceParentAsync` once every remaining child is
terminal (Done/Failed/Cancelled) — including zero children left, e.g. after the last child
is deleted (`WorkerHub.DeleteTask` / `ExternalMcpService.DeleteTask` both call it).
- **Failed** FailAsync (Running/Queued → Failed).
- **Cancelled** CancelAsync (Running/Queued/WaitingForReview/WaitingForChildren → Cancelled).
## Model, effort & max-turns resolution
*(section added at commit `f6cb825`, 2026-08-05; resolver extraction added same day)*
The resolution below lives in `Runner/EffectiveRunConfigResolver.Resolve` (not inlined in
`TaskRunner` anymore) so `TaskRunner.ResolveConfigAsync` and the read-only
`get_effective_run_config` MCP tool (`External/ConfigMcpTools.cs`) share one codepath and can't
report different numbers for the same task. The tool additionally surfaces, per field, whether
it came from the task/list/preset/global layer, and — for max turns — the raw requested value
plus whether it was clamped.
Step 6 builds the CLI args. Model and turn budget resolve like this:
1. **Effective model** — task override → list config → `AppSettings.DefaultModel`.
2. **Preset row**`ModelPresets.For(global.ModelPresets, model, global.DefaultMaxTurns)`.
The model string is resolved through `ModelRegistry.TryNormalizeAlias` **first**, so a full
CLI model id (e.g. `claude-sonnet-4-6`, not just the bare `sonnet`/`opus`/`haiku`/`fable`
aliases) still hits its alias's preset row instead of missing every lookup. Only a model
that normalizes to nothing recognized falls back to a synthesized row using
`AppSettings.DefaultMaxTurns`**never a hardcoded number, and it never throws**: an
unknown model must not block a run.
3. The preset supplies `--effort` and the **global** max-turns default. Task/list `MaxTurns`
overrides still win over it.
4. **Ceiling clamp**`TaskRunner.ResolveMaxTurns` hard-clamps the resolved value to
`AppSettings.MaxTurnsCeiling` (default 80). An override above the ceiling still starts, just
capped, and a Warn logs the task id + requested + effective value.
⚠️ **Trap:** if `app_settings.model_presets` is somehow null, the fallback path decides the turn
budget — which is why `AppSettingsRepository.GetAsync` backfills shipping defaults on the first
read after null. Ship preset turns are low (haiku 20, sonnet 30, opus 40, fable 25), so a task
that genuinely needs a long run must set its own `MaxTurns`.
Prompt composition: `TaskPromptComposer.Compose` injects attachment **absolute paths** as a
read-only "## Reference files" section.
## Component Responsibilities
**Queue/**
@@ -73,14 +113,18 @@ How a task moves Queued → Running → terminal, across `src/ClaudeDo.Worker`
- `TaskStateService` — all task status transitions; guards preconditions; signals queue/hub.
**Lifecycle/** (startup recovery)
- `StaleTaskRecovery` — tasks stuck Running after a crash/restart → Failed.
- `StaleTaskRecovery` — tasks stuck Running after a crash/restart → Failed. The underlying
`TaskStateService.RecoverStaleRunningAsync` bulk-flips Running→Failed, then re-runs the same
chain/parent-advance side effects as a normal `FailAsync` (per recovered id, best-effort) so a
crash mid-chain-child or mid-improvement-child doesn't leave a successor blocked forever or a
`WaitingForChildren` parent wedged.
- `OrphanRecovery` — dequeues children whose parent is no longer planning (stays attached).
- `AttachmentOrphanRecovery` — cleans orphaned attachment files.
- `TaskResetService` — manual reset to Idle.
- `TaskMergeService` — conflict resolution for worktree merges.
**Hub/**
- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/RunCreated…).
- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/WorktreeUpdated…).
- `WorkerHub` — SignalR hub + client methods.
**Agents/**
@@ -113,6 +157,15 @@ Program.cs (DI setup)
- **Slot limit** — respects MaxParallelExecutions; a backstop timer wakes even if a Wake() is missed.
- **Pre-claimed tasks** — the dispatcher pre-claims via the picker; the override slot
(RunNow/ContinueTask) must call StartRunningAsync if a task is not pre-claimed.
- **Claim before create** — `TaskRunner.RunAsync`'s unclaimed path calls `StartRunningAsync`
*before* `PrepareRunDirectoryAsync`. RunNow racing the picker for the same Queued row used to
create the worktree first and only claim afterwards, so the losing dispatch could hit
WorktreeManager's branch-collision self-heal and force-remove the winner's live worktree
mid-run. `OverrideSlotService.RunNow` also fast-rejects a task already Running in the DB
(defense in depth; the picker's atomic SQL claim is the real arbiter either way).
`RunCancellationRegistry.Register` refuses (and logs) a second registration for the same task
id instead of silently overwriting the first, so a losing dispatch's cleanup can't unregister
the winner's CTS out from under it.
- **Terminal writes** — use `CancellationToken.None`; a task is never left Running after crash/cancel.
- **Per-run MCP tokens** — each run gets a unique token scoping tool access; unregistered on end.
- **Auto-retry** — one automatic retry if a session exists and the first run failed.
+226
View File
@@ -0,0 +1,226 @@
# Handoff — List-handler run on list "Claude do", 2026-08-05
> **✅ COMPLETED 2026-08-05 (follow-up session).** Everything in §1 and §2 is merged and `main`
> verified green. Read §0 below before §1–§8 — §5's diagnosis turned out to be **wrong** and the
> rest is now history. Still nothing pushed.
Repo: `C:\Private\ClaudeDo` · List id: `5f973815-050a-4136-94f0-1506a5d4560a` · Branch: `main` (nothing pushed)
---
## 0. What the follow-up session did (and what §5 got wrong)
**All merged, `main` green after every step** (Worker 811/811, Data 143/143, Ui 292/292,
Localization 16/16, builds 0 new warnings):
| Merged | Task | Merge commit |
|---|---|---|
| §1 #1 | `9e307199` revert_merge + merge-SHA persistence | `3e7126b` |
| §1 #2 | `0b2fbb48` post-merge verification gate | (conflict-resolved) |
| §1 #3 | `8c1c2130` roadblock reply box | `519ea5a` |
| §2 #42 | `c1df5b9a` Hub surface for usage | `6e2158d` |
| §2 #43 | `f74b44d9` Usage pill | `7661129` |
| §2 #45 | `06068810` gate thresholds in settings | `5115cfc` |
| §2 #44 | `82488d2a` Usage Monitor modal | `338fc39` |
| §2 #46 | `9c8cffe0` docs | `5872666` |
| parent | `439a4daf` Usage Monitor unit | approved → Done (empty unit merge; all children already `Merged`) |
Plus one hand-fix on `main`: `677a4c1``UsagePillViewModelTests` never set `Loc.Current`
(defaults to a key-echo localizer) and only passed because another test class happened to
install a real `Localizer` first; #44's new tests changed the ordering and broke it on `main`.
Classic "both branches green, `main` red".
### §5 is wrong — `"exited with code 1 and no result"` is NOT (only) a CLI crash
It is a **catch-all** hiding at least three causes. The truth is in the run log's last NDJSON
line (`{"type":"result", …}``terminal_reason` / `errors` / `result`):
- **`max_turns`** — what actually killed #40 and #42. `app_settings.model_presets` is `NULL`, so
`ModelPresets.Parse` falls back to the shipping defaults, and **sonnet's default is 30 turns**.
`AppSettings.DefaultMaxTurns` (100) is only the fallback for an *unrecognized* alias, so it
never applies. Every task without an explicit `maxTurns` override got 30 turns.
→ Fix used here: `set_task_config(taskId, model="sonnet", maxTurns=200)` before queueing.
- **`api_error`** + `"You've hit your session limit · resets 1pm (Europe/Berlin)"` — the account's
5-hour limit, which took out #43's first attempt. Nothing to fix; wait for the reset, re-queue.
- Genuine process death — the case §5 describes.
§5's *operational* advice still holds and is what saved #42: **never `reset_failed_task`** on one
of these; check the worktree, build/test it, then set the task `Queued` so the agent resumes its
own session and commits. #42's worktree held a complete green implementation (773/773).
Follow-up tasks: `ca6e55c0` (surface the real failure reason) is new; the turn-budget/presets side
is already covered by the Idle task `2de2f008` (`b0317ec7` fixed only the unknown-alias half).
### Still open
- **Visual passes** (nobody has looked at these in a running app): usage pill in the footer *and*
the Mission Control header; Usage Monitor modal (gauges, tables, stale/blocked bands, dark/light);
the roadblock reply box; the verify-command field in the List Settings modal. See `docs/open.md`.
- **`~/.todo-app/prompts/planning.md` still shadows the compiled default** (§7.1) — unchanged.
- **`wait_for_task_change` is merged but not in the running Worker**, so it isn't callable over MCP
until the Worker is restarted. §3's sqlite poll was used instead.
- One rough edge in the verify gate: if the verify command fails, the worktree has already been
removed and its state set `Merged` while the task stays `WaitingForReview` — re-approving is then
refused. Recovery is `update_task_status(..., "Done")` once `main` is fixed.
Predecessor session ran the five-phase list handler over 11 briefed tasks and, along the way,
absorbed the 9-child "Usage Monitor" unit. **Phases 03 are complete for the brief.** What is
left is Phase 4 (review + merge) for three tasks, plus the Usage chain.
---
## 1. Do this first — three brief tasks sit in WaitingForReview
Merge in **this order** (the order was chosen with the user and matters):
| # | Task | Id | Note |
|---|------|----|------|
| 1 | Feat: Merge zurücknehmen — Merge-Commit festhalten + `revert_merge` | `9e307199-2eca-4eb7-9057-d2c675cc57ca` | Migration + new tool. Merge **before** #2 |
| 2 | Feat: Verifikations-Gate nach dem Merge | `0b2fbb48-d44c-4155-8c21-d3464c0bd5c2` | Depends on #1's merge-SHA persistence; both edit `TaskMergeService.cs` |
| 3 | Feat: Antwortfeld auf der Roadblock-Karte | `8c1c213004574c4fad6beb75b84b70d7` | UI + localization (en **and** de) |
For each one:
1. `get_task_diff(taskId, stat=true)`, then the full diff if non-trivial. Sanity-check against
the task description (they are long and precise — the acceptance criteria are the checklist).
2. `review_task(taskId, decision="approve", leaveConflictsInTree=true)`.
3. On conflict: open the files under the returned `repoPath`, resolve keeping **both** sides'
intent, then `continue_merge(taskId)`. Conflicts are expected and normal here.
4. **After every merge, verify `main`** (see §4). This is non-negotiable — see §5.
Expected conflicts: `TaskMergeService.cs` between #1 and #2; `src/ClaudeDo.Worker/CLAUDE.md`
and `src/ClaudeDo.Data/CLAUDE.md` in nearly every merge (doc bullet lists — trivial, keep both
sides' entries).
## 2. Then the Usage Monitor unit
Parent `439a4daf166f4ab5b0fa415693d2c80d` ("Usage Monitor hinzufügen") is `WaitingForChildren`
and has **no worktree of its own**. It has 9 children. Four are merged, one is in flight, four
are Idle.
| Child | Id | State |
|---|---|---|
| #38 Data: Usage-Gate-Schwellen + Modell-Spalte | `c1c999b6-b800-4b6b-a821-fbc028c15772` | merged `b1efcdc` |
| #39 Worker: OAuth-Usage-Client + Poller | `f657e316-ad72-4f45-8036-460841fc8997` | merged `b126a21` |
| #40 Worker: UsageGate | `06a7cc32-6ab7-4758-98f4-bee77149b2bf` | merged `1ee21b5` |
| #41 Worker: TranscriptUsageReader | `840fdb98-1c0e-4219-8062-c8769233fc14` | merged `334cf1e` |
| #42 Worker: Hub-Surface für Usage | `c1df5b9a-b911-4fe8-aab4-5876d9d85793` | **re-queued, in flight — read §5 before touching** |
| #43 UI: Usage-Pill | `f74b44d9-7e48-4bfe-9d89-075e194d1fc9` | Idle — queue once #42 is merged |
| #45 UI: Gate-Schwellen im Settings-Modal | `06068810-5b5c-4635-80dd-62eeba89fb8c` | Idle — queue once #42 is merged (parallel with #43) |
| #44 UI: Usage-Monitor-Modal | `82488d2a-8ff7-41b8-b791-367959a8f827` | Idle — needs #42 **and** #43 merged |
| #46 Docs: Usage Monitor | `9c8cffe0-8f7b-401e-a4f0-33b937047082` | Idle — last, after everything is merged |
**The chain is strictly serial and you must respect it.** Every child forks from `main`, and each
one's own description hard-requires the earlier ones. Queueing them all at once is exactly what
produced the original roadblock: #40 ran, found its prerequisite types only on unmerged sibling
branches, and returned `Done` having written **zero** code. So: merge a child → then queue the
next → verify `main` → repeat.
When the last child is merged the parent surfaces for review by itself; approve it to close the
unit (it has no worktree, so it approves straight to Done).
## 3. Cheap status polling — important
`list_tasks` and `batch_get_tasks` return full descriptions and **blow the token limit** on this
list (`list_tasks` over 52 tasks = ~206,000 chars; that is literally one of the bugs this run
fixed). Do not poll with them. Poll the DB read-only instead:
```bash
PYTHONIOENCODING=utf-8 python - <<'EOF'
import sqlite3
c=sqlite3.connect("file:C:/Users/mika.kuns/.todo-app/todo.db?mode=ro",uri=True)
for i,s in c.execute("select id,status from tasks"):
print(s, i)
EOF
```
`list_worktrees` is also compact and safe. **New this run:** `wait_for_task_change(taskIds,
timeoutSeconds)` is now merged and is the proper primitive — it returns as soon as any listed
task leaves Queued/Running (server-clamped to 170 s). Prefer it over sleeping.
## 4. Verify main after every merge
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
```
For the UI/localization task (#3 above, and children #43#45) also:
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
`.slnx` needs .NET 9 — build individual csproj files, `-c Release` (a running Worker locks Debug).
Baseline as of this handoff: Worker **753/753**, Data **143/143**, build 0 warnings.
## 5. The trap that cost this run the most time
Two children "failed" with `"Claude exited with code 1 and no result"`. **That is a CLI crash,
not bad code.** In both cases the worktree held complete work that built with 0 warnings and
passed the full suite (#40: 732/732, #42: 766/766) — the run just died before the auto-commit.
- **Never `reset_failed_task` on such a task** — it discards the worktree and destroys the work.
- Instead: `cd` into the worktree, `git status`, build + test it. If green, set the task
`Queued`. The worktree is preserved and the agent resumes its own session (`--resume`),
finds its work and commits it. That is how #40 was recovered.
- #42 is mid-recovery right now via exactly this route. If it failed again, verify its worktree
(`C:\Private\.claudedo-worktrees\claude-do\c1df5b9a-b911-4fe8-aab4-5876d9d85793`) before
doing anything destructive.
Second trap: git merges cleanly and the **compiler** still breaks. It happened again this run —
`Usage/UsageModels.cs` was an add/add conflict, and `src/ClaudeDo.Worker/CLAUDE.md` merged
"cleanly" into a file with the `Usage/` folder documented **twice**. Always read what a clean
merge produced, and always run §4.
## 6. Phase 03 decisions already made (do not redo)
Dedupe: four candidate pairs examined, **nothing cancelled**. Decisions:
- `05827da5``81e37801` — kept both, and `05827da5` was **re-scoped**: its "lean status query"
half was removed because `81e37801`'s wait tool covers it. `05827da5` now owns only the
brief-description rendering. Both are merged.
- `a76d9547``99732497` — kept both, `99732497` merged first. Done.
- `0b2fbb48``9e307199` — kept both, `9e307199` merges first. **This is item #1/#2 in §1.**
- `20c78c95``a76d9547`(b) — kept both, different actors. Done.
Phase 2: all 11 tasks carry acceptance criteria, real file+line references and out-of-scope
sections. Three that were one-liners were researched and rewritten after asking the user
(ConPTY fix approach, maxTurns-only scope, roadblock reply-box design).
Run config: `maxParallelExecutions` = **3**. No list config exists, so effective max turns was
the global **100**; it was raised to **200** per-task on the five heaviest via `set_task_config`.
`0b2fbb48`, `9e307199` and `8c1c2130` still carry that override.
## 7. Open follow-ups worth new tasks
1. **`~/.todo-app/prompts/planning.md` shadows the planning prompt.** `PromptFiles.EnsureExists`
only writes a default when the file is absent, and that file exists (dated Jun 2). The
`maxTurns` guidance merged in `65db1cd` therefore **does not reach real planning sessions**
until that file is updated by hand. `system.md` and `agent.md` are shadowed too.
`merge-helper-system.md`/`merge-helper-initial.md` do **not** exist, so this run's handler
prompt changes are live.
2. **MCP task DTOs expose no parent/child link.** The 9-child Usage unit had to be reconstructed
from `sortOrder` and creation timestamps. `get_task`/`list_tasks` should return
`parentTaskId` / `blockedByTaskId`.
3. **Visual verification open** on: the ConPTY fix (open a tile on a task whose description
contains `->`), and — once merged — the roadblock reply box and the verify-gate field in the
list settings modal.
4. **`"exited with code 1 and no result"` is too common.** Three runs died that way today, two
with finished work. Worth investigating whether the auto-commit step can be made to survive
a late CLI crash.
5. Nothing has been **pushed**. `main` is 22 commits ahead of `8d7ba1e`.
## 8. Rules this session operated under
- Drive merges through the MCP tools. Never raw `git merge` / `reset` / `checkout`.
- Hand-resolve only markers the tools left behind, then `continue_merge`. When committing by
hand is unavoidable, stage **explicit paths** — never `git add -A`: the main checkout is
shared with other sessions.
- For a parent/children unit merge, pass the **parent** id to `continue_merge` / `abort_merge`.
- Ask the user on anything ambiguous, risky, or destructive.
- Never `delete_task` to dedupe — `Cancelled` keeps it visible and resettable.
+212 -41
View File
@@ -1,60 +1,46 @@
# ClaudeDo — Offene Punkte
Stand: 2026-07-24. Diese Datei listet die **aktiv verifizierten Findings** aus der Verifikations-Session vom 2026-07-24. Die laufende Verifikations-Checkliste (Pass/Fail je Abschnitt, inkl. noch offener Abschnitte §7–§11 und Kanten) lebt in `docs/verification-handoff.md`. Erledigtes steht in den Commits/im Code, nicht hier.
Stand: 2026-08-06. Der Findings-Block von 2026-07-24 wurde am 2026-08-06 **gegen den Code
nachverifiziert** (statisch, nicht in der laufenden App); alles, was inzwischen gefixt ist, ist
hier entfernt — Erledigtes steht in den Commits/im Code, nicht hier. Die alte Verifikations-
Checkliste lebt in `docs/verification-handoff.md`.
---
## Bugs (verifiziert 2026-07-24)
## Bugs (offen)
- **OUTCOME-Karte rendert rohes Structured-Output-JSON:** `TaskMonitorViewModel.ApplyOutcome` (ClaudeDo.Ui) setzt bei Tasks ohne Roadblock-Marker `SessionOutcome = result` wörtlich (Zeile ~261). Der Worker legt in `task.Result` das rohe `{"summary":…,"files_changed":[…]}` ab (die lesbare Fassung steht in `task_runs.resultMarkdown`), also zeigt die Detail-Insel OUTCOME als JSON-Blob statt als Text. Fix: (a) UI parst ein JSON-Result und zeigt `summary`, oder (b) Worker schreibt `summary`/`resultMarkdown` statt des JSON in `task.Result`. Verifiziert am Task `verif §1 diff matrix`. Deckt sich mit Memory `worker_testing_findings`.
- **Approve & Merge schluckt einen „blocked"-Merge still:** `DetailsIslandViewModel.ApproveReviewAsync` reagiert nur auf `result.Status == "conflict"` (öffnet den Resolver); bei `"blocked"` (z.B. Ziel-Working-Tree hat uncommittete getrackte Änderungen) und anderen Nicht-`merged`/Nicht-`conflict`-Status passiert **nichts** — kein Footer-Fehler, kein Dialog (der `catch` greift nur bei Exceptions, „blocked" ist aber ein normaler Rückgabewert mit `ErrorMessage`). User sieht „Klick tut nichts". Doppelt verifiziert (Konflikt-Approve UND sauberer additiver Approve `verif §1b`, beide bei dirty `main`-Checkout still). Fix: bei `blocked`/unerwartetem Status `result.ErrorMessage` via `ShowErrorAsync`/`FlashFooterError` surfacen. Verstößt gegen `feedback_ui_error_surfacing`. (Der Konflikt-Resolver + 3-Pane-Editor funktionieren, sobald der Ziel-Tree sauber ist.)
- **„Waiting for Improvements" für Planning-Parents (Terminologie):** `taskStatus.waitingForChildren` = „Waiting for Improvements" (en.json:504), `agentStatus.children` dito (503), `childOutcomesLabel` = „IMPROVEMENTS" (191). Seit dem unified-parent-Modell gilt `WaitingForChildren` für Planning **und** Improvement-Parents — „Improvements" ist für einen Planning-Parent falsch. Auf neutrales „Waiting for Subtasks"/„Subtasks" umstellen (en **und** de — Localization.Tests-Parität). Verifiziert §3.
- **Kind-Rows aktualisieren nach Parent-Planning-Transitionen nicht live:** Parent-getriebene Änderungen propagieren nicht auf die Kind-`TaskRowViewModel`s ohne Listen-Reload. (a) Nach „Finalize planning" bleiben Kind-Rows auf „Draft" (`IsDraft`) statt „Planned" (`IsPlanned`) — der geänderte `ParentFinalized` kommt nicht über das Parent-`TaskUpdated`-Broadcast an. (b) Nach „Discard" bleiben die (in der DB gelöschten) Draft-Kind-Rows sichtbar, bis die Liste neu geladen wird. Gemeinsame Ursache: die Kinderliste/-zustände werden bei Parent-Transitionen nicht live neu aufgelöst. Doppelt verifiziert §3 (Finalize **und** Discard, 2026-07-24).
- **AskUser-Frage erscheint NUR in Mission Control, nicht in der Task-Detail-Insel:** Der `ask_user`-Banner (Frage + Antwort-Eingabe) lebt ausschließlich in `MonitorPaneView` (Mission Control). Die Detail-Insel (`DetailsIslandView`) streamt zwar den Live-Log eines laufenden Tasks, zeigt aber **kein** Frage-Banner — ein Nutzer, der nur die Detailansicht offen hat, sieht nicht, dass der Run auf eine Antwort wartet, und läuft nach 3 min in den Timeout-Fallback. Verifiziert §7 (User schaute Detailansicht, Frage war unsichtbar; erst in Mission Control sichtbar). Fix: Frage-Banner + Inline-Antwort auch in der Detail-Insel für den gebundenen laufenden Task surfacen (VM-Zustand liegt bereits in `TaskMonitorViewModel`, müsste für die Detail-Insel repliziert/geteilt werden). **Von Mika bei der §7-Verifikation ausdrücklich gewünscht** („vermisse diese Interaktion in der Detail-Ansicht"). §7.
- **„New session"-Button (Mission Control) unsichtbar — Icon.Plus ist Strich-Only:** `Icon.Plus` = `M12 5v14M5 12h14` (IslandStyles.axaml:61) ist reine Linien-Geometrie ohne Fläche; im `<PathIcon>` (MissionControlView.axaml:38, füllt Geometrie) rendert sie **unsichtbar** → der Ad-hoc-„New session"-Button erscheint leer und ist nicht auffindbar (Feature + VM `OpenAdHocConPtySessionAsync` + Tests funktionieren, nur das Icon fehlt sichtbar). Fix: Icon.Plus als gefüllte Geometrie authoren ODER als gestricheltes `Path` rendern (Icon-Gotcha in CLAUDE.md). **Andere `Icon.Plus`-Verwendungen mitprüfen.** Verifiziert §5.
- **Verwaiste git-Worktrees sind für die App unsichtbar** (gefunden 2026-08-06). In `C:\Private\ClaudeDo` waren nach dem Cleanup 22 git-registrierte Worktrees + 26 `claudedo/*`-Branches vorhanden, die ClaudeDo-DB kannte davon nur zwei. Die Worktrees-Übersicht listet ausschließlich Zeilen aus `worktrees`, also kann der Nutzer diese Reste nicht über die App entfernen — und es gibt keinen Sweep dafür (`OrphanRecovery` räumt nur Task-Zeilen auf, keine Worktrees). Wunsch: entweder ein Startup-Abgleich `git worktree list` ↔ DB, der Unbekannte als „untracked" in die Übersicht aufnimmt, oder mindestens eine Warnung mit Anzahl.
## UX / Nits (verifiziert 2026-07-24)
## UX / Nits (offen)
- **„Resume planning session" ist grundsätzlich kaputt (Session-Id wird nie erfasst) + Fehler wird verschluckt:** Im UnfinishedPlanning-Modal macht **Resume** nichts. Ursache: `PlanningSessionManager.ResumeAsync` (PlanningSessionManager.cs:238) bricht mit `InvalidOperationException("No Claude session ID captured yet; cannot resume.")` ab, wenn `task.PlanningSessionId` leer ist — und der Setter `TaskRepository.UpdatePlanningSessionIdAsync` (TaskRepository.cs:322) hat **keinen einzigen Aufrufer** im Worker, d.h. `planning_session_id` wird nie befüllt (verifiziert an `verif §3 resume-discard`: `planning_session_id=NULL`, Token gesetzt, Drafts angelegt). Resume kann also **nie** erfolgreich sein. Zusätzlich verschluckt `TasksIslandViewModel.ResumePlanningSessionAsync` den ganzen Block in einem leeren `catch { }` (~Zeile 870) → der Button ist ein **stummer No-op**, kein Footer-Fehler, kein Dialog (verstößt gegen `feedback_ui_error_surfacing`). Hintergrund: die wt-Planning-Session ist interaktiv; ClaudeDo erfasst die claude-Session-Id dort nicht (analog zur bewusst nicht persistierten ConPTY-Session-Id, §5). Fix-Optionen: (a) beim Planning-Start/-Lauf die claude-Session-Id erfassen und via `UpdatePlanningSessionIdAsync` persistieren, damit Resume echt resumen kann; ODER (b) Resume entfernen/deaktivieren, wenn keine Session-Id vorliegt; in JEDEM Fall den Fehler statt des leeren `catch` surfacen. Verifiziert §3. §3.
- **Attachments: erster Drag&Drop der Session schlug einmalig fehl („An error occurred"):** Beim allerersten Drop-to-attach einer UI-Session zeigte die `DropStatus`-Zeile inline einen generischen Fehler und es wurde nichts persistiert (kein File, keine DB-Row); alle folgenden Drops derselben Session + der „Add file…"-Picker + Remove funktionierten fehlerfrei. Nicht reproduzierbar nach dem ersten Mal (App-Neustart nötig, um die „erster-Drop"-Bedingung wiederherzustellen). Kandidaten: transiente SQLite-Contention (der UI-Prozess schreibt `todo.db` direkt via `new TaskAttachmentRepository`, während der Worker dieselbe DB hält) ODER ein Fehler im Drop-Stream-Pfad (`IStorageFile.OpenReadAsync` im Code-Behind, außerhalb des `try` in `AddFilesAsync`). Falls es erneut auftritt: `AddFilesAsync`/`OnDrop` mit robusterem Error-Logging versehen (die genaue Exception-Message fehlt, weil `DropStatus` nur `{fileName}: {ex.Message}` zeigt). Verifiziert §9 (einmalig beobachtet). §9.
- **Agent-Settings-Gear weicht vom übrigen Settings-Icon ab:** Der Agent-Settings-Flyout-Button (`TaskHeaderBar.axaml:68`) rendert ein Unicode-Glyph `⚙` (`<TextBlock Text="⚙">`), während die Listen-Nav (`ListsIslandView.axaml:56`) und die Listen-Settings (`TasksIslandView.axaml:47`) das gefüllte `Icon.Settings`-PathIcon (Gear-StreamGeometry, IslandStyles.axaml:110) nutzen → optisch ein anderes Zahnrad. Angleichen: den `⚙`-TextBlock durch `<PathIcon Data="{StaticResource Icon.Settings}" .../>` ersetzen. Verifiziert §8 (User-Sichtprüfung). §8.
- **Session-Skills-Tab hat keinen Empty-State:** Bei 0 installierten Skills zeigt der Skills-Tab (Settings) nur eine nackte leere Fläche unter der Install-Zeile — kein erklärender Hinweis (z.B. „Noch keine Skills installiert — GitHub-URL oben einfügen"). Verifiziert §8 (frischer Zustand vor ponytail-Install). Kleiner Empty-State-Text ergänzen.
- **Planning-aktiver Parent zeigt weiter „Idle":** Parent in `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber `TaskRowViewModel.StatusLabel` (Zeile 132) kennt nur `HasInteractiveSession`/`IsParked` als Overrides — der `PlanningBadge` ersetzt den Chip nicht sichtbar → liest sich wie ein normaler Idle-Task. Wunsch: klarer „Planning/Draft aktiv"-Zustand, der Idle überschreibt. (Kinder zeigen korrekt „Draft".)
- **Blocked-by-Kette nicht sichtbar:** Nach Finalize ist die sequentielle Kette korrekt gesetzt (child[i] blocked-by child[i-1]), aber die UI stellt Reihenfolge/Abhängigkeit nicht dar — es gibt nur den „waiting"-Chip. Wunsch: Kette visualisieren (z.B. „wartet auf <Vorgänger>").
- **Dequeue-„X" fehlt auf wartenden (blockierten) Kettengliedern:** `CanRemoveFromQueue = IsQueued || HasQueuedSubtasks` (TaskRowViewModel.cs:99), `IsQueued` verlangt leeres `blocked_by`. Ein gequeuetes, aber blockiertes Kind (`IsWaiting`) bekommt daher kein Remove-from-queue-X — nur Parent + erstes (entsperrtes) Kind.
- **Session-Skills-Tab hat keinen Empty-State:** Bei 0 installierten Skills zeigt der Skills-Tab (Settings) nur eine nackte leere Fläche unter der Install-Zeile — kein erklärender Hinweis (z.B. „Noch keine Skills installiert — GitHub-URL oben einfügen"). Im `sessionSkillsTab`-Locale-Namespace (en.json:674) gibt es keinen `empty`-Key.
- **Modal-Bodies sind unten abgeschnitten** (Sichtprüfung 2026-08-06, beobachtet in den Listen-Settings: der Hinweistext unter „Verify command" ist mitten in der Zeile vom Footer weggeschnitten und lässt sich nicht herunterscrollen; laut Mika „fast überall" in den Settings-Modals). Gemeinsames Muster: `<ScrollViewer Padding="20,16">` als Modal-Body — in `ListSettingsModalView:36`, `MergeModalView:33`, `WorktreesOverviewModalView:131`, `AboutModalView:20`, `MergeHelperSelectionModal:47`, `RepoImportModalView:45`, `LogVisualizerView:45` sowie `WorkConsole:295/395`. **Vermutete** Ursache (nicht verifiziert): das untere `Padding` des ScrollViewers zählt nicht zum scrollbaren Extent, die letzten Pixel sind also unerreichbar. Erst am echten Fall nachmessen, dann ggf. einheitlich das Padding vom ScrollViewer auf ein `Margin` des inneren Inhalts umziehen.
- **Usage-Monitor-Modal: Analyse-Tabellen ausbaufähig** (Sichtprüfung 2026-08-06; Gauges, Info-Bänder, Presets/Custom-Range und Refresh-Button sind in Ordnung). Models-Tab: die Spaltenköpfe „OTHER CACHE" und „SHARE" kollidieren zu `OTHER CACHISHARE`; `claude-haiku-4-5-20251001` läuft in die IN-Spalte; alle Zahlen linksbündig und ohne Tausendertrenner (`506830708`). Tasks-Tab: Task-Titel wird ohne Ellipse hart an der LIST-Spalte abgeschnitten, der LIST-Text ebenfalls; MODEL ist bei Runs von vor der `task_runs.model`-Spalte leer (besser „—"). Wunsch: Zahlen rechtsbündig + `#,##0` (oder k/M), Spaltenbreiten/Truncation fixen.
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar:** Beim 2-Datei-Konflikt schwer zu sehen, dass zwei Dateien betroffen sind (File-Switcher/Anzahl zu unauffällig). Prominentere Datei-Liste / „x von y Dateien".
- **Attachments: erster Drag&Drop der Session schlug einmalig fehl („An error occurred"):** Beim allerersten Drop-to-attach einer UI-Session zeigte die `DropStatus`-Zeile inline einen generischen Fehler und es wurde nichts persistiert (kein File, keine DB-Row); alle folgenden Drops derselben Session + der „Add file…"-Picker + Remove funktionierten fehlerfrei. Nicht reproduzierbar nach dem ersten Mal. Kandidaten: transiente SQLite-Contention (der UI-Prozess schreibt `todo.db` direkt via `new TaskAttachmentRepository`, während der Worker dieselbe DB hält) ODER ein Fehler im Drop-Stream-Pfad (`IStorageFile.OpenReadAsync` im Code-Behind, außerhalb des `try` in `AddFilesAsync`). Falls es erneut auftritt: `AddFilesAsync`/`OnDrop` mit robusterem Error-Logging versehen.
- **Diff-Viewer: reiner Rename schwach dargestellt:** Rename korrekt erkannt (`UnifiedDiffParser``DiffFileStatus.Renamed`, Badge `StatusCode="R"`), aber der File-Tree zeigt nur den neuen Namen + „+0 0" ohne „alt → neu"-Pfad; liest sich wie „keine Änderung". Optional: alten Pfad + „renamed"-Label anzeigen. §1.
- **Header-TurnsText zeigt `0/max` für abgeschlossene Runs:** `TurnsText => {Turns}/{EffectiveMaxTurns}``Turns` wird beim Laden eines terminalen Tasks nicht aus `task_runs.turnCount` restauriert (nur live gefüllt). Kosmetisch. §1.
- **Conflict-Resolver: Continue/Merge-Button klickbar trotz offener Konflikte:** Gate greift funktional (merged erst wenn alle Konflikte in allen Dateien gelöst), aber der Button ist nicht disabled/gegraut → früher Klick ist ein stummer No-op. Besser: disabled bis `AllResolved`, oder Hinweis „N Konflikte in M Dateien offen". §4.
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar:** Beim 2-Datei-Konflikt schwer zu sehen, dass zwei Dateien betroffen sind (File-Switcher/Anzahl zu unauffällig). Prominentere Datei-Liste / „x von y Dateien". §4.
- **Planning-aktiver Parent zeigt weiter „Idle":** Parent in `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber der Row-Status-Chip zeigt „Idle"; der `PlanningBadge` ersetzt das nicht sichtbar → liest sich wie ein normaler Idle-Task. Wunsch: klarer „Planning/Draft aktiv"-Zustand, der Idle überschreibt. (Kinder zeigen korrekt „Draft".) §3.
- **Blocked-by-Kette nicht sichtbar:** Nach Finalize ist die sequentielle Kette korrekt gesetzt (child[i] blocked-by child[i-1]), aber die UI stellt die Reihenfolge/Abhängigkeit nicht dar. Wunsch: Kette visualisieren (z.B. „wartet auf <Vorgänger>"), nicht nur der „waiting"-Chip nach dem Queueen. §3.
- **Dequeue-„X" fehlt auf wartenden (blockierten) Kettengliedern:** `CanRemoveFromQueue = IsQueued || HasQueuedSubtasks`, `IsQueued` verlangt leeres `blocked_by`. Ein gequeuetes, aber blockiertes Kind (`IsWaiting`) bekommt daher kein Remove-from-queue-X — nur Parent + erstes (entsperrtes) Kind. §3.
- **„Open ConPTY session" erneut = Prompt wird neu gesendet, kein Resume:** Da die ConPTY-Session-Id bewusst nicht persistiert wird, startet ein erneutes „Open ConPTY session" auf demselben Task eine frische Session und sendet den Task-Prompt erneut (re-runt die Arbeit im selben Worktree) statt zu resumen. So designt, aber UX-Falle — evtl. „Resume"-Affordance oder Re-Open-Warnung. §5.
## Feature-Wünsche
## Feature-Wünsche (aus der Session)
- **Conflict-Resolver: farbliches Hervorheben eingefügter Zeilen im Result-Pane** (grüner „flow" der übernommenen Zeilen).
- **Die Handler-Session soll „Submit for review" selbst auslösen können** (Sichtprüfung 2026-08-06). Aktuell ist der Abschluss eines List-Handler-Laufs ein reiner Handgriff über die Schaltfläche in der Mission-Control-Kachel — die Session selbst hat kein Werkzeug dafür, obwohl sie am besten weiß, wann sie fertig ist. Wunsch: ein MCP-Tool auf der In-Task-Oberfläche, das denselben Pfad wie der Button nimmt.
- **Handoff-Kachel besser beschriften** (Sichtprüfung 2026-08-06). Die zweite Kachel heißt `Merge Helper — <Liste> (Handoff)` bzw. „(Übergabe)" (`missionControl.mergeHelperHandoffTitleSuffix`, en.json/de.json:297). „Handoff" ist internes Vokabular und sagt nicht, was die Session tut. Besser etwas, das die Rolle nennt (Ausführen + Mergen der verbliebenen Tasks, Phasen 3-5).
- **Conflict-Resolver: farbliches Hervorheben eingefügter Zeilen im Result-Pane** (grüner „flow" der übernommenen Zeilen). §4.
- Als ClaudeDo-Tasks in Liste „Claude do" erfasst: **Approve erzwingt Diff/Review vor Merge** (koppelt den blocked-Merge-Silent-Fail-Fix) und **interaktive Planning-Session über embedded ConPTY statt externem wt-Fenster** (koppelt den Planning-Permission-Prompt).
## Design-Entscheidungen (27.07.-Batch, Sichtprüfung am 2026-08-06 abgeschlossen)
## Sichtprüfung offen (2026-07-27, Batch „Claude do"-Liste)
Alles gebaut + unit-getestet, aber **nicht visuell verifiziert** — Mika prüft:
- **Ctrl+K fokussiert die Suche, `#` ist wieder tippbar** (Binding war `OemQuestion` = `#` auf DE-Layout).
- **Titel-Edit in der Detail-Insel persistiert** (400 ms debounced, wie die Beschreibung); Row-Titel + Merge-Kontext ziehen mit.
- **Spinner beim ConPTY-Start:** Tile erscheint sofort mit „Sitzung wird gestartet…" und deckt Launch-Spec-Roundtrip + Spawn ab. **Nicht** abgedeckt: die Sekunden, die die claude-TUI danach zum ersten Frame braucht (dafür bräuchte es einen Hook auf `TerminalControl.DataReceived`). Bei Launch-Fehler bleibt das Tile jetzt mit Inline-Banner stehen statt zu verschwinden.
- **Spinner beim Refine** ersetzt den Refine-Button in der Row, solange der Run läuft.
- **Interactive-Chip** statt „Parked" auf Tasks mit offener ConPTY-Session; Klick öffnet Mission Control und fokussiert die Pane. Accent-Tint (bewusst dieselbe „live"-Familie wie Running, klar unterschieden vom slate-blauen Parked).
- **Diff-Viewer:** rechter Abstand der `+n n`-Zahlen im File-Tree.
- **Manual-Tasks:** MANUAL-Badge, Kontextmenü-Toggle, Listen-Checkbox „Manuelle Liste"; Queue/Refine/Planning ausgeblendet, ConPTY bleibt.
- **Settings → Allgemein:** Tabelle „Vorgaben pro Modell" (Effort + Max. Durchläufe je haiku/sonnet/opus/fable). Ersetzt das einzelne globale „Max. Durchläufe"-Feld.
Offene Entscheidungen dazu:
Der Sichtprüfungs-Block dieses Batches (Ctrl+K/`#`, Titel-Edit, ConPTY-/Refine-Spinner,
Interactive-Chip, Diff-Viewer-Abstände, Manual-Tasks, Vorgaben-pro-Modell-Tabelle) ist von Mika
verifiziert und deshalb hier entfernt. Es bleiben die Entscheidungen, die daran hängen:
- Interaktive ConPTY-Sessions bekommen `--effort`, aber **kein** `--model` — die Session läuft weiter unter dem Modell aus Mikas Claude-Config, der Effort kommt aus dem Preset des Modells, das ClaudeDo für die Task auflösen würde. Falls ClaudeDo auch interaktiv das Modell erzwingen soll, ist das ein Folge-Task.
- `AppSettings.DefaultMaxTurns` ist nur noch Fallback für ein Modell ohne Preset-Zeile und hat keinen Editor mehr. Spalte könnte später entfallen.
- `AppSettings.DefaultMaxTurns` ist jetzt tatsächlich verdrahtet (`ModelPresets.For(..., global.DefaultMaxTurns)` in `TaskRunner.ResolveConfigAsync`): reiner Fallback für ein Modell, das auch nach `ModelRegistry.TryNormalizeAlias` auf keine Preset-Zeile trifft — vorher war das Feld tot (hartkodierte 30). Hat weiterhin keinen eigenen Editor mehr (nur die Preset-Tabelle pro Modell); Spalte könnte später entfallen, falls das nie zutrifft.
## Beobachtung (offen — Entscheidung Mika)
- **`--permission-mode auto` + Modell `haiku` → Writes werden denied:** Kontrolliert verifiziert (CLI 2.1.207): unter dem Default-Mode `auto` bekommt **sonnet** Writes auto-approved (`permission_denials:[]`), **haiku** wird `denied` (`permission_denials:[Write]`, keine Datei) — eine haiku-Task macht unter `auto` still nichts und landet ohne Änderung in `WaitingForReview`. Normalbetrieb (Default = sonnet) nicht betroffen. KEINE CLI-Regression, sondern modellabhängiges `auto`-Verhalten. Optionen falls es nervt: haiku aus der Auswahl nehmen, ODER Runner auf `acceptEdits`/`bypassPermissions` (modell-unabhängig). Mika: erstmal beobachten. Siehe Memory `auto_permission_haiku_footgun`.
- ~~**`QueueServiceTests.UsageGate_TransitionLogging_FiresOncePerChange` ist zeitbasiert flaky**~~ — **am 2026-08-06 gefixt**: der feste `Task.Delay(200)` ist durch die schon im selben File vorhandene `AssertStableCountAsync` ersetzt (pollt bis der erste Backstop-Tick geloggt hat, wartet dann eine Karenzzeit und stellt sicher, dass kein weiterer Tick nachlegt). Historischer Befund zur Einordnung: Schlägt reproduzierbar fehl (`Expected 1, Actual 0` Warn-Log-Aufrufe), sowohl solo (`--filter`) als auch im Vollauf, auf einem sauberen `git worktree add` gegen `main` (bdee731) — also **kein** durch diese Abschluss-Session verursachter Regress (die Session hat keine `.cs`-Datei angefasst). Ursache: der Test verlässt sich auf einen festen `Task.Delay(200)`, um mehrere 50-ms-Backstop-Ticks abzuwarten (Kommentar im Test: „Several backstop ticks (50ms interval) all observe the same blocked state"); auf einer stark ausgelasteten Maschine (hier: viele parallele ClaudeDo-Worktrees/Builds) reicht das Fenster nicht immer. Zum Vergleich: derselbe Test lief in einer zweiten, isolierten Verifikation (Scratch-Merge für den Environment-Checks-Task) sauber durch (876/876). Fix wäre ein Poll-basiertes Warten statt fixem Sleep — aber außerhalb des Scopes dieser Doku/Verifikations-Session (keine Code-Änderung angefasst).
---
@@ -62,8 +48,193 @@ Offene Entscheidungen dazu:
- **List handler (2026-07-27)** — visual pass: the Broom button is gone from the lists footer,
the context-menu item appears only on lists with a working dir, and the selection dialog has no
LIST column. Plus a real-Claude smoke run of the five phases (dedupe questions, enhancements
landing in task descriptions, queued execution, merges).
LIST column. (Der real-Claude-Smoke-Run der fünf Phasen ist am 2026-07-29 gelaufen — 6/6 sauber
gemerged; nur die Sichtprüfung ist noch offen.)
## Offene Verifikation (2026-08-05)
- **List handler owns a task (2026-08-05)** — **visuell verifiziert am 2026-08-06** (Lauf über die
Liste `ClaudeDoTests` mit 4 Tasks, davon 2 absichtliche Dubletten): genau ein neuer Task, Mission-
Control-Tile task-basiert mit „Submit for review", Dedupe hat die Dublette erkannt,
Beschreibungen wurden angereichert, Submit → `WaitingForReview`, und die Diff-Karte zeigte alle
drei Dateien des Laufs. Korrektur zur Erwartung oben: der Task-Chip liest sich **„Interactive"**,
nicht `Idle` — das ist korrekt so (`HasInteractiveSession` überschreibt den Status-Chip, solange
die ConPTY-Session läuft). Die drei dabei gefundenen Punkte stehen unter „Bugs" bzw.
„Feature-Wünsche".
- **Roadblock reply field (2026-08-05)** — **Kernfunktion am 2026-08-06 verifiziert**: auf einem
Task mit gemeldetem Roadblock erscheint das Antwortfeld unter dem Roadblock-Text, die Antwort
setzt die Session fort, und der Task landet danach sauber auf `WaitingForReview`. Dabei ist der
Bug oben aufgefallen (Feld deaktiviert, solange der Task seit vor dem Lauf offen ist). Layout der
Roadblock-Karte ist in Ordnung. **Noch offen:** Enter-zum-Senden, und dass ein „Override-Slot
besetzt"-Fehler im Footer-Strip landet (nicht als Modal) mit dem getippten Text weiterhin im
Feld. Design-Entscheidung dazu: der Review-Bereich lebt als zwei nullable Spalten
direkt auf `TaskEntity` (kein Phantom-`WorktreeEntity`), damit `list_worktrees`/die
Worktrees-Übersicht ihn nie sehen.
- **Post-merge verify gate (2026-08-05)** — build + unit tests all green (incl. real-process
`VerifyCommandRunner` exit-code/output/timeout tests and `TaskMergeService` success/failure/
timeout paths via a fake runner), but **not visually verified**: open a list's Settings modal,
confirm the new "VERIFICATION" section renders below Agent with a settable/clearable
`VerifyCommand` field; approve a task on a list with a failing command configured and confirm
the footer/error surfacing (`ShowErrorAsync`) actually shows the verify failure message instead
of silently looking like nothing happened. Also no real-build smoke test (a real `dotnet build`/
`dotnet test` invocation as the configured command) — only fast synthetic commands (`exit N`,
`ping` for timeout) were exercised.
## Offene Verifikation (2026-08-11, Task numbers in UI)
Slice 4 der Task-Numbers-Features (UI-Display) ist gemerged (commit 38af549); Build + Tests grün,
aber **nicht visuell verifiziert**:
- **Task Row Display:** `TaskRowViewModel.Number` (Zeile 39, TaskRowViewModel.cs) zeigt die
Nummer als `#<number>` dimmed vor dem Titel in der Row an (`TaskRowView.axaml` Zeile 45+). Prüfen:
offene Task in der Übersicht hat sichtbar `#<number>` vor dem Titel.
- **Detail Pane Header:** `DetailsIslandViewModel.TaskIdBadge` (Zeile 77, DetailsIslandViewModel.cs)
zeigt jetzt `#<number>` statt des alten GUID-Präfix. Prüfen: Task-Detail-Chip zeigt
`#<number>`.
- **Worker Log Messages:** Geschäftsereignisse in `TaskRunner` (Zeile 199+), `TaskMergeService`
(Zeile 174+), und `TaskResetService` (Zeile 84+) prefixen Task-Titel mit `#<number>`. Prüfen:
Footer Worker-Log zeigt Task-Messages wie „`#42 finished`" statt nur dem GUID.
## Offene Verifikation (2026-08-06, Fix-Batch aus der Sichtprüfung)
Fünf Findings der Sichtprüfung sind gefixt, Build + Tests grün, aber **noch nicht in der App
nachgeprüft** (der laufende Build ist älter — erst nach Neuinstallation testbar):
- **NumericUpDown-Leeren wirft nicht mehr:** neuer `KeepLastNumberConverter` bildet beim
ConvertBack `null` auf `BindingOperations.DoNothing` ab, an allen acht nicht-nullbaren
NumericUpDowns im Settings-Modal verdrahtet. Prüfen: Wert löschen und neu tippen — keine
Exception, alter Wert bleibt stehen, bis eine echte Zahl kommt. Damit ist auch der offene Rest
von „MaxTurnsCeiling ändern → Hinweistexte ziehen mit" nachholbar.
- **Verify-Gate greift jetzt auch ohne Worktree:** Approve auf einem List-Handler-Task mit
fehlschlagendem `VerifyCommand` muss `verify_failed` liefern und den Task aus `Done` halten
(vorher ging er kommentarlos auf `Done`).
- **`verify_failed` im Merge-Modal und in der Worktrees-Übersicht:** Modal zeigt statt „Unknown
status" den echten Fehlertext und schließt sich **nicht** automatisch; die Batch-Übersicht
zeigt `VerifyFailed` und markiert die Zeile trotzdem als `Merged` (der Merge ist ja gelandet).
- **Roadblock-Reply/Continue live:** Task offen lassen, während er in den Roadblock läuft — das
Antwortfeld und Continue müssen **ohne** erneutes Öffnen aktiv werden.
- **Cleanup-Log:** ein Worktree-Cleanup über veraltete DB-Zeilen darf keine WARN-Flut mehr im
Footer erzeugen (die „schon erledigt"-Fälle gehen auf Debug).
- **Handoff schließt die alte Kachel:** beim Übergang von Phase 2 auf Phase 3 muss die alte
Merge-Helper-Kachel verschwinden und die neue an ihrer Stelle stehen — keine leere Geister-Pane
mehr. (Entscheidung Mika 2026-08-06: automatisch schließen statt Output erhalten.)
## Offene Verifikation (2026-08-06, Resume planning session)
`planning_session_id` wurde nie befüllt (der Setter hatte keinen Aufrufer), weil die interaktive
Planning-Session ihre claude-Session-Id nie zurückmeldet — Resume konnte deshalb nie
funktionieren. `PlanningSessionManager.ResumeAsync` liest die Id jetzt beim ersten Resume aus dem
Transkript, das Claude Code unter `~/.claude/projects/<encodiertes cwd>/<sessionId>.jsonl` für den
Planning-Worktree ablegt (`PlanningTranscriptLocator`), und persistiert sie. Unit-Tests grün,
**nicht visuell verifiziert**:
- Planning-Session starten, Fenster/Pane schließen, Task erneut öffnen → „Resume" muss die
ConPTY-Pane mit der **fortgesetzten** Unterhaltung öffnen (nicht mit leerem Verlauf).
- Ohne Transkript (z. B. Session nie wirklich gestartet): Fehlermeldung „No Claude session
transcript found…" landet sichtbar im Footer-Error-Strip (`Terminal.StartError`
`ErrorReported`), nicht still.
- **Risiko:** das Verzeichnis-Encoding (`~/.claude/projects/`, jedes Nicht-Alphanumerische wird
`-`) ist undokumentiertes CLI-Verhalten — ändert es sich, findet der Locator nichts und Resume
meldet sauber „cannot resume" (fail-safe, kein falscher Resume).
## Offene Verifikation (2026-08-05, Usage Monitor)
> **Kein Light-Theme.** `App.axaml:6` setzt `RequestedThemeVariant="Dark"` fest, es gibt keinen
> Umschalter und `Tokens.axaml` kennt keine Light-Variante. „Dark/Light"-Checks sind deshalb
> überall aus dieser Datei entfernt — sie waren nie erfüllbar.
- **Visueller Pass Usage-Pill** (Footer **und** Mission-Control-Header) — **am 2026-08-06
verifiziert**: Text/Tooltip lesbar, Dot-Zustände plausibel, Pill in Footer und
Mission-Control-Header identisch.
- **Visueller Pass Usage-Monitor-Modal** — **am 2026-08-06 verifiziert**: Gauges (dynamisch aus
`limits[]`), Info-Bänder (der Throttle-Hinweis „Queue throttled: 1/3 slots (7d)" stand real an),
7d/30d-Presets + Custom-Range. Die Analyse-Tabellen sind ausbaufähig → siehe „UX / Nits".
- **E2E Gate**: das Gate greift real, sobald ein Bucket (`five_hour`/`seven_day`) die
konfigurierte Schwelle reißt — Queue-Nachschub pausiert, laufende Runs/`RunNow`/ConPTY/
Planning/Prime bleiben unberührt — und die Queue nimmt nach dem Reset selbstständig wieder
auf (30s-Backstop, kein persistenter Pause-Zustand).
- **Risiko:** der Usage-Endpoint (`GET https://api.anthropic.com/api/oauth/usage`) ist
undokumentiert und kann sich ändern; bei Ausfall/Formatänderung ist das Gate wirkungslos
(fail-open by design — kein Blocker, aber der Schutz fällt dann aus, ohne dass es auffällt).
### Nachtrag 2026-08-05: 429-Fix (Poll-Kadenz + Refresh-Button)
Der 60s-Poll lief in 429s. Neu: aktivitätsabhängige Kadenz (5 Min. solange ein Task `Running`
ist, sonst 15 Min.), 429-Backoff mit `Retry-After`, und ein „Jetzt aktualisieren"-Button im
Usage-Monitor-Modal (`RefreshUsage``UsageMonitorService.RefreshNowAsync`, 10s-Cooldown).
Unit-Tests grün, **offen**:
- **Visueller Pass Refresh-Button** im Modal — **am 2026-08-06 verifiziert** (Button, Hinweiszeile
„Polled every 5 min while a task runs, otherwise every 15 min.").
- **E2E:** über ≥20 Min. mit und ohne laufenden Task beobachten, dass keine 429s mehr im
Worker-Log auftauchen und die Pill trotzdem aktuell bleibt.
- **Beachten:** die Pill wird jetzt erst nach 3× 15 Min. als `stale` markiert — ein echter
Endpoint-Ausfall fällt vorher nur über `LastError` auf (der `IsStale` sofort setzt).
## Offene Verifikation (2026-08-05, Max-Turns-Ceiling)
Build + unit tests grün (`ResolveMaxTurns`-Klemmung, Repository-Backfill von `model_presets`,
Migration `AddMaxTurnsCeiling` gegen eine Scratch-DB angewendet), aber **nicht visuell
verifiziert**:
- Agent-Settings-Editor (Task **und** Liste): Max-Turns-Feld auf einen Wert über der Ceiling
(Default 80) setzen, Hinweistext unter dem `NumericUpDown` erscheint ("Runs are capped at
{N} turns…").
- Settings → Allgemein → Vorgaben pro Modell: eine Zeile über 80 setzen, derselbe Hinweistext
erscheint unter der Zeile.
- `MaxTurnsCeiling` ist seit `2975f90` selbst editierbar (Settings → Allgemein,
`SettingsModalView.axaml`) — Feld prüfen: Wert ändern, speichern, Hinweistexte oben ziehen mit.
## Offene Verifikation (2026-08-05, Environment Checks / SystemCheckPage)
Checks + SystemCheckPage (`claudedo/06aca9b3…`) und das ExecutableResolver-Wiring im Worker
(`claudedo/40272c0b…`) sind seit 2026-08-06 auf `main` gemerged; die frühere „erst mergen"-
Voraussetzung ist erledigt. Details → `installer-preflight` in `docs/explore-notes/README.md`
und der Abschnitt „Environment Checks" in `src/ClaudeDo.Installer/CLAUDE.md`.
**Update (2026-08-06):** beide Folge-Features sind jetzt implementiert und auf `main`.
- Diagnose-Sektion (Config-Modus/`SettingsWindow`): `Pages/DiagnosePage/` + geteilte
`Checks/CheckListViewModel.cs`/`Checks/CheckListView.xaml` (auch von `SystemCheckPage`
genutzt, keine zweite Implementierung). Unit-getestet
(`tests/ClaudeDo.Installer.Tests/Pages/DiagnosePage/DiagnosePageViewModelTests.cs`).
- „Claude Help Me"-Button: `Core/ClaudeHelpLauncher.cs` + `SystemCheckPageViewModel`/-View,
unit-getestet (`tests/ClaudeDo.Installer.Tests/Core/ClaudeHelpLauncherTests.cs`).
Beides **nicht visuell verifiziert**:
- [x] Help-Me-Button ist deaktiviert, wenn `claude-cli` nicht `Ok` ist oder `claude-auth`
`Failed` ist (bleibt aktiv bei `Unknown`), mit erklärendem Tooltip — unit-getestet.
- [ ] „Claude Help Me" öffnet tatsächlich ein Terminal mit laufender Claude-Session, und die
Session hat den Diagnose-Report gelesen — **nicht verifiziert** (der eigentliche
Terminal-Start/`wt.exe`-Zusammenspiel und die Session-Qualität sind nur über die
injizierte `IProcessLauncher`-Fake getestet, nie mit einem echten Terminal/CLI).
- [ ] Platzierung des Help-Me-Buttons: er sitzt seit dem Merge der Diagnose-Sektion in einer
**eigenen Zeile unter** dem geteilten Check-Listen-Footer (der reservierte Slot *im*
Footer entfiel mit der Extraktion nach `CheckListView.xaml`). Optisch prüfen, ob das
so bleiben soll oder ob der Button in den geteilten Footer gehört.
- [ ] Diagnose-Sektion im Config-Modus: Öffnen von SettingsWindow löst keinen Prüflauf aus, Klick
auf „Erneut prüfen" schon; zeigt die echten installierten Pfade/Ports (nicht die
InstallContext-Defaults), und der laufende Worker auf dem konfigurierten SignalR-Port gilt
nicht als Konflikt — **unit-verifiziert, visueller Durchlauf noch offen.**
Weitere Punkte, gebaut + unit-getestet auf `main`, aber **nicht visuell verifiziert**:
- [ ] SystemCheckPage: Layout, Icon-/Farbwirkung der vier Status (Ok grün / Warnung orange /
Fehler rot / Unbekannt grau — `StatusGreenBrush`/`StatusOrangeBrush`/`StatusRedBrush`/
`StatusGrayBrush`), Lesbarkeit der Hint-Texte, DE und EN.
- [ ] Weiter-Button gesperrt bei einem echten blockierenden Fehler (z. B. `claude` nicht im
PATH → `claude-cli` Error/Failed), und der Grund ist in der Zusammenfassungszeile
sichtbar (nennt den/die blockierenden Check(s) namentlich).
- [ ] „Erneut prüfen" wechselt einen Status live (z. B. git-Identity setzen → Warnung
verschwindet), ohne dass ein zweiter paralleler Lauf startet, wenn währenddessen erneut
geklickt wird.
- [ ] Update-Modus zeigt die SystemCheckPage **nicht** (Wizard bleibt Welcome + Install).
- [ ] Auf einem Rechner mit npm-installiertem `claude.cmd`: `claude-cli`-Check findet es
(Detail-Text „Resolved via a shim…"), und ein Task läuft im Worker durch (bestätigt, dass
`ClaudeProcess`/`ClaudeCliPreflight` den Shim über `cmd.exe /c` tatsächlich startet, nicht
nur, dass der Check ihn findet).
---
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,939 @@
# Handler-Run Links Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A "Let Claude handle it" run records which tasks it processed, shows them as a list on the handler task's detail pane, and wears a HANDLER badge instead of MANUAL.
**Architecture:** One new nullable column `TaskEntity.HandlerTaskId` (1:n, last run wins) stamped at handler-task creation from the selection the UI already passes down. The badge is a display-only computed property on `TaskRowViewModel`, driven by the existing `HandlerBaseCommit`. The panel reuses `ChildOutcomeRowViewModel` and the existing refresh path.
**Tech Stack:** .NET 8, EF Core (SQLite), Avalonia 12 + CommunityToolkit.Mvvm, xUnit.
**Spec:** `docs/superpowers/specs/2026-08-07-handler-run-links-design.md`
---
## File Structure
**Modified:**
- `src/ClaudeDo.Data/Models/TaskEntity.cs` — new `HandlerTaskId` property
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — column mapping + index
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs``SetHandlerTaskIdAsync`
- `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs` — stamp after creating the handler task
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs``HandlerBaseCommit`, `IsHandlerRun`, `HandlerBadge`, `ManualBadge` precedence
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — HANDLER badge border
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml``HandlerBadgeBrush` + `Border.badge.handler`
- `src/ClaudeDo.Localization/locales/en.json` + `de.json``tasks.badgeHandler`, `tasks.handlerTip`
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs``HandledTasks` collection, loader, clear, refresh
- `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — HANDLED TASKS panel
- `src/ClaudeDo.Data/CLAUDE.md`, `src/ClaudeDo.Ui/CLAUDE.md`, `docs/explore-notes/conpty-sessions.md` — docs
**Created:**
- `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` (+ Designer, + snapshot update) — generated
- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`
- `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`
- `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`
---
## Task 1: Data — `HandlerTaskId` column and migration
**Files:**
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs:60-61`
- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs:96-97` and `:127-131`
- Create: `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` (generated)
- [ ] **Step 1: Add the property**
In `src/ClaudeDo.Data/Models/TaskEntity.cs`, directly after the existing `HandlerHeadCommit` line (`public string? HandlerHeadCommit { get; set; }`), add:
```csharp
// Id of the "list handler" run task that processed this task ("Let Claude handle it").
// 1:n and last-run-wins -- a second handler run over the same task overwrites it. Deliberately
// NOT ParentTaskId: that is the planning-child relation and drives the indented tree rendering.
// No FK: a deleted handler task must not cascade into the tasks it merely touched.
public string? HandlerTaskId { get; set; }
```
- [ ] **Step 2: Map the column and index it**
In `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`, after the line
`builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");` add:
```csharp
builder.Property(t => t.HandlerTaskId).HasColumnName("handler_task_id");
```
At the end of `Configure`, after the line
`builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");` add:
```csharp
builder.HasIndex(t => t.HandlerTaskId).HasDatabaseName("idx_tasks_handler_task_id");
```
Do **not** add a `HasOne`/`HasForeignKey` relationship — the column is intentionally FK-less.
- [ ] **Step 3: Generate the migration**
Run from the repo root:
```bash
dotnet ef migrations add AddHandlerTaskId --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: creates `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` + `.Designer.cs` and updates `ClaudeDoDbContextModelSnapshot.cs`. The `Up` method must contain exactly one `AddColumn<string>(name: "handler_task_id", table: "tasks", nullable: true)` and one `CreateIndex(name: "idx_tasks_handler_task_id", table: "tasks", column: "handler_task_id")`. If it contains anything else, another agent's uncommitted model change leaked in — delete the migration, coordinate, retry.
If `dotnet ef` is unavailable, hand-author the migration + Designer mirroring
`src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.cs`, and add
`Property<string>("HandlerTaskId").HasColumnType("TEXT").HasColumnName("handler_task_id");`
plus the index to the `TaskEntity` builder in `ClaudeDoDbContextModelSnapshot.cs`.
- [ ] **Step 4: Build**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release`
Expected: `Build succeeded`, 0 errors.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations
git commit -- src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations -m "feat(data): add handler_task_id to link handled tasks to their handler run"
```
⚠️ Always commit with explicit paths (`git commit -- <paths>`), never a bare `git commit` — the
main checkout is shared with concurrent sessions.
---
## Task 2: Data — `SetHandlerTaskIdAsync` repository method
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (after `SetHandlerHeadCommitAsync`, currently `:394-403`)
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Repositories;
/// Covers the handler-run link: SetHandlerTaskIdAsync stamps the tasks a "Let Claude handle it"
/// run processed, so the handler task's detail pane can list them after the run.
public sealed class TaskRepositoryHandlerLinkTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
public TaskRepositoryHandlerLinkTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
private async Task<string> CreateListAsync()
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity
{
Id = listId,
Name = "Test List",
CreatedAt = DateTime.UtcNow,
});
return listId;
}
private async Task<string> AddTaskAsync(string listId)
{
var id = Guid.NewGuid().ToString();
await _tasks.AddAsync(new TaskEntity
{
Id = id,
ListId = listId,
Title = "T",
Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
});
return id;
}
[Fact]
public async Task SetHandlerTaskIdAsync_StampsAllGivenTasks()
{
var listId = await CreateListAsync();
var a = await AddTaskAsync(listId);
var b = await AddTaskAsync(listId);
var handlerId = await AddTaskAsync(listId);
var affected = await _tasks.SetHandlerTaskIdAsync(new[] { a, b }, handlerId);
Assert.Equal(2, affected);
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(b))!.HandlerTaskId);
Assert.Null((await _tasks.GetByIdAsync(handlerId))!.HandlerTaskId);
}
[Fact]
public async Task SetHandlerTaskIdAsync_IgnoresUnknownIds()
{
var listId = await CreateListAsync();
var a = await AddTaskAsync(listId);
var handlerId = await AddTaskAsync(listId);
var affected = await _tasks.SetHandlerTaskIdAsync(
new[] { a, "does-not-exist" }, handlerId);
Assert.Equal(1, affected);
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
}
[Fact]
public async Task SetHandlerTaskIdAsync_SecondRunOverwrites()
{
var listId = await CreateListAsync();
var a = await AddTaskAsync(listId);
var firstHandler = await AddTaskAsync(listId);
var secondHandler = await AddTaskAsync(listId);
await _tasks.SetHandlerTaskIdAsync(new[] { a }, firstHandler);
await _tasks.SetHandlerTaskIdAsync(new[] { a }, secondHandler);
Assert.Equal(secondHandler, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
}
[Fact]
public async Task SetHandlerTaskIdAsync_EmptyList_IsNoOp()
{
var listId = await CreateListAsync();
var handlerId = await AddTaskAsync(listId);
var affected = await _tasks.SetHandlerTaskIdAsync(Array.Empty<string>(), handlerId);
Assert.Equal(0, affected);
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"`
Expected: compile error — `TaskRepository` does not contain a definition for `SetHandlerTaskIdAsync`.
- [ ] **Step 3: Implement the method**
In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, directly after `SetHandlerHeadCommitAsync`, add:
```csharp
// Links the tasks a "list handler" run processed back to the handler's own task, so the
// handler's detail pane can list them after the run. Stamped from the user's selection at
// creation time -- that way tasks the handler later cancels as duplicates stay visible.
// Unknown ids are silently skipped. Returns the number of rows actually stamped.
public async Task<int> SetHandlerTaskIdAsync(
IReadOnlyList<string> taskIds,
string handlerTaskId,
CancellationToken ct = default)
{
if (taskIds.Count == 0) return 0;
var ids = taskIds.Where(id => id != handlerTaskId).Distinct().ToList();
if (ids.Count == 0) return 0;
return await _context.Tasks
.Where(t => ids.Contains(t.Id))
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.HandlerTaskId, handlerTaskId), ct);
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"`
Expected: `Passed! - Failed: 0, Passed: 4`.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs
git commit -- src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs -m "feat(data): add SetHandlerTaskIdAsync to stamp handled tasks"
```
---
## Task 3: Worker — stamp the selection when the handler task is created
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:409-450`
- Test: `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` (append a `[Fact]` in the `── CreateMergeHelperTaskAsync ──` region, currently starting at `:747`)
Note: `CreateMergeHelperTaskAsync` already receives `IReadOnlyList<string> taskIds` — the UI →
`IWorkerClient``WorkerHub` chain needs **no** change.
- [ ] **Step 1: Write the failing test**
Append to `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` inside the same test class, after the existing `CreateMergeHelperTaskAsync_CreatesIdleManualTask_StampsHandlerBaseCommit` test:
```csharp
[Fact]
public async Task CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var listId = await SeedListAsync(workingDir: repo.RepoDir, name: "Alpha");
var t1 = Guid.NewGuid().ToString();
var t2 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService();
var handlerId = await svc.CreateMergeHelperTaskAsync(
new[] { t1, t2 }, listId, "List handler: Alpha", "Tasks handled by this run:", CancellationToken.None);
using var readCtx = _db.CreateContext();
var tasks = new TaskRepository(readCtx);
Assert.Equal(handlerId, (await tasks.GetByIdAsync(t1))!.HandlerTaskId);
Assert.Equal(handlerId, (await tasks.GetByIdAsync(t2))!.HandlerTaskId);
// The handler never links to itself.
Assert.Null((await tasks.GetByIdAsync(handlerId))!.HandlerTaskId);
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks"`
Expected: FAIL — `Assert.Equal() Failure: Values differ … Actual: null`.
- [ ] **Step 3: Stamp the selection**
In `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs`, in `CreateMergeHelperTaskAsync`, replace:
```csharp
await taskRepo.AddAsync(handlerTask, ct);
return handlerTask.Id;
```
with:
```csharp
await taskRepo.AddAsync(handlerTask, ct);
// Link the selection back to this run BEFORE the session starts: the handler cancels
// duplicates in phase 1, and those still belong in the "what was this run supposed to do"
// list. Stamping later (e.g. at handoff) would lose them.
await taskRepo.SetHandlerTaskIdAsync(taskIds, handlerTask.Id, ct);
return handlerTask.Id;
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync"`
Expected: `Passed! - Failed: 0` (all five `CreateMergeHelperTaskAsync` tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
git commit -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs -m "feat(handler): link the selected tasks to the handler run task"
```
---
## Task 4: Ui — HANDLER badge instead of MANUAL
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:40,51,234-240,308-329`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml:141-144`
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml:114-118` and `:987-990`
- Modify: `src/ClaudeDo.Localization/locales/en.json:163-164`, `src/ClaudeDo.Localization/locales/de.json:163-164`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`:
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Islands;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
/// A "list handler" host task is IsManual=true so automation skips it, but MANUAL reads wrong on
/// it -- the HANDLER badge must win and MANUAL must disappear.
public class TaskRowViewModelHandlerBadgeTests
{
[Fact]
public void HandlerTask_ShowsHandlerBadge_AndSuppressesManualBadge()
{
var row = new TaskRowViewModel { Id = "t1" };
row.IsManual = true;
row.HandlerBaseCommit = "base123";
Assert.True(row.IsHandlerRun);
Assert.NotNull(row.HandlerBadge);
Assert.Null(row.ManualBadge);
}
[Fact]
public void PlainManualTask_StillShowsManualBadge()
{
var row = new TaskRowViewModel { Id = "t2" };
row.IsManual = true;
Assert.False(row.IsHandlerRun);
Assert.Null(row.HandlerBadge);
Assert.NotNull(row.ManualBadge);
}
[Fact]
public void UpdateFromEntity_MirrorsHandlerBaseCommit()
{
var row = new TaskRowViewModel { Id = "t3" };
row.UpdateFromEntity(new TaskEntity
{
Id = "t3",
ListId = "l1",
Title = "List handler: Alpha",
Status = TaskStatus.Idle,
IsManual = true,
HandlerBaseCommit = "base123",
CreatedAt = DateTime.UtcNow,
});
Assert.Equal("base123", row.HandlerBaseCommit);
Assert.True(row.IsHandlerRun);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"`
Expected: compile error — `TaskRowViewModel` has no `HandlerBaseCommit` / `IsHandlerRun` / `HandlerBadge`.
- [ ] **Step 3: Add the properties**
In `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`, after the `_isManual` field
declaration (`[ObservableProperty] private bool _isManual;`), add:
```csharp
// Mirror of TaskEntity.HandlerBaseCommit -- non-null marks this row as a "list handler" run
// host task ("Let Claude handle it"), which wears HANDLER instead of MANUAL.
[ObservableProperty] private string? _handlerBaseCommit;
```
Replace the `ManualBadge` line (currently `public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;`) with:
```csharp
public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit);
public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null;
// HANDLER outranks MANUAL: a handler host task is IsManual only so automation skips it, and
// "MANUAL" would read as a hand-written reminder. The two badges never show together.
public bool ShowManualBadge => IsManual && !IsHandlerRun;
public string? ManualBadge => ShowManualBadge ? Loc.T("tasks.badgeManual") : null;
```
Add a change hook next to the existing `OnIsManualChanged` partial method:
```csharp
partial void OnHandlerBaseCommitChanged(string? value)
{
OnPropertyChanged(nameof(IsHandlerRun));
OnPropertyChanged(nameof(HandlerBadge));
OnPropertyChanged(nameof(ShowManualBadge));
OnPropertyChanged(nameof(ManualBadge));
}
```
Inside the existing `OnIsManualChanged`, next to the existing `OnPropertyChanged(nameof(ManualBadge));` line, add:
```csharp
OnPropertyChanged(nameof(ShowManualBadge));
```
In `UpdateFromEntity`, after the line `IsManual = t.IsManual;` add:
```csharp
HandlerBaseCommit = t.HandlerBaseCommit;
```
Also add `HandlerBadge` to `RefreshLocalized`, next to the existing `PlanningBadge` line:
```csharp
OnPropertyChanged(nameof(HandlerBadge));
OnPropertyChanged(nameof(ManualBadge));
```
- [ ] **Step 4: Add the locale keys**
In `src/ClaudeDo.Localization/locales/en.json`, after `"manualTip": ...` (line 164) add:
```json
"badgeHandler": "HANDLER",
"handlerTip": "Handler run — see the tasks it processed in the detail pane",
```
In `src/ClaudeDo.Localization/locales/de.json`, after `"manualTip": ...` (line 164) add:
```json
"badgeHandler": "HANDLER",
"handlerTip": "Handler-Run — die bearbeiteten Tasks stehen im Detailbereich",
```
- [ ] **Step 5: Add the badge style and brush**
In `src/ClaudeDo.Ui/Design/IslandStyles.axaml`, after the line
`<SolidColorBrush x:Key="ManualBadgeBrush" Color="{StaticResource TextFaintColor}"/>` add:
```xml
<SolidColorBrush x:Key="HandlerBadgeBrush" Color="{StaticResource PeatSoftColor}"/>
```
After the existing `Border.badge.manual` style block add:
```xml
<!-- handler → peat: a "Let Claude handle it" run host, not a hand-written reminder -->
<Style Selector="Border.badge.handler">
<Setter Property="Background" Value="{DynamicResource HandlerBadgeBrush}"/>
</Style>
```
- [ ] **Step 6: Render the badge**
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, replace the manual badge block (lines 141-144):
```xml
<Border Classes="badge manual" IsVisible="{Binding IsManual}"
ToolTip.Tip="{loc:Tr tasks.manualTip}">
<TextBlock Text="{Binding ManualBadge}"/>
</Border>
```
with:
```xml
<Border Classes="badge manual" IsVisible="{Binding ShowManualBadge}"
ToolTip.Tip="{loc:Tr tasks.manualTip}">
<TextBlock Text="{Binding ManualBadge}"/>
</Border>
<Border Classes="badge handler" IsVisible="{Binding IsHandlerRun}"
ToolTip.Tip="{loc:Tr tasks.handlerTip}">
<TextBlock Text="{Binding HandlerBadge}"/>
</Border>
```
Only the `IsVisible` binding changed on the manual border (`IsManual``ShowManualBadge`); the
handler border is new. No converter is needed — `ShowManualBadge` is already a `bool`.
- [ ] **Step 7: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"`
Expected: `Passed! - Failed: 0, Passed: 3`.
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
Expected: `Passed! - Failed: 0` (en/de key parity).
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: `Build succeeded` — this compiles the AXAML.
- [ ] **Step 8: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs
git commit -- src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs -m "feat(ui): show a HANDLER badge on list-handler run tasks"
```
---
## Task 5: Ui — "HANDLED TASKS" panel on the handler's detail pane
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:248-255`, `:581-584`, `:685`, `:814-833`
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:414-434`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
/// Covers the handler-run link: binding a "list handler" host task lists every task stamped with
/// its id, including ones the handler cancelled as duplicates.
public class DetailsIslandHandledTasksTests : IDisposable
{
private readonly string _dbPath;
public DetailsIslandHandledTasksTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_handled_test_{Guid.NewGuid():N}.db");
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
{
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) =>
Task.FromResult<DailyNoteDto?>(null);
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask;
}
private sealed class FakeWorkerClient : StubWorkerClient
{
public override bool IsConnected => true;
}
private DetailsIslandViewModel BuildVm()
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(
factory, new FakeWorkerClient(), new NullServiceProvider(), new StubNotesApi(), new MergeCoordinator());
}
[Fact]
public async Task Bind_HandlerTask_ListsHandledTasksWithTheirStatus()
{
const string listId = "list-1";
const string handlerId = "handler-task-1";
await using (var ctx = NewContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = @"C:\repo", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity
{
Id = handlerId, ListId = listId, Title = "List handler: L",
Status = TaskStatus.WaitingForReview, IsManual = true,
HandlerBaseCommit = "base123", HandlerHeadCommit = "head456",
CreatedAt = DateTime.UtcNow,
});
ctx.Tasks.Add(new TaskEntity
{
Id = "done-1", ListId = listId, Title = "Merged task",
Status = TaskStatus.Done, HandlerTaskId = handlerId,
SortOrder = 0, CreatedAt = DateTime.UtcNow,
});
ctx.Tasks.Add(new TaskEntity
{
Id = "dupe-1", ListId = listId, Title = "Duplicate the handler cancelled",
Status = TaskStatus.Cancelled, HandlerTaskId = handlerId,
SortOrder = 1, CreatedAt = DateTime.UtcNow,
});
ctx.Tasks.Add(new TaskEntity
{
Id = "unrelated-1", ListId = listId, Title = "Not part of the run",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var vm = BuildVm();
vm.Bind(new TaskRowViewModel { Id = handlerId, Status = TaskStatus.WaitingForReview });
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.HandledTasks.Count == 0)
await Task.Delay(20);
Assert.Equal(2, vm.HandledTasks.Count);
Assert.True(vm.HasHandledTasks);
Assert.Equal("Merged task", vm.HandledTasks[0].Title);
Assert.Equal(TaskStatus.Done, vm.HandledTasks[0].Status);
Assert.Equal(TaskStatus.Cancelled, vm.HandledTasks[1].Status);
Assert.DoesNotContain(vm.HandledTasks, r => r.Id == "unrelated-1");
}
[Fact]
public async Task Bind_PlainTask_HasNoHandledTasks()
{
const string listId = "list-1";
const string taskId = "plain-1";
await using (var ctx = NewContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity
{
Id = taskId, ListId = listId, Title = "Plain",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var vm = BuildVm();
vm.Bind(new TaskRowViewModel { Id = taskId, Status = TaskStatus.Idle });
await Task.Delay(300);
Assert.Empty(vm.HandledTasks);
Assert.False(vm.HasHandledTasks);
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"`
Expected: compile error — `DetailsIslandViewModel` has no `HandledTasks` / `HasHandledTasks`.
- [ ] **Step 3: Add the collection**
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, after the line
`public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();` add:
```csharp
// Tasks a "list handler" run processed ("Let Claude handle it"), linked via
// TaskEntity.HandlerTaskId. Separate from ChildOutcomes on purpose: that collection is the
// planning/improvement parent's children and feeds the merge card's combined diff, which a
// handler run must not touch (it commits straight to the list's working dir).
public ObservableCollection<ChildOutcomeRowViewModel> HandledTasks { get; } = new();
```
After the line `public bool HasChildOutcomes => ChildOutcomes.Count > 0;` add:
```csharp
public bool HasHandledTasks => HandledTasks.Count > 0;
```
- [ ] **Step 4: Clear it on rebind**
In the same file, in the rebind reset block, after the line `ChildOutcomes.Clear();` add:
```csharp
HandledTasks.Clear();
```
and after `OnPropertyChanged(nameof(HasChildOutcomes));` in that same block add:
```csharp
OnPropertyChanged(nameof(HasHandledTasks));
```
- [ ] **Step 5: Load it on bind**
In the same file, directly after the line `await LoadChildOutcomesAsync(row.Id, ct);` add:
```csharp
await LoadHandledTasksAsync(row.Id, ct);
```
Then add the loader immediately after the closing brace of `LoadChildOutcomesAsync`:
```csharp
// Tasks stamped with this handler run's id. Ordered like the task list itself so the panel
// reads in the same order the user picked them.
private async System.Threading.Tasks.Task LoadHandledTasksAsync(string handlerTaskId, CancellationToken ct)
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var handled = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.Where(t => t.HandlerTaskId == handlerTaskId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct);
ct.ThrowIfCancellationRequested();
if (handled.Count == 0) return;
HandledTasks.Clear();
foreach (var h in handled)
HandledTasks.Add(new ChildOutcomeRowViewModel
{
Id = h.Id,
Title = h.Title,
Status = h.Status,
RoadblockCount = h.RoadblockCount,
WorktreeState = h.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active,
});
OnPropertyChanged(nameof(HasHandledTasks));
}
catch (OperationCanceledException) { }
catch { /* best-effort */ }
}
```
- [ ] **Step 6: Keep the rows live**
In the same file, in `RefreshChildOutcomeAsync`, replace:
```csharp
var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId);
if (row is null) return;
```
with:
```csharp
// The same refresh serves both lists: a planning parent's children and a handler run's
// handled tasks. Only one of them can hold a given id.
var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId)
?? HandledTasks.FirstOrDefault(c => c.Id == childTaskId);
if (row is null) return;
```
- [ ] **Step 7: Render the panel**
In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, directly after the closing
`</StackPanel>` of the existing `<!-- Child outcomes -->` block, add:
```xml
<!-- Handled tasks (list handler run) -->
<StackPanel Spacing="6" IsVisible="{Binding HasHandledTasks}">
<TextBlock Classes="section-label" Text="HANDLED TASKS" />
<ItemsControl ItemsSource="{Binding HandledTasks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Text="{Binding RoadblockText}"
IsVisible="{Binding HasRoadblock}"
Foreground="#E0A030"
Margin="8,0" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Text="{Binding StatusLabel}"
Opacity="0.75" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
```
- [ ] **Step 8: Run the tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"`
Expected: `Passed! - Failed: 0, Passed: 2`.
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: `Build succeeded`.
- [ ] **Step 9: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs
git commit -- src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs -m "feat(ui): list the tasks a handler run processed on its detail pane"
```
---
## Task 6: Full verification and docs
**Files:**
- Modify: `src/ClaudeDo.Data/CLAUDE.md` (TaskEntity field list)
- Modify: `src/ClaudeDo.Ui/CLAUDE.md` (TaskRowViewModel + DetailsIslandViewModel bullets)
- Modify: `docs/explore-notes/conpty-sessions.md` (list handler → host task section)
- [ ] **Step 1: Run every affected test project**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
Expected: `Failed: 0` in all four. If a hand-rolled fake in a test project fails to compile,
it is one of the known `IWorkerClient`/ViewModel-ctor fakes — update it; do not skip the test.
- [ ] **Step 2: Update `src/ClaudeDo.Data/CLAUDE.md`**
In the `TaskEntity` bullet, append `HandlerTaskId` to the field enumeration (after
`HandlerBaseCommit / HandlerHeadCommit`), and add a sub-bullet under the existing
`HandlerBaseCommit`/`HandlerHeadCommit` sub-bullet:
```markdown
- `HandlerTaskId` = back-link from a task to the **list handler run** that processed it (1:n, last run wins, no FK). Stamped from the user's selection when the handler task is created, so tasks the handler later cancels as duplicates stay listed. Deliberately not `ParentTaskId` — that is the planning-child relation and drives the indented tree.
```
- [ ] **Step 3: Update `src/ClaudeDo.Ui/CLAUDE.md`**
In the `DetailsIslandViewModel` bullet, after the `ChildOutcomes` mention, add
`, plus `HandledTasks` (tasks a list-handler run processed, via `HandlerTaskId`)`.
In the `TaskRowViewModel` sentence, after the `IsManual` clause, add
`, `IsHandlerRun` (→ HANDLER badge, which outranks MANUAL)`.
- [ ] **Step 4: Update `docs/explore-notes/conpty-sessions.md`**
In the "The host task and its commit range" section, add after the existing description:
```markdown
`CreateMergeHelperTaskAsync` also stamps `TaskEntity.HandlerTaskId` on every selected task
(`TaskRepository.SetHandlerTaskIdAsync`) before the session starts, so the handler task's detail
pane can list what the run was meant to process — including tasks phase 1 cancels as duplicates.
The handler never links to itself.
```
Bump that note's "verified against" commit line to the current HEAD.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md
git commit -- src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md -m "docs(handler): document the handler-run task link"
```
- [ ] **Step 6: Report the visual-verification gap**
The build and tests cannot confirm any of this renders correctly. Explicitly hand these to Mika:
1. HANDLER badge colour and legibility on a handler task row (light **and** dark theme), and that MANUAL is gone from that row while still present on a normal manual reminder.
2. The HANDLED TASKS panel on the handler task's Session tab: position relative to OUTCOMES, spacing, and behaviour with ~20 handled tasks (scroll).
3. That a real "Let Claude handle it" run over a multi-task selection produces a populated panel after the run, including a phase-1-cancelled duplicate.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,495 @@
# Feedback für langlaufende Operationen — Vorgaben für die Umsetzung
> **Dieses Dokument ist absichtlich kein Task-Skript.** Es enthält die Vorgaben pro Gruppe.
> Die konkreten Tasks schreibt der **Merge-Helper je Gruppe** beim Ausführen — er kennt dann
> den tatsächlichen Codestand. Was hier steht, ist bindend; was hier nicht steht, entscheidet
> er. Checkboxen stehen auf Paketebene, damit der Fortschritt sichtbar bleibt.
**Design:** `docs/superpowers/specs/2026-08-11-operation-feedback-design.md` — dort stehen die
Belege (Datei:Zeile) und die Begründungen. Dieses Dokument wiederholt sie nicht.
**Ziel:** Jede Operation, die länger als ~300 ms dauern kann, zeigt an, dass sie läuft, was sie
tut und wie lange sie schon läuft — über **einen** Mechanismus statt pro Fall neu gebaut.
---
## Ausführungsmodell
```
P0 (Fundament) ── muss ALLEIN und ZUERST landen
├── Gruppe A UI-Stille Merge-Helper 1
├── Gruppe B UI-Freeze Merge-Helper 2
└── Gruppe C Worker-Stille Merge-Helper 3
Gruppe D MCP-Stille Merge-Helper 4 ── unabhängig von P0, kann sofort starten
```
**Ein Merge-Helper pro Gruppe.** Nach P0 laufen A, B, C und D parallel. Jede Gruppe hält sich
strikt an ihr Datei-Eigentum — das ist die einzige Absicherung gegen gegenseitiges
Überschreiben, weil alle im gemeinsamen `main`-Checkout arbeiten.
### Datei-Eigentum (bindend)
| Gruppe | Besitzt exklusiv | Darf **nicht** anfassen |
|---|---|---|
| **P0** | `Ui/Services/OperationStatus.cs` (neu), `Ui/Views/Controls/OperationIndicator.axaml(.cs)` (neu), **beide `locales/*.json`**, `Ui/Services/WorkerClient.cs` (nur Timing-Hook) | alles andere |
| **A** | Island-VMs, Modal-VMs, deren AXAML, `Ui/Services/UpdateCheckService.cs` | `locales/*`, `WorkerClient`, `IWorkerClient`, Worker-Projekt |
| **B** | `Ui/ViewModels/Modals/DiffViewerViewModel.cs`, `Ui/Views/Controls/DiffTextView.axaml.cs` | `locales/*`, Island-VMs, Worker-Projekt |
| **C** | `Worker/Hub/HubBroadcaster.cs`, `Worker/Hub/WorkerHub.cs`, `Ui/Services/WorkerClient.cs`, `IWorkerClient.cs`, `StubWorkerClient.cs`, `Worker/Lifecycle/*Recovery.cs`, `Worker/Runner/WorktreeManager.cs`, `Worker/Worktrees/WorktreeMaintenanceService.cs` | `locales/*`, Island-VMs, `Worker/External/*` |
| **D** | `Worker/External/*McpTools.cs`, `Worker/External/ExternalMcpService.cs`, `Worker/Lifecycle/TaskMergeService.cs` (nur die Progress-Schleife) | `locales/*`, alles im Ui-Projekt |
**Locale-Regel:** Nur P0 schreibt in `en.json`/`de.json`. Braucht eine Gruppe doch einen Key,
den P0 nicht vorgesehen hat: als **letzte** Änderung des Pakets anhängen und im Merge-Helper
als bekannte Konfliktstelle behandeln. Nie mitten in der Gruppe.
---
## Vorgaben für den Merge-Helper selbst
Gilt für alle vier Gruppen:
- **Agent-Modell:** `sonnet` für Implementierer und Reviewer. Nie haiku, nie opus, nie Fable.
- **`maxTurns` explizit auf 200 setzen.** `model_presets` ist NULL, sonst bekommt ein
sonnet-Task 30 Turns und stirbt mit „exited with code 1 and no result".
- **`serializeOnFileOverlap` auf der Gruppenliste einschalten.** Innerhalb einer Gruppe
fassen mehrere Pakete dieselben Dateien an.
- **Bauen:** `dotnet build ClaudeDo.slnx` schlägt auf .NET 8 fehl. Einzelprojekte mit
`-c Release` bauen (ein laufender Worker sperrt `Debug`):
```
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
```
- **Testen — pro Gruppe die relevanten Projekte:**
```
A, B dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/... -c Release (nur wenn Keys berührt)
C Ui.Tests + ClaudeDo.Worker.Tests (Worker.Tests enthält auch UiVm-Tests)
D dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
P0 Ui.Tests + Localization.Tests
```
Fällt ein Test innerhalb der Verify-Kette, erst die Suite **allein** laufen lassen — die
`Ui.Tests` haben eine bekannte reihenfolgen-abhängige Flakiness, die nichts mit dem Merge zu
tun hat.
- **Nie `git add -A`.** Immer explizit nach Pfad stagen und `git commit -- <pfade>` — der
`main`-Checkout ist von parallelen Sessions geteilt, ein blankes Commit fegt fremde WIP mit.
- **Nach dem Batch-Merge `main` selbst prüfen.** Approve baut und testet nicht. Neun grüne
Branches haben `main` schon zweimal zerlegt, davon einmal durch eine Test-Kollision, die ein
reiner src-Build nicht sieht. Also: Build **und** die betroffenen Testprojekte.
- **Vor Approve prüfen, ob der Branch überhaupt etwas enthält** (`changedFileCount`). Ein
blockierter Task wird sonst `Done` mit leerem Branch.
- **Keine EF-Migration** in diesem Vorhaben. Parallele Migrationen aus Geschwister-Branches
löschen sich beim SQLite-Table-Rebuild gegenseitig die Spalten, und die Tests bemerken es
nicht (`EnsureCreated`).
- **Keine Tests, die die echte `claude`-CLI starten.**
- **Visuelle Prüfung ist nicht durch Tests ersetzbar.** Jedes Paket in A und B endet mit einer
offenen visuellen Prüfung für den Nutzer. Nie behaupten, die UI funktioniere.
---
## P0 — Fundament
- [ ] **P0-1 — `OperationStatus` + `OperationIndicator` + Locale-Keys**
- [ ] **P0-2 — Timing-Hook für die Messung**
- [ ] **P0-3 — Regel in den CLAUDE.md-Dateien**
**Vorbedingung:** keine. Muss allein landen, bevor A/B/C starten.
### Der Vertrag (bindend — 19 Pakete hängen daran)
`src/ClaudeDo.Ui/Services/OperationStatus.cs`, eine `ObservableObject`-Klasse:
| Member | Verhalten |
|---|---|
| `bool IsRunning` | **sofort** `true` bei `Begin`. Treibt `CanExecute`, verhindert Doppelklick |
| `bool ShowIndicator` | erst nach **300 ms** `true`. Kein Flackern bei schnellen Calls |
| `string? Label` | lokalisierter Text |
| `string Elapsed` | `mm:ss`, **lokal getickt** |
| `bool IsStalled` | `true` nach **60 s ohne `Report`** |
| `IDisposable Begin(string label)` | startet; `Dispose` beendet **auch im Exception-Fall** |
| `void Report(string label)` | überschreibt `Label` mid-flight, setzt die Stall-Uhr zurück |
**Nachbesserung 1 (bindend):** `IsStalled` bemisst sich an der Zeit **seit dem letzten
`Report`**, nicht an der Gesamtdauer. Ein regulär mehrminütiges Verify-Gate darf sich nicht
selbst als hängend melden. Eine Operation ohne jeden `Report` gilt nach 60 s als stalled — das
ist genau der Fall, der heute wie ein Absturz aussieht.
Benutzung im ViewModel:
```csharp
using var op = Approve.Begin(Loc.T("ops.merge.merging"));
var result = await _worker.ApproveReviewAsync(...);
```
### Weitere Vorgaben
- **Zeitquelle injizierbar** über `TimeProvider` (in .NET 8 vorhanden). **Kein statischer
`DispatcherTimer`** — geteilter statischer State ist die Ursache der reihenfolgen-abhängigen
Flakiness in den `Ui.Tests`. Timer-Callbacks kommen vom Threadpool und müssen auf den
Dispatcher gepostet werden.
- **Mehrere `OperationStatus` pro VM sind erwünscht.** `WorktreesOverviewModalViewModel`
braucht getrennte für Refresh, Cleanup und Merge — sonst blockiert ein laufender Refresh die
Cleanup-Anzeige.
- **`OperationStatus` transportiert keine Fehler.** Fehlerbehandlung bleibt unverändert über
`ShowErrorAsync` / `ErrorReported` / `FlashFooterError`.
- **`OperationIndicator`** nutzt `Ellipse.spinner` aus `Design/IslandStyles.axaml` (14×14,
Accent, 0.9 s Rotation) plus Label und Elapsed. Ohne dieses Control wird die
Spinner-StackPanel aus `MergeModalView.axaml` acht Mal von Hand nachgebaut. Werte aus
`Tokens.axaml` verwenden, keine Inline-Zahlen.
- **Locale-Keys:** neuer Top-Level-Namespace `ops` in `en.json` und `de.json`, Parität wird von
`Localization.Tests` erzwungen. P0 legt die Keys für **A und C** vorab an — abgeleitet aus den
Paketlisten unten.
**Nachbesserung 3 (bindend):** **Gruppe D braucht keine Locale-Keys.**
MCP-Progress-Meldungen gehen an Agenten, nicht an den Nutzer, und bleiben englische
Klartext-Strings.
### P0-2: Messung an genau einer Stelle
Zwei Hooks, nicht zwanzig:
1. In `WorkerClient` jeden Hub-Invoke mit Dauer loggen.
2. Im DB-Pfad der Islands dasselbe.
Ein Tag Nutzung liefert eine sortierte Liste echter Ausreißer. **A5 und C werten sie aus**,
statt zu raten. Bild 2 und 4 brauchen keine Messung.
Faustregel für den Zweifelsfall: instrumentiert wird, was git aufruft, Netz nutzt, einen
Prozess startet, oder O(n) über unbegrenzt viele DB-Zeilen läuft. Einzelzeilen-Reads nicht.
### Definition of Done für P0
`OperationStatus` hat Unit-Tests mit einem Fake-`TimeProvider` für: 300-ms-Grace, 60-s-Stall
**ab letztem Report**, Elapsed-Formatierung, `Dispose` nach Exception, `Report` überschreibt
Label und setzt die Stall-Uhr zurück. `Localization.Tests` grün. Kein VM ist umgestellt — P0
liefert nur das Werkzeug.
### Task-Entwürfe (grob)
**P0-1 · `OperationStatus` + `OperationIndicator` + `ops`-Locale-Keys**
Baue das Primitive nach dem Vertrag oben, dazu das Anzeige-Control und den neuen
Locale-Namespace `ops` in beiden Sprachdateien. Keys für Gruppe A und C vorab anlegen, für D
keine. Zeitquelle über `TimeProvider`, kein statischer Timer.
*Dateien:* `Ui/Services/OperationStatus.cs` (neu), `Ui/Views/Controls/OperationIndicator.axaml(.cs)` (neu), `Localization/locales/{en,de}.json`, `tests/ClaudeDo.Ui.Tests/`
*Fertig wenn:* Unit-Tests für Grace, Stall-ab-Report, Elapsed, Dispose-nach-Exception, Report-Reset. Localization.Tests grün. Kein ViewModel angefasst.
**P0-2 · Timing-Hook für die Messung**
Zwei Hooks: Dauer jedes Hub-Invokes in `WorkerClient`, Dauer des DB-Pfads der Islands. Ziel
sind auswertbare Zahlen, keine Anzeige. Ausgabe so, dass sie nach einem Tag Nutzung sortierbar
ist.
*Dateien:* `Ui/Services/WorkerClient.cs`, der gemeinsame DB-Zugriffspfad der Island-VMs
*Fertig wenn:* Beide Hooks aktiv, Overhead vernachlässigbar, das Format ist dokumentiert.
**P0-3 · Feedback-Regel in den CLAUDE.md-Dateien**
`src/ClaudeDo.Ui/CLAUDE.md`: jeder `IWorkerClient`-Call in einem `[RelayCommand]` läuft durch
eine `OperationStatus`, Anzeige über `OperationIndicator`. `src/ClaudeDo.Worker/CLAUDE.md`: ein
MCP-Tool, das über ~5 s laufen kann, reportet Progress.
*Fertig wenn:* Beide Regeln stehen, jeweils mit einem Satz Begründung.
---
## Gruppe A — Bild 1: UI-Stille
- [ ] **A1 — Detail-Pane** ⚠️ *der gemeldete Fall*
- [ ] **A2 — WorktreesOverview**
- [ ] **A3 — Settings-Tabs**
- [ ] **A4 — Reports und Planning-UI**
- [ ] **A5 — Island-DB-Pfade nach Messdaten**
**Vorbedingung:** P0 gemergt.
### Vorgaben
- **A1 zuerst.** `DetailsIslandViewModel.ApproveReviewAsync` ist der gemeldete Schmerz. Dazu im
selben Paket: `SubmitForReviewAsync`, `RejectReviewAsync`, `ParkReviewAsync` und
`MergeSectionViewModel.PreviewMergeAsync`.
- **A1 abonniert `IWorkerClient.MergeProgressEvent`**, um das Label mid-flight von „merging" auf
„verifying" zu schärfen. Das Event existiert seit `cad0582`.
**Nachbesserung 2 (bindend):** A verlässt sich darauf, dass dieses Event **bestehen bleibt**.
C1 baut den generischen Kanal darunter, muss `MergeProgressEvent` aber als dünnen Forwarder
erhalten. Andernfalls müsste C `DetailsIslandViewModel` ändern — eine Datei aus Gruppe A —
und die Parallelität wäre zerstört. **A darf keinen anderen Kanal verwenden.**
- **Abo nur für die Dauer des Calls.** Der Worker broadcastet an alle Clients; ein dauerhaft
registriertes VM bekommt Events für fremde Tasks. `MergeModalViewModel` macht es richtig
vor: `+=` im `try`, `-=` im `finally`, plus `if (taskId != TaskId) return;`.
- **A2:** `IsBusy` existiert dort schon, schaltet aber nur `IsEnabled`. Die Anzeige fehlt
komplett. Betrifft Refresh, Cleanup, Reset, ForceRemove und Batch-Merge — Batch-Merge
zusätzlich mit Zeilen-Status, weil dort N Tasks sequenziell durchlaufen.
- **A3:** SessionSkill-Install ist ein `git clone` — der offensichtlichste Kandidat. Dazu
Update/Restore-Defaults, OnlineInbox-SignIn, RepoImport-Scan, Update-Check.
- **A4:** GenerateWeekReport, RunDailyPrepNow, BuildPlanningIntegrationBranch,
GetPlanningAggregate, Finalize/QueuePlanningSubtasks. `DiffViewerViewModel.IsLoadingCombined`
existiert bereits und bleibt — nicht doppeln.
- **A5 erst nach Auswertung von P0-2.** Nur Stellen umstellen, die die Messung als Ausreißer
zeigt. Kein Umstellen auf Verdacht.
- **`DetailsIslandViewModel` ist groß.** Nicht umstrukturieren, nur die Commands anfassen.
- **Bereits saubere Flächen nicht anfassen:** `MergeModalViewModel`,
`ConflictResolverViewModel`, `DiffViewerViewModel.IsLoadingCombined`,
`UsageMonitorModalViewModel`.
### Definition of Done pro Paket
Jeder umgestellte Command: `IsRunning` während des Laufs gesetzt, `CanExecute` gesperrt,
Zustand nach einer Exception zurückgesetzt — als Test in `ClaudeDo.Ui.Tests`. Kein
handgebauter Spinner, immer `OperationIndicator`. **Visuelle Prüfung offen und explizit
benannt** (Grace-Periode und Layout kann kein Test bestätigen).
### Task-Entwürfe (grob)
**A1 · Detail-Pane: Approve, Submit, Reject, Park, Preview** ⚠️ *der gemeldete Fall*
Fünf Commands im Detail-Pane bekommen je eine `OperationStatus` und einen `OperationIndicator`
neben dem auslösenden Button. Approve abonniert zusätzlich `MergeProgressEvent` **nur für die
Dauer des Calls** und filtert auf die eigene TaskId, um das Label von „merging" auf „verifying"
zu schärfen. `DetailsIslandViewModel` nicht umstrukturieren.
*Dateien:* `Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, `MergeSectionViewModel.cs`, `Ui/Views/Islands/Detail/*`
*Fertig wenn:* Approve zeigt während des Verify-Gates Phase und Elapsed, Button ist gesperrt, Zustand nach Fehler zurückgesetzt. Visuelle Prüfung offen.
**A2 · WorktreesOverview: Anzeige für alle fünf Aktionen**
`IsBusy` existiert, schaltet aber nur `IsEnabled`. Getrennte `OperationStatus` für Refresh,
Cleanup, Reset, ForceRemove und Batch-Merge — der Batch-Merge zusätzlich mit Zeilen-Status, weil
er N Tasks sequenziell durchläuft.
*Dateien:* `Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`, `Ui/Views/Modals/WorktreesOverviewModalView.axaml`
*Fertig wenn:* Jede der fünf Aktionen zeigt sichtbar, dass sie läuft; ein laufender Refresh blockiert die Cleanup-Anzeige nicht. Visuelle Prüfung offen.
**A3 · Settings-Tabs: Skill-Install, Restore-Defaults, OnlineInbox, RepoImport, Update-Check**
Fünf Flächen, bei denen `IsBusy` heute nur den Button sperrt. Der Skill-Install ist ein
`git clone` und der offensichtlichste Kandidat.
*Dateien:* `Ui/ViewModels/Modals/Settings/*`, `Ui/ViewModels/Modals/RepoImportModalViewModel.cs`, `Ui/Services/UpdateCheckService.cs`, `Ui/Views/Modals/SettingsModalView.axaml`
*Fertig wenn:* Alle fünf zeigen Aktivität; der Skill-Install zeigt zusätzlich, dass geklont wird. Visuelle Prüfung offen.
**A4 · Reports und Planning-UI**
GenerateWeekReport, RunDailyPrepNow, BuildPlanningIntegrationBranch, GetPlanningAggregate,
Finalize/QueuePlanningSubtasks. `DiffViewerViewModel.IsLoadingCombined` existiert bereits und
bleibt unangetastet.
*Dateien:* `Ui/ViewModels/Modals/WeeklyReportModalViewModel.cs`, Planning-Pfade in `DetailsIslandViewModel`/`TasksIslandViewModel`, zugehörige AXAML
*Fertig wenn:* Report-Generierung und Planning-Integration zeigen Aktivität; kein Doppel-Indikator im Combined-Diff. Visuelle Prüfung offen.
**A5 · Island-DB-Pfade nach Messdaten**
**Erst die Zahlen aus P0-2 auswerten**, dann nur die Ausreißer umstellen. Kandidaten:
`ClearCompleted`, Drag-Reorder über viele Zeilen, Laden großer Listen. Nichts auf Verdacht.
*Dateien:* nach Messergebnis
*Fertig wenn:* Die Auswertung ist im Task dokumentiert, und jede umgestellte Stelle ist durch eine Messung begründet. Wenn nichts über der Schwelle liegt: Task mit Begründung schließen, nichts bauen.
---
## Gruppe B — Bild 2: UI-Freeze
- [ ] **B1 — `UnifiedDiffParser.Parse` auslagern**
- [ ] **B2 — `DiffAlignment.Build` Grenze**
- [ ] **B3 — Guard-Test gegen Dispatcher-Blockade**
**Vorbedingung:** P0 gemergt (für den Indikator in B1).
### Vorgaben
- **Reihenfolge ist zwingend: erst auslagern, dann anzeigen.** Ein Spinner auf einem
blockierten UI-Thread wird nicht gezeichnet. B1 verschiebt `UnifiedDiffParser.Parse` nach
`Task.Run` und setzt danach den Indikator.
- **`UnifiedDiffParser` ist statisch und rein** — thread-safe, `Task.Run` ist unkritisch. Das
Zurückschreiben der Ergebnisse in Observable-Collections muss auf dem UI-Thread passieren.
- **B2 braucht eine Entscheidung, die der Merge-Helper trifft:** `DiffAlignment.Build` läuft in
`DiffTextView.axaml.cs` — in einem Control, nicht in einem VM. Entweder Grenze („ab N Zeilen
auslagern") oder inkrementeller Aufbau. Die Wahl gehört ins Paket, nicht hierher; die
Vorgabe ist nur: **eine sichtbare Grenze definieren, keine unbegrenzte Synchron-Arbeit.**
- **AvaloniaEdit-Fallen** (belegt, nicht neu ausprobieren): `this.TryGetResource` in einem
Control findet Brushes aus `Tokens.axaml` **nie** und scheitert lautlos; Brushes und Typeface
nicht im Konstruktor auflösen. `ScrollToVerticalOffset` ist in v12 ein No-op — schreiben über
`ScrollViewer.Offset`, lesen über `TextView.ScrollOffsetChanged`.
- Gemeinsames Editor-Boilerplate gehört in `Views/Controls/DiffEditorSetup.cs`, nicht ein
drittes Mal kopiert.
### Definition of Done
B3 ist der einzige Test im Vorhaben, der Blockade prüft: großes Diff-Fixture, Nachweis, dass
der Dispatcher weiter Nachrichten verarbeitet. Visuelle Prüfung offen (Verhalten bei großem
Diff, Indikator während des Parsens).
### Task-Entwürfe (grob)
**B1 · `UnifiedDiffParser.Parse` vom UI-Thread nehmen**
Beide Aufrufstellen im `DiffViewerViewModel` nach `Task.Run` verschieben, danach den
`OperationIndicator` setzen. Reihenfolge ist zwingend: erst auslagern, dann anzeigen — ein
Spinner auf einem blockierten Dispatcher wird nicht gezeichnet. Das Zurückschreiben in
Observable-Collections muss auf dem UI-Thread passieren.
*Dateien:* `Ui/ViewModels/Modals/DiffViewerViewModel.cs`
*Fertig wenn:* Kein synchroner Parse mehr im VM, Indikator während des Parsens sichtbar. Visuelle Prüfung mit einem großen Diff offen.
**B2 · `DiffAlignment.Build` — Grenze gegen unbegrenzte Synchron-Arbeit**
`Build` läuft in einem Control, nicht in einem VM. Der ausführende Agent entscheidet zwischen
Auslagern ab einer Zeilenzahl und inkrementellem Aufbau — die Vorgabe ist nur: **eine
sichtbare, begründete Grenze**. AvaloniaEdit-Fallen aus dem Vorgaben-Abschnitt beachten
(`TryGetResource` scheitert lautlos, `ScrollToVerticalOffset` ist ein No-op).
*Dateien:* `Ui/Views/Controls/DiffTextView.axaml.cs`, gemeinsames Boilerplate nach `DiffEditorSetup.cs`
*Fertig wenn:* Die gewählte Grenze ist im Code kommentiert und begründet; große Diffs blockieren nicht mehr unbegrenzt. Visuelle Prüfung offen.
**B3 · Guard-Test: großer Diff blockiert den Dispatcher nicht**
Headless-Test mit großem Diff-Fixture, der nachweist, dass der Dispatcher während Parse und
Build weiter Nachrichten verarbeitet. Der einzige Blockade-Test im Vorhaben — soll auch
zukünftige Regressionen fangen.
*Dateien:* `tests/ClaudeDo.Ui.Tests/`, Fixture im Testprojekt
*Fertig wenn:* Der Test fällt gegen den Stand **vor** B1/B2 und ist danach grün.
---
## Gruppe C — Bild 3: Worker-Stille
- [ ] **C1 — generischer `OperationProgress`-Kanal**
- [ ] **C2 — Startup-Recovery sichtbar**
- [ ] **C3 — Worktree-Anlage sichtbar**
- [ ] **C4 — Rebase-after-Merge und WorktreeMaintenance**
- [ ] **C5 — periodische Dienste**
**Vorbedingung:** P0 gemergt. Die Abhängigkeit zur Parallelsession ist mit `cad0582`
aufgelöst.
### Vorgaben
- **Der Kanal:**
```
OperationProgress(string opKey, string phase, int current, int total)
```
`opKey` = TaskId bei task-gebundenen Operationen, sonst ein stabiler String
(`"worktree-cleanup"`, `"startup-recovery"`, `"planning-integration:<taskId>"`).
- **Kein Elapsed auf der Leitung.** Das UI tickt lokal. Die heutige Implementierung lässt den
Worker alle 30 s ticken — damit zeigt sie die ersten 30 Sekunden nur „merging". C1 stellt das
um.
- **`MergeProgressEvent` bleibt als Forwarder** (siehe Nachbesserung 2 unter Gruppe A). Vier
Zeilen. Entfernen erst, wenn A und C beide gemergt sind — nicht in dieser Gruppe.
- **`current`/`total` wird als Text gezeigt, nie als Prozentbalken.** Die meisten Operationen
haben kein sinnvolles Total.
- **C3 zuerst prüfen, nicht bauen:**
`docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md` führt
`WorktreeManager.cs:103` als DB-Write-ohne-Broadcast. Ob das inzwischen geschlossen ist,
gehört geprüft, bevor es doppelt behoben wird.
- **C5 ist der wackeligste Punkt im Design.** Periodische Dienste (Usage, OnlineSync, Prime,
Queue) dürfen **nur Aktivität und Fehler** melden, keinen Tick-Strom. Wenn der Merge-Helper
beim Umsetzen zum Schluss kommt, dass C5 nur Rauschen erzeugt: **weglassen und begründen**,
statt es durchzuziehen.
- **Test-Fakes wachsen mit.** Ein neues Event auf `IWorkerClient`/`WorkerHub` bricht
handgeschriebene Fakes in **beiden** Testprojekten — `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
und die UiVm-Tests unter `tests/ClaudeDo.Worker.Tests/`.
- **Broadcast-Sparsamkeit:** vor einem „fehlenden Broadcast" immer den Aufrufer prüfen. Im
08-07-Spec war ein gemeldetes Loch in Wahrheit schon vom Aufrufer abgedeckt, und der Fix
wäre ein Duplikat gewesen.
### Definition of Done
Progress-Callbacks in `ClaudeDo.Worker.Tests` mit einem Fake gezählt — keine echten Timeouts,
keine echte CLI. UI-Seite: die Anzeige beim Worker-Start ersetzt „reconnecting" durch die
laufende Recovery-Phase. Visuelle Prüfung offen.
### Task-Entwürfe (grob)
**C1 · Generischer `OperationProgress`-Kanal, `MergeProgress` darauf umstellen**
Neues Hub-Event `OperationProgress(opKey, phase, current, total)`, ohne Elapsed auf der Leitung.
Der Merge-Pfad sendet darüber. **`MergeProgressEvent` bleibt als vierzeiliger Forwarder** —
Gruppe A hängt daran und darf nicht angefasst werden. Fakes in beiden Testprojekten mitziehen.
*Dateien:* `Worker/Hub/HubBroadcaster.cs`, `Worker/Hub/WorkerHub.cs`, `Ui/Services/WorkerClient.cs`, `IWorkerClient.cs`, `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, UiVm-Fakes in `Worker.Tests`
*Fertig wenn:* Merge-Phasen laufen über den neuen Kanal, das UI tickt Elapsed lokal, `MergeProgressEvent` funktioniert unverändert weiter, beide Testprojekte grün.
**C2 · Worker-Startup-Recovery sichtbar machen**
Die sechs Recovery-Services beim Worker-Start melden Phase und `i/n` über den Kanal
(`opKey = "startup-recovery"`). Das UI ersetzt „reconnecting" durch die laufende Phase.
*Dateien:* `Worker/Lifecycle/*Recovery.cs`, Anzeige im `IslandsShellViewModel`-Umfeld
*Fertig wenn:* Beim Worker-Start ist sichtbar, welche Recovery läuft. Visuelle Prüfung offen.
**C3 · Worktree-Anlage beim Task-Start sichtbar machen**
**Zuerst prüfen**, ob der im 08-07-Spec gemeldete Broadcast-Gap in `WorktreeManager` inzwischen
geschlossen ist — nicht doppelt beheben. Danach die stille Lücke zwischen `Queued` und erster
Ausgabe an der Task-Zeile sichtbar machen.
*Dateien:* `Worker/Runner/WorktreeManager.cs`, Task-Row-Anzeige
*Fertig wenn:* Das Prüfergebnis steht im Task; die Anlage-Phase ist an der Zeile sichtbar. Visuelle Prüfung offen.
**C4 · Rebase-after-Merge und WorktreeMaintenance in den Kanal**
`RebaseOthersAfterMergeAsync` läuft heute innerhalb des Merge-Calls **nach** dem
Phasen-Broadcast und ist damit vollständig unsichtbar. Dazu der Hintergrunddienst über alle
Worktrees, mit `i/n`.
*Dateien:* `Worker/Lifecycle/TaskMergeService.cs` (Rebase-Abschnitt), `Worker/Worktrees/WorktreeMaintenanceService.cs`
*Fertig wenn:* Beide melden Fortschritt; ein Merge zeigt nach dem Verify-Gate die Rebase-Phase statt Stille.
**C5 · Periodische Dienste — optional, Abbruch erlaubt**
Usage, OnlineSync, Prime, Queue sollen **nur Aktivität und Fehler** melden, keinen Tick-Strom.
Der wackeligste Punkt im Design: kommt der ausführende Agent zum Schluss, dass das nur
Anzeige-Rauschen erzeugt, **weglassen und begründen** statt durchziehen.
*Dateien:* `Worker/Usage/`, `Worker/Online/`, `Worker/Prime/`, `Worker/Queue/`
*Fertig wenn:* Entweder sparsame Meldungen ohne Rauschen — oder eine begründete Absage im Task.
---
## Gruppe D — Bild 4: MCP-Stille
- [ ] **D1 — `ProgressReporter` extrahieren**
- [ ] **D2 — die 8 `batch_*`-Tools**
- [ ] **D3 — Worktree- und Diff-Tools**
- [ ] **D4 — Rest und Doku-Regel**
**Vorbedingung:** keine. Kann sofort starten, parallel zu P0.
### Vorgaben
- **Warum das zählt:** ohne Progress bricht der MCP-Client bei Stille nach 300 s ab, während
der Worker weiterarbeitet. Das ist die dokumentierte Ursache dafür, dass ein Merge zwar
durchläuft, den Task aber nie auf `Done` bringt und Abhängige dauerhaft blockiert.
- **D1:** `TaskMergeService.RunReportingProgressAsync` ist die einzige existierende
Progress-Schleife. Als eigenständige Klasse extrahieren, plus ein Overload für
Element-Fortschritt (`i/n`). Nicht kopieren — extrahieren, damit es eine Implementierung
bleibt.
- **`TaskMergeService` ist die einzige Datei, die D mit C teilt.** D fasst dort ausschließlich
die Progress-Schleife an. Wenn beide Gruppen gleichzeitig laufen, ist das die Stelle, die der
Merge-Helper im Auge behalten muss.
- **Keine Locale-Keys** (Nachbesserung 3). Englische Klartext-Strings.
- **Zielumfang:** die 8 `batch_*`-Tools mit `i/n`, dann `cleanup_task_worktree`,
`batch_cleanup_task_worktrees`, `get_task_diff`, `preview_merge_set`. Bereits versorgt und
nicht anzufassen: die 5 Tools in `ExternalMcpService` mit `IProgress`, `TaskWaitMcpTools`,
`merge_task`/`review_task`.
- **D4 schreibt die Regel ins Worker-`CLAUDE.md`:** ein MCP-Tool, das über ~5 s laufen kann,
reportet Progress. Ohne die Regel wiederholt sich das Muster beim nächsten Tool.
### Definition of Done
Für jedes umgestellte Tool ein Test in `ClaudeDo.Worker.Tests`, der mit einem Fake-`IProgress`
zählt, dass Meldungen kommen — bei `batch_*` mindestens eine pro Element. Keine visuelle
Prüfung nötig, diese Gruppe hat keine UI.
### Task-Entwürfe (grob)
**D1 · `ProgressReporter` extrahieren**
`TaskMergeService.RunReportingProgressAsync` als eigenständige Klasse herauslösen, plus einen
Overload für Element-Fortschritt (`i/n`). **Extrahieren, nicht kopieren** — es soll eine
Implementierung bleiben. Dies ist die einzige Datei, die D mit C teilt; nur die Progress-Schleife
anfassen.
*Dateien:* `Worker/Lifecycle/TaskMergeService.cs`, neue Klasse im Worker-Projekt
*Fertig wenn:* Der Merge-Pfad nutzt die extrahierte Klasse, Verhalten unverändert, bestehende Merge-Tests grün.
**D2 · Die acht `batch_*`-Tools mit Element-Fortschritt**
Jedes `batch_*`-Tool reportet pro verarbeitetem Element. Grund: ohne Meldung bricht der
MCP-Client nach 300 s Stille ab, während der Worker weiterläuft — genau der Mechanismus, der
einen Merge durchlaufen lässt, den Task aber nie auf `Done` bringt.
*Dateien:* `Worker/External/BatchMcpTools.cs`
*Fertig wenn:* Pro Tool ein Test mit Fake-`IProgress`, der mindestens eine Meldung je Element zählt. Englische Klartext-Strings, keine Locale-Keys.
**D3 · Worktree- und Diff-Tools mit Progress**
`cleanup_task_worktree`, `batch_cleanup_task_worktrees`, `get_task_diff`, `preview_merge_set`.
Bereits versorgt und **nicht** anzufassen: die fünf Tools mit `IProgress` in
`ExternalMcpService`, `TaskWaitMcpTools`, `merge_task`, `review_task`.
*Dateien:* die betroffenen `Worker/External/*McpTools.cs`, `ExternalMcpService.cs`
*Fertig wenn:* Jedes der vier Tools meldet Fortschritt, mit Test.
**D4 · Restliche Long-Runner und die Regel im Worker-`CLAUDE.md`**
Verbleibende Tools durchgehen, die über ~5 s laufen können, und die Regel festschreiben: ein
MCP-Tool über ~5 s reportet Progress. Ohne die Regel wiederholt sich das Muster beim nächsten
Tool.
*Dateien:* restliche `Worker/External/*McpTools.cs`, `src/ClaudeDo.Worker/CLAUDE.md`
*Fertig wenn:* Die Regel steht mit Begründung; die durchgegangenen Tools sind im Task aufgelistet — auch die, die bewusst nichts bekommen.
---
## Was nach allen vier Gruppen offen bleibt
- **Visuelle Prüfung** für alle Pakete in A und B — der Nutzer, nicht ein Agent.
- **Auswertung von P0-2** steuert A5 und die Feinheiten in C. Vorher ist A5 absichtlich
unbestimmt.
- **`MergeProgressEvent`-Forwarder entfernen**, sobald A und C beide gemergt sind. Ein
Aufräum-Task, kein Paket.
- **Bewusst nicht gebaut:** Cancel, Footer-Anzeige für laufende Operationen, Prozentbalken,
Änderungen am Installer (der hat bereits eine vollständige Progress-Pipeline).
@@ -35,7 +35,7 @@ the engine.
| A1 | Diff viewing | `DiffModalViewModel` (worktree + commit-range), `WorktreeModalViewModel` (file-tree + per-file), `PlanningDiffViewModel` (per-subtask + integration) | `UnifiedDiffParser`, `DiffLinesView` (good) |
| A2 | Agent-config editing | `ListSettingsModalViewModel` (list scope), `AgentSettingsSectionViewModel` (task scope); global lives in `SettingsModalViewModel` | `InheritanceResolver`, `InheritedBadge` (good) |
| A3 | Worktree actions | `WorktreesOverviewModalViewModel` per-row cmds (Merge/Discard/Keep/ForceRemove/ShowDiff/Jump) vs `MergeSectionViewModel` (Merge/OpenDiff) | same `IWorkerClient` calls |
| A4 | Merge display | `AgentStripView` re-displays `MergeSectionViewModel` state | — |
| A4 | Merge display | ~~`AgentStripView` re-displays `MergeSectionViewModel` state~~ — resolved 2026-08-06: `AgentStripView` was dead (never bound after the task-detail redesign); deleted instead of unified | — |
### Bucket B — entry-point sprawl (one backend, many hand-wired doors)
@@ -43,7 +43,7 @@ the engine.
|---|---|---|---|
| B1 | Conflict-resolution seam | 5 copies of `Func<string,string,Task>? RequestConflictResolution` | `WorktreesOverviewModalViewModel.cs:83`, `DiffModalViewModel.cs:75`, `MergeModalViewModel.cs:33`, `MergeSectionViewModel.cs:51`, `DetailsIslandViewModel.cs:347` (delegates). Threaded through `MainWindow.axaml.cs:81`, `IslandsShellViewModel.cs:49/202`, `DiffModalViewModel.cs:103`, `MergeSectionViewModel.cs:159` |
| B2 | Diff (open) | 34 | MergeSection "Open Diff", TaskHeaderBar "Review Merged Diff", WorktreesOverview "Show Diff", Planning "Review Combined" |
| B3 | List Settings dialog | 3 | Lists context menu, Tasks header button, shell bridge `IslandsShellViewModel.cs:190-194` |
| B3 | List Settings dialog | 2 (was 3 Lists context menu entry removed 2026-08-06) | Tasks header button, double-click on a list row, shell bridge `IslandsShellViewModel.cs:190-194` |
| B4 | Worktrees Overview | 23 | Repos menu (global), Lists context menu (per-list) |
| B5 | Repo Import | 2 | Repos menu, Lists footer button |
@@ -0,0 +1,235 @@
# Planning-Chain Children: Fork Base Commit (Design Options, No Implementation)
**Date:** 2026-08-06
**Status:** Options evaluated, recommendation given — decision pending (Mika)
**Scope:** Analysis only. No production code changed by this document.
## Problem
The planning chain gives **ordering**, not **code inheritance**. `PlanningChainCoordinator.SetupChainAsync`
(`src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs:36-72`) links children via
`BlockedByTaskId` (child[i] blocks on child[i-1]) so they run one at a time. But each child's
worktree is still created from **main's current HEAD**, not from the predecessor's branch, so
child N+1 never sees child N's (unmerged) work.
**Observed failure (unit `44241dcb`, "Installer: Environment-Checks", 2026-08-05):** 7 of 9
children succeeded; the two that depended on a sibling's output could not:
- `4e196058` ("Claude Help Me" button) — reported: *"the task spec assumes an existing
SystemCheckPage/EnvironmentCheckReport/ExecutableResolver, but that code only exists on an
unmerged sibling branch (claudedo/06aca9b3...). A fast-forward merge of that branch was
attempted ... but was denied twice by the auto-mode permission classifier. No implementation
work was done."*
- `c22cdd06` (Diagnose section) — *"Blocked before implementation could start."*
Both correctly reported `CLAUDEDO_BLOCKED` and committed nothing (`ahead: 0`). A second-order
symptom of the same root cause: `ExecutableResolver.cs` was independently created **twice**
(`40272c0b`, `06aca9b3`, near-identical) and collided as an add/add conflict at unit-merge time.
## Ist-Zustand (verified against commit `816f247`, 2026-08-06)
### Where the base commit is chosen
`WorktreeManager.ResolveBaseCommitAsync``src/ClaudeDo.Worker/Runner/WorktreeManager.cs:191-207`:
```csharp
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct)
{
if (task.ParentTaskId is not null)
{
var parent = ...;
if (parent is not null && parent.PlanningPhase == PlanningPhase.None)
{
var parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct);
var parentHead = parentWt?.HeadCommit ?? parentWt?.BaseCommit;
if (parentHead is not null)
return parentHead;
}
}
return await _git.RevParseHeadAsync(workingDir, ct); // planning children land here
}
```
**A "don't fork from main" mechanism already exists** — but only for *improvement* children
(a non-planning parent's own follow-up subtasks), which base off `task.ParentTaskId`'s worktree
HEAD. The guard `parent.PlanningPhase == PlanningPhase.None` explicitly **excludes** planning
children: their `ParentTaskId` points at the planning parent (which has no worktree of its own),
not at a sibling, so this branch never fires for them and they fall through to `RevParseHeadAsync`
= main HEAD. This is a deliberate exclusion in the existing code, not an oversight — it simply
never anticipated that a planning child's *predecessor in the chain* (not its parent) might be
the thing to inherit from.
Called from `WorktreeManager.CreateAsync` (`:35`), invoked by `TaskRunner` (`Runner/TaskRunner.cs:309`)
at the moment a task transitions to `Running` — i.e. worktree creation happens per-run, not once
at plan-finalize time.
### Children already run strictly sequentially — this is not a new constraint
`SetupChainAsync` sets `BlockedByTaskId` on every child but the first
(`Planning/PlanningChainCoordinator.cs:61-69`); the queue picker only claims rows with
`BlockedByTaskId IS NULL`. `OnChildFinishedAsync` (`:92-120`) unblocks the successor **only**
after the predecessor reaches `Done` (and cascades cancellation down the chain on
Failed/Cancelled). `OverrideSlotService.RunNow` (`Queue/OverrideSlotService.cs:32-42`) is the one
path that bypasses this — it calls `StartRunningAsync` directly with no `BlockedByTaskId` check,
so a user can manually force a later child to run out of turn.
Net: under the normal (queue-driven) path, by the time child N+1's worktree is created, child N
is already terminal. Forking N+1 from N's branch tip instead of main's HEAD does **not** introduce
any new concurrency constraint on the normal path — the sequencing already exists. It only matters
for the `RunNow` bypass edge case (see Option 1 below).
### The merge side already treats the unit as a sequential chain, twice over
`PlanningMergeOrchestrator.DrainAsync` (`Planning/PlanningMergeOrchestrator.cs:183-229`) merges
`Done` children into `targetBranch` **one at a time, in `SortOrder`**, via
`TaskMergeService.MergeAsync`; the first conflict pauses the whole drain
(`PlanningMergeConflict`, state kept for `ContinueAsync`/`AbortAsync`), and the first
non-conflict failure **aborts the drain outright** — remaining children are never merged and the
parent never reaches `Done` (`:208-215`).
`PlanningAggregator.BuildIntegrationBranchAsync` (`Planning/PlanningAggregator.cs:81-126`) — used
for the pre-approve combined-diff preview — does the same thing again: builds a scratch
integration branch off `targetBranch` and `MergeNoFfAsync`s each child's branch in, in
`SortOrder`, stopping at the first conflict.
So **both** the actual unit-merge and its preview already model the child set as an ordered
sequence where one bad link can stall everything downstream. The only place in the whole pipeline
that still treats planning children as N independent forks of main is worktree creation.
## Options evaluated
### Option 1 — child forks from the predecessor's branch
Extend `ResolveBaseCommitAsync` (or a planning-specific sibling of it) to also handle planning
children: look up the chain predecessor via `BlockedByTaskId` (not `ParentTaskId` — that points at
the planning parent, which has no worktree) and use its `WorktreeEntity.HeadCommit ?? BaseCommit`
the same way the improvement-child path already does.
**What actually breaks, given the Ist-Zustand above:**
- *Not* a new concurrency constraint (see above) — the chain already serializes execution.
- *Not* a new "one failure blocks everyone" behavior at merge time — `DrainAsync` already aborts
the whole drain on the first non-conflict failure, and the integration-branch preview already
stops at the first conflict. Option 1 brings execution-time behavior in line with what merge-time
behavior already is, rather than introducing a new failure mode.
- **Genuinely new:** child N+1's branch now contains N's commits as ancestors. When N is later
merged into `targetBranch` with `--no-ff` and N+1 is merged afterward, N+1's diff against N's
content is empty (identical trees) — merges cleanly as a no-op for that slice, verified by the
same mechanism `DrainAsync` already uses. No new conflict class is introduced; if anything this
*removes* one: the `ExecutableResolver.cs` add/add collision would not have occurred, since N+1
would start from a tree that already has the file.
- **Genuinely new edge case:** `OverrideSlotService.RunNow` on a chain member whose predecessor
hasn't produced a `WorktreeEntity`/`HeadCommit` yet. Needs an explicit fallback to main HEAD —
the same `?? ` pattern the improvement-child branch already uses for a predecessor with no
`HeadCommit` (never committed) covers most of this; a predecessor that was never even *run* needs
the same fallback-to-`RevParseHeadAsync` the code already falls through to today.
- **Genuinely new edge case:** a discarded/failed predecessor's worktree row still carries its last
`HeadCommit`/`BaseCommit` (the row isn't deleted on discard, only `State` flips) — same as the
improvement-child path already tolerates, so no new handling needed there.
- Children still go straight to `Done` with no individual review (Unified Parent Model), so there
is no "reject and rewrite a middle child after a successor already forked from it" scenario to
worry about — that action doesn't exist in the current state machine.
**Price:** deeper branch chains (cosmetic — they're squashed away by the sequential merge/prune
regardless); one new fallback path for the `RunNow` bypass. Meaningfully smaller than it first
looks, because it extends an existing pattern (improvement children) into a lane whose execution
and merge sides are *already* sequential — it only fixes the one place that wasn't.
### Option 2 — merge predecessor to main immediately on success, before the successor starts
Keeps children independent worktrees (forked from main-as-it-is-now, updated after each merge),
but requires a real merge to `main` per child, outside of Approve.
**Breaks:**
- Directly contradicts the documented invariant "**Approve is the single review+merge action**"
(`src/ClaudeDo.Worker/CLAUDE.md` → Status Model; `docs/explore-notes/review-merge.md` → "Approve
= merge the whole unit"). Unreviewed work would land on `main` automatically, mid-chain, before
the parent — or the user — ever sees it.
- Requires calling `TaskMergeService.MergeAsync` from `OnChildFinishedAsync` (state-transition
code), duplicating what `PlanningMergeOrchestrator` already owns, and doing it against a
`VerifyCommand`-gated repo N times instead of once — if the gate is configured, a mid-chain
verify failure now has to be handled somewhere there's currently no error path for it (parent is
still `WaitingForChildren`, not under merge orchestration yet).
- Unavoidably touches `TaskStateService`/status-transition semantics, which this task's scope
explicitly excludes ("Die Status-Logik oder `TaskStateService` anfassen" is out of scope) — this
option cannot be implemented without doing exactly that.
**Verdict:** rejected outright — it isn't just costly, it conflicts with a stated architectural
invariant and this task's own non-goals.
### Option 3 — child may merge the predecessor's branch into its own worktree if it needs to
Least invasive to the base-commit mechanism; delegates the decision to the agent at runtime.
**Breaks:**
- This is *exactly* what the blocked child in the observed incident already tried, and it was
denied twice by the auto-mode permission classifier. The real blocker isn't a missing
capability, it's that `git merge` trips the classifier's "risky/hard-to-reverse" heuristic
regardless of target — even though a merge confined to the task's own isolated worktree (never
touching the shared `workingDir`) is materially lower-risk than a merge on a shared checkout.
Fixing this option means carving a narrower permission rule (allow `git merge` only inside a
path under the task's own worktree root), which is a security-policy change, not a merge-model
change.
- Even with permission granted, it still relies on the agent (a) noticing it's missing a
prerequisite, (b) correctly identifying which sibling branch has it, and (c) successfully
resolving any conflict that surfaces — three separate failure points per occurrence, decided
fresh by an LLM each time rather than encoded once in the pipeline.
- Doesn't help children that fail *before* they'd even think to look (e.g., a child whose first
action is reading a file that doesn't exist yet has no signal to act on).
**Verdict:** possible as a narrow permission-scope fix layered *on top of* Option 1 (so an agent
that still needs something from further back than its immediate predecessor isn't stuck), but not
a substitute for it — on its own it reproduces the exact failure mode from the incident, just with
one fewer denial.
### Option 4 — change nothing; require independent children at planning time
Zero code changes; pushes the constraint into plan quality.
**Breaks:**
- No enforcement exists today (`PlanningSessionManager` doesn't validate structural independence
between proposed subtasks), and reliably detecting "child B depends on code child A will write"
from a plan draft is itself an unsolved code-review problem — not something a prompt tweak
guarantees.
- A shared-foundation-plus-N-consumers decomposition (exactly the `44241dcb` shape: environment
checks feeding two UI consumers) is often the *correct* decomposition, not a planning mistake.
Banning it either forces over-merging into one giant child (defeating the purpose of splitting)
or requires the planner to reject a structurally sound plan.
- Reproduces the observed failure verbatim under the same conditions: nothing about this option
would have caught `44241dcb` before it shipped two dead children.
**Verdict:** cheapest to write down, but doesn't fix the problem — it relocates it to "the plan
looked independent but wasn't," which is what already happened.
## Recommendation
**Option 1**, with the `RunNow`-bypass fallback described above, and with Option 3's narrower
permission-scope fix as an optional follow-up (not a prerequisite) for cases where a child needs
something from further back in the chain than its immediate predecessor.
**Why:** Option 1 is not a new mechanism — it's closing the one gap in a pattern that already
exists twice in this codebase: `WorktreeManager.ResolveBaseCommitAsync` already forks improvement
children from their parent's HEAD instead of main, and both `PlanningMergeOrchestrator.DrainAsync`
and `PlanningAggregator.BuildIntegrationBranchAsync` already treat the child set as an ordered
merge sequence where the first failure stalls everything after it. Planning-child worktree
creation is the outlier, not the rule. Extending the existing predecessor-lookup pattern (keyed
off `BlockedByTaskId` instead of `ParentTaskId` for this lane) makes the "sees predecessor's work"
guarantee hold everywhere the chain already implies it, without touching `TaskStateService`, the
review model, or introducing a merge-time failure mode that doesn't already exist. Options 2 and 4
either conflict with a stated invariant/this task's own non-goals, or fail to address the incident
at all; Option 3 alone reproduces the incident's exact failure.
**Price to pay knowingly:** a chain member that never got a chance to run (no `WorktreeEntity` yet)
needs an explicit main-HEAD fallback when its successor is forced via `RunNow` — a few lines,
mirroring the null-coalescing fallback the improvement-child path already has. No other new failure
surface was found.
## Explicitly out of scope (per task)
- Implementing Option 1 or any other option — Mika decides first.
- Any change to `TaskStateService` or status-transition logic.
- Changing blocked-child visibility/behavior — that's task `001ee94a`, running in parallel; it only
touches MCP visibility, no production code overlap with this document.
@@ -0,0 +1,225 @@
# Diff Viewer: Side-by-Side, Syntax Highlighting, Word Diff
Date: 2026-08-07
Status: approved (design), not implemented
## Problem
The diff viewer renders every change as a flat unified stream. Reading what actually changed
inside a modified line means mentally aligning a `` row with a `+` row several rows below it.
The user reads diffs faster side by side.
Three gaps, all in the same surface:
1. No side-by-side mode.
2. No syntax highlighting — the merge editor (`ConflictResolverView`) already has it via
TextMate, the diff viewer does not.
3. No intra-line (word) highlighting, so a one-character change looks like a whole-line rewrite.
## Current state
| Concern | Where |
|---|---|
| Parsing | `src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs``DiffFileViewModel.Lines` |
| Models | `src/ClaudeDo.Ui/ViewModels/Modals/DiffModels.cs` (`DiffLineViewModel{Kind,OldNo,NewNo,Text}`, `DiffLineKind{Add,Del,Ctx,File}`) |
| Rendering | `src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml` — non-virtualized `ItemsControl`, one `Border`+`Grid`+4 `TextBlock`s per line, `TextWrapping="NoWrap"` |
| Host | `src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml` — Files mode (line 154, `SelectedFile.Lines`) and Planning mode (line 162, flattened `DiffLines` across all files) |
| Highlighting reference | `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs:80-83``RegistryOptions(ThemeName.DarkPlus)` + `InstallTextMate` + `SetGrammar` by file extension |
`Avalonia.AvaloniaEdit`, `AvaloniaEdit.TextMate` and `TextMateSharp.Grammars` are already
referenced in `ClaudeDo.Ui.csproj`.
## Decision
Replace `DiffLinesView` with an AvaloniaEdit-based control. TextMate highlighting is bound to
the `TextEditor` control; it cannot be lifted into `TextBlock` inlines without reimplementing
the scope→brush layer that `AvaloniaEdit.TextMate` already provides. Building split/wrap/word
diff on the `TextBlock` model first and swapping the renderer later would be throwaway work.
Side effect worth having: AvaloniaEdit virtualizes, which removes the current non-virtualized
`ItemsControl` as a scaling limit on large diffs.
Rejected: keeping the `ItemsControl` and hand-rolling highlighting from `TextMateSharp`
tokenization — same output, materially more code, and a second highlighting path to maintain
alongside the merge editor's.
## Layout semantics
Left pane is the **old** state, right pane is the **new** state — each side carries the complete
version of the hunk, not "removals here, additions there".
```
LEFT (old) RIGHT (new)
12 public void Save() 12 public void Save()
13 var x = 1; 13 var x = 2; ← word diff on `1` / `2`
14 Log("old"); · (filler)
· (filler) 14 Log("new");
15 } 15 }
```
## Components
### 1. `DiffAlignment` (new, pure)
`src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs`
Turns `IReadOnlyList<DiffLineViewModel>` into a render-ready `AlignedDiff`. No Avalonia types,
fully unit-testable.
```csharp
public enum AlignedSide { Ctx, Del, Add, Filler, Gap }
public readonly record struct TextSpan(int Start, int Length);
public sealed record SplitRow(
AlignedSide LeftKind, int? OldNo, string LeftText, IReadOnlyList<TextSpan> LeftSpans,
AlignedSide RightKind, int? NewNo, string RightText, IReadOnlyList<TextSpan> RightSpans);
public sealed record UnifiedRow(
AlignedSide Kind, int? OldNo, int? NewNo, string Text, IReadOnlyList<TextSpan> Spans);
public sealed record AlignedDiff(
IReadOnlyList<SplitRow> SplitRows, string LeftText, string RightText,
IReadOnlyList<UnifiedRow> UnifiedRows, string UnifiedText);
```
Row index `i` maps to document line `i + 1` in the corresponding text. That mapping is the
contract the margin and both renderers depend on.
**Pairing.** Walk the lines. A `Ctx` run emits rows with the same text on both sides. A change
block (a `Del` run followed by an `Add` run) pairs index-wise up to `min(delCount, addCount)`;
the overhang gets `Filler` rows on the opposite side.
**Gaps.** The parser drops `@@` headers, so a skipped region shows up as a jump in `OldNo`/`NewNo`
between consecutive lines. `DiffAlignment` detects that jump and inserts a `Gap` row on both
sides. The parser is not touched.
**Word diff.** Only for `(Del, Add)` rows that are paired 1:1. Tokenize each side into runs of
word characters / whitespace / single punctuation, run an LCS over the tokens, and emit the
changed token runs as character spans per side.
Two guards, both `const` and both covered by tests:
- Skip when either side exceeds `MaxWordDiffChars = 2000` — LCS cost, and such lines are
unreadable as word diffs anyway.
- Skip when token similarity is below `MinWordDiffSimilarity = 0.5` (common tokens / max token
count). Below that the two lines are unrelated rewrites and per-word tinting is noise.
### 2. `DiffTextView` (new control, replaces `DiffLinesView`)
`src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml` + `.axaml.cs`
Styled properties:
| Property | Type | Meaning |
|---|---|---|
| `File` | `DiffFileViewModel?` | Source; the control aligns it and caches the `AlignedDiff` per file instance |
| `Mode` | `DiffViewMode` (`Unified`\|`Split`) | Layout |
| `WrapLines` | `bool` | Bound to each editor's `WordWrap` |
Two `TextEditor`s in a two-column grid, both `IsReadOnly=true`, `ShowLineNumbers=false`.
`Unified` mode collapses the right editor and spans the left one across both columns, feeding
it `UnifiedText`. `Split` mode shows both, fed `LeftText` / `RightText`.
Per editor:
- **TextMate**: one shared `RegistryOptions(ThemeName.DarkPlus)`; grammar resolved from
`File.Path`'s extension via `GetLanguageByExtension``GetScopeByLanguageId``SetGrammar`,
exactly as `ConflictResolverView.ApplyGrammar` does. No extension match → no grammar, plain text.
- **`DiffLineNumberMargin : AbstractMargin`** — draws line numbers from the row list. Split: old
numbers left, new numbers right. Unified: two number columns in one margin. `Filler` and `Gap`
rows draw nothing.
- **`DiffLineBackgroundRenderer : IBackgroundRenderer`** — full-width tint per visual line by
row kind: add / del / filler / gap / ctx.
- **`WordDiffRenderer : IBackgroundRenderer`** — stronger tint over the changed spans, via
`BackgroundGeometryBuilder` at `lineStartOffset + span.Start`.
Both renderers resolve rows through a single `Func<int, RowInfo?>` keyed by document line.
**Highlighting on fragments.** Only hunks are in the document, not whole files, so TextMate's
line-by-line state can be wrong at a fragment boundary (a line inside a block comment may be
highlighted as code). Accepted — the same is true of every fragment-based diff viewer.
**Colors.** Line tints stay the existing low-alpha `RunningTintBrush` / `ErrorTintBrush` so
syntax foregrounds remain legible. The per-line foreground recolor from `DiffLinesView`
(green/red text) is dropped — syntax colors take over. `Filler` gets a new dim token brush,
`Gap` renders as a dim `⋯` separator row.
**Scroll sync** (split only), modeled on `ConflictResolverView.HookScrollSync` — find each
editor's descendant `ScrollViewer`, guard re-entry with a `_syncing` flag:
- `WrapLines = false`: sync `Offset.Y` directly. Line heights match, so alignment is exact.
- `WrapLines = true`: line heights diverge. Sync on the first visible document line instead
(`ScrollToLine`), which keeps the top of the viewport aligned and lets rows drift downward.
### 3. Host changes
`DiffViewerView.axaml`:
- Header gains a segmented Unified/Split toggle and a wrap toggle.
- Files mode: `DiffLinesView Lines="{Binding SelectedFile.Lines}"``DiffTextView File="{Binding SelectedFile}"`.
- Planning mode: the flattened single-stream `DiffLines` view is replaced by an `ItemsControl`
over the subtask's parsed files, each item a file header plus its own `DiffTextView`. One
editor can only carry one grammar, so per-file editors are required for highlighting to work
at all here. `DiffViewerViewModel` exposes the parsed per-file list for the selected subtask;
`DiffLines` and `UnifiedDiffParser.Flatten` lose their last consumer and are removed.
`DiffLinesView.axaml` + `.axaml.cs` are deleted once both usages are migrated.
### 4. Persistence
`src/ClaudeDo.Ui/AppSettings.cs` (`~/.todo-app/ui.config.json`) already holds UI-only
preferences (`Language`, `AccentPreset`) with plain `Load()`/`Save()`. Two properties are added
there:
```csharp
public string DiffViewMode { get; set; } = "unified"; // "unified" | "split"
public bool DiffWrapLines { get; set; }
```
`DiffViewerViewModel` takes the injected `AppSettings`, seeds its toggles on open and calls
`Save()` when either changes. No database column, no EF migration, no hub method — a view
preference does not belong in `AppSettingsEntity`.
### 5. Localization
New keys in both `locales/en.json` and `locales/de.json` (Localization.Tests enforces parity):
`diff.view.unified`, `diff.view.split`, `diff.view.wrap`.
## Testing
`tests/ClaudeDo.Ui.Tests``DiffAlignment` is pure and carries the logic worth testing:
- Context-only diff → identical rows on both sides, no fillers.
- Equal-size change block → 1:1 pairing, no fillers.
- Unequal change block (3 del / 5 add) → 3 paired rows + 2 right-side rows with left fillers.
- Add-only and delete-only blocks → fillers on the opposite side throughout.
- Non-contiguous line numbers → exactly one `Gap` row inserted.
- Word diff: single-token change yields one span per side at the right offsets.
- Word diff skipped above `MaxWordDiffChars` and below `MinWordDiffSimilarity`.
- Row index ↔ document line mapping holds for both `SplitRows` and `UnifiedRows`.
- Binary file and empty-content file → empty `AlignedDiff`, no crash.
`AppSettings` round-trip: persisted mode and wrap survive `Save()`/`Load()`.
Rendering (margin, both renderers, scroll sync, TextMate colors) is not unit-testable here and
is an explicit manual visual pass — see Open items.
## Known limitations
1. **Wrap + split drift.** With wrap on, the two panes align at the top of the viewport but rows
drift apart further down. Per-line vertical alignment as VS Code does it is out of scope.
2. **Fragment highlighting.** See above — highlighting state can be wrong at hunk boundaries.
3. **Editors per file in Planning mode.** A subtask touching many files instantiates one editor
per file. Same cost as viewing those files individually in Files mode; not capped. If it
proves slow, the fix is lazy instantiation on expand, not a silent truncation.
## Open items (manual verification)
- Visual pass on both modes: tints legible over DarkPlus syntax colors; line numbers aligned;
filler and gap rows readable.
- Scroll sync with wrap off (exact) and wrap on (top-anchored).
- Planning mode with a multi-file subtask.
- Toggle state survives an app restart.
## Docs to update on completion
- `src/ClaudeDo.Ui/CLAUDE.md` — Views/Controls list and the "Diff & Conflicts" section still
name `DiffLinesView`.
- `docs/explore-notes/review-merge.md` — diff stack description + "verified against" commit.
@@ -0,0 +1,169 @@
# Handler-Run: Verknüpfung zu den behandelten Tasks
**Date:** 2026-08-07
**Status:** Design approved (Mika), implementation pending
**Verified against:** commit `c792765`
## Problem
Ein "Let Claude handle it"-Run besitzt seit 2026-08-05 einen echten Task (`IsManual=true`,
`HandlerBaseCommit`/`HandlerHeadCommit`, Diff über Commit-Range). Was fehlt: **welche Tasks der Run
behandelt hat, ist nirgends persistiert.** Die Auswahl lebt nur in der ConPTY-Session und im
Transcript; `HandoffMcpTools.HandoffListHandler` (`src/ClaudeDo.Worker/External/HandoffMcpTools.cs:28-45`)
bekommt `survivingTaskIds` als flüchtige Liste.
Folge: Nachdem ein Run durch ist und der Diff sichtbar wird, lässt sich nicht mehr nachvollziehen,
*was alles gemacht werden sollte* und *welcher Task was produziert hat*. Duplikate, die der Handler
in Phase 1 gecancelt hat, verschwinden vollständig aus dem Blickfeld.
Zweitens zeigt der Handler-Task in der Liste das Badge **MANUAL**, weil er `IsManual=true` setzt —
irreführend, denn es ist kein manueller Reminder.
## Ist-Zustand
### Es gibt kein Task-Kind
`TaskEntity` hat **kein `Kind`/`Type`-Enum**. Task-"Arten" sind heute Feld-Kombinationen:
| Feld | Bedeutung |
|---|---|
| `IsManual` | manueller Reminder — Queue/Daily-Prep/Refine überspringen ihn |
| `ParentTaskId` | Kind einer Planning-/Improvement-Session |
| `PlanningPhase` | Planning-Parent |
| `BlockedByTaskId` | Kettenglied, Queue-Picker überspringt es |
| `HandlerBaseCommit` | worktree-loser List-Handler-Host (`src/ClaudeDo.Data/Models/TaskEntity.cs:60-61`) |
Ein Handler-Task ist also allein durch `HandlerBaseCommit != null` identifiziert.
### `ParentTaskId` ist belegt
`TaskRepository.CreateChildAsync` (`src/ClaudeDo.Data/Repositories/TaskRepository.cs:306`) setzt es
für Planning-Kinder; `TaskRowViewModel.IsChild`/`ShowAsChild`
(`src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:59,66`) hängen daran und rücken die Zeile
im Baum ein. Ein Recycling für Handler→behandelte Tasks würde die Auswahl optisch unter den Handler
schieben und mit echten Planning-Kindern kollidieren.
### Badge-Infrastruktur existiert
`TaskRowView.axaml:129-144` rendert DRAFT / PLANNED / PLANNING / MANUAL über
`Border Classes="badge <variant>"`. Basis-Style und Varianten liegen in
`src/ClaudeDo.Ui/Design/IslandStyles.axaml:963-990`, die Brushes als theme-fähige Tokens in
`Tokens.axaml`. Loc-Keys: `tasks.badgeManual`, `tasks.manualTip` (en.json:163-164).
### Kinder-Panel existiert
`DetailsIslandViewModel.LoadChildOutcomesAsync`
(`src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:704-748`) lädt
`Where(t => t.ParentTaskId == parentTaskId)` in `ChildOutcomes` (`:248`) und rendert pro Zeile
Id/Titel/Status/RoadblockCount/WorktreeState via `ChildOutcomeRowViewModel`; Refresh läuft über
`TaskUpdated`/`WorktreeUpdated` (`:814-833`).
## Entscheidungen
| Frage | Entscheidung | Begründung |
|---|---|---|
| Neues `TaskKind`-Enum? | **Nein** | Es gäbe kein Enum zu erweitern — es wäre das erste überhaupt, inkl. Migration und Rückwirkung auf Queue/Filter/UI. Der Bedarf ist eine Beziehung, kein Typ. |
| `ParentTaskId` wiederverwenden? | **Nein** | belegt durch Planning-Kinder, kollidiert mit Einrückungs-Logik |
| 1:n oder n:m? | **1:n**, eine nullable Spalte | Historie "welcher Run hat den Task mal berührt" bringt nichts, wenn ohnehin der letzte Run derjenige ist, dessen Diff man ansieht. Join-Tabelle = doppelter Code für einen Randfall. |
| Wann stempeln? | **Beim Anlegen des Handler-Tasks** | Die UI kennt die Auswahl bereits. Erfasst auch die Tasks, die der Handler in Phase 1 als Duplikat cancelt — genau das "was sollte alles gemacht werden". Ein Stempeln erst in `handoff_list_handler` würde Dedupe-Verlierer verlieren und bei Abbruch vor Phase 2 gar nichts verknüpfen. |
| Umfang der Anzeige | **Nur Liste + Endstatus** | Kein Phasen-Protokoll, kein Per-Task-Diff im Panel — der Diff hängt ohnehin am jeweiligen Task. |
## Design
### 1. Daten
Neue nullable Spalte auf `TaskEntity`:
```csharp
/// <summary>Id des Handler-Task-Runs, der diesen Task behandelt hat (null = keiner).</summary>
public string? HandlerTaskId { get; set; }
```
Konfiguration in `TaskEntityConfiguration`: `HasIndex(t => t.HandlerTaskId)`, kein FK-Constraint
(konsistent mit `BlockedByTaskId`-Handhabung; ein gelöschter Handler-Task soll die behandelten Tasks
nicht kaskadierend anfassen). EF-Core-Migration `AddHandlerTaskId`.
Ein zweiter Run über dieselben Tasks überschreibt die Zuordnung — gewollt (1:n).
### 2. Schreiben
Die Auswahl wird durchgereicht: UI → `IWorkerClient.CreateMergeHelperTaskAsync`
`WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:827-838`) →
`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync`. Nach dem Anlegen des Handler-Tasks setzt
eine neue Repository-Methode die Zuordnung in einem Batch-Update:
```csharp
Task<int> SetHandlerTaskIdAsync(IReadOnlyList<string> taskIds, string handlerTaskId, CancellationToken ct);
```
Der Handler-Task selbst bekommt **kein** `HandlerTaskId` (kein Selbstbezug). Unbekannte Ids werden
still übersprungen.
### 3. Badge
`TaskRowViewModel`:
```csharp
public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit);
public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null;
public string? ManualBadge => IsManual && !IsHandlerRun ? Loc.T("tasks.badgeManual") : null;
```
`HandlerBaseCommit` muss dafür auf das Row-ViewModel und in dessen Mapping aufgenommen werden.
HANDLER hat Vorrang vor MANUAL — beide Badges nie gleichzeitig.
In `TaskRowView.axaml` analog zu `:141-144` ein `Border Classes="badge handler"` mit
`ToolTip.Tip="{loc:Tr tasks.handlerTip}"`. In `IslandStyles.axaml` eine `.badge.handler`-Variante
mit `{DynamicResource HandlerBadgeBrush}`, Token in `Tokens.axaml` für Light und Dark.
Neue Loc-Keys in en.json **und** de.json (Parität ist testgeprüft):
- `tasks.badgeHandler` — "HANDLER" / "HANDLER"
- `tasks.handlerTip` — "Handler run — lists the tasks it processed" / "Handler-Run — listet die
Tasks, die er bearbeitet hat"
### 4. Anzeige
Im Detail-Bereich eines Handler-Tasks eine Liste der behandelten Tasks, parallel zum bestehenden
Kinder-Panel:
- Neue Collection `HandledTasks` auf `DetailsIslandViewModel`, befüllt von `LoadHandledTasksAsync`
mit `Where(t => t.HandlerTaskId == taskId)`, sortiert wie die Kinder-Liste.
- Zeilen wiederverwenden `ChildOutcomeRowViewModel` (Id, Titel, Status, RoadblockCount,
WorktreeState) — keine neue Row-Klasse.
- Refresh über dieselben `TaskUpdated`-Events wie `ChildOutcomes`; der bestehende
`RefreshChildOutcomeAsync`-Pfad (`:814-833`) wird um die zweite Collection erweitert.
- Sichtbar nur wenn `HandledTasks.Count > 0`.
- **Keine Klick-Interaktion** — das bestehende `ChildOutcomes`-Template ist eine reine Anzeige
(Titel / Roadblock / Status, kein Tapped-Handler). Die neue Liste bleibt identisch; "zum Task
springen" wäre neues Verhalten und ist hier nicht enthalten.
### 5. Fehlerfälle
- Handler-Task gelöscht → `HandlerTaskId` der behandelten Tasks zeigt ins Leere; die Tasks bleiben
normal nutzbar, das Panel existiert schlicht nicht mehr. Kein Cleanup nötig.
- Behandelter Task gelöscht → verschwindet aus der Liste (Query läuft live gegen die Tasks).
- Leere Auswahl → kein Stempeln, Panel bleibt unsichtbar.
## Tests
| Ebene | Test |
|---|---|
| Data | `SetHandlerTaskIdAsync` stempelt alle übergebenen Ids, ignoriert unbekannte, überschreibt eine vorhandene Zuordnung |
| Worker | `CreateMergeHelperTaskAsync` stempelt die übergebene Auswahl und **nicht** den Handler-Task selbst |
| Ui | `TaskRowViewModel`: HANDLER schlägt MANUAL (`IsManual=true` + `HandlerBaseCommit` gesetzt → nur HANDLER) |
| Ui | `DetailsIslandViewModel`: `HandledTasks` lädt nach `HandlerTaskId`, aktualisiert sich auf `TaskUpdated` |
| Localization | Parität en/de — deckt der bestehende Test automatisch ab |
## Bewusst nicht enthalten
- Kein `TaskKind`-Enum.
- Keine n:m-Historie über mehrere Runs.
- Kein Phasen-Protokoll (Dedupe-Begründungen, Umformulierungen) — nur das Ergebnis.
- Kein Per-Task-Diff im Panel; der Diff bleibt am jeweiligen Task.
- Kein Badge auf den *behandelten* Tasks.
## Offen
- **Sichtprüfung durch Mika:** Badge-Farbe im Light- und Dark-Theme, Position des Panels im
Detail-Bereich, Verhalten bei vielen behandelten Tasks (Scroll).
@@ -0,0 +1,173 @@
# UI-Reaktivität und Listen-Performance
**Datum:** 2026-08-07
**Status:** Design freigegeben, Implementierung offen
## Problem
Zwei Symptome, die als eines gemeldet wurden:
1. **Stale UI.** Ein Task bleibt in der Liste auf `Queued` stehen, obwohl der Worker ihn längst auf `Running` gesetzt hat. Ebenso tauchen extern angelegte Tasks (Online-Inbox, List-Handler) erst nach einem manuellen Neuladen auf. Modals und Overlays zeigen den Stand vom Öffnungszeitpunkt.
2. **Langsames Laden.** Eine Liste mit ~125 erledigten Tasks braucht 12 Sekunden zum Öffnen. Ziel sind Listen mit bis zu ~1000 erledigten Tasks.
## Analyse
### Reaktivität: es fehlt kein Event, es fehlt die Selbstheilung
Der Broadcast-Pfad ist im Grundsatz korrekt: der Worker schreibt in die DB, committet, und sendet danach eine ID über SignalR (`HubBroadcaster`); die UI lädt die Entity frisch nach. WAL-Sichtbarkeit und EF-Change-Tracking wurden als Ursache **ausgeschlossen** — die UI nutzt `IDbContextFactory` mit kurzlebigen Kontexten, und WAL-Reader sehen Commits sofort.
Das eigentliche Problem: **ein einziger verlorener Event ist permanent.** Der einzige Reconcile-Trigger ist heute `ConnectionRestoredEvent`, also ein Verbindungsabbruch. Es gibt drei Wege, auf denen ein Update verloren geht:
| # | Loch | Ort |
|---|---|---|
| 1 | Blankes `catch { }` um den gesamten Delta-Pfad. Eine einzige transiente Exception (z.B. `SQLITE_BUSY`) lässt die Zeile dauerhaft auf dem alten Stand — ohne Log, ohne Retry. | `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs:224` |
| 2 | `QueuePicker.ClaimNextAsync` committet `status='running'` sofort. Wirft danach etwas in `RunInSlotAsync` oder im ungeschützten Setup-Block von `TaskRunner.ContinueAsync` (Zeilen 218238 liegen außerhalb jedes `try`), fängt der Catch das ab und **loggt nur** — kein `FailAsync`, kein Broadcast. DB sagt Running, die UI erfährt es nie. | `src/ClaudeDo.Worker/Queue/QueueService.cs:349-352` |
| 3 | DB-Writes ohne Broadcast. | `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:103`, `src/ClaudeDo.Worker/Online/OnlineSyncService.cs:131` |
**Korrektur (2026-08-07, bei der Umsetzung gefunden):** `InteractiveLaunchSpecService.cs:447` stand hier ursprünglich als drittes Loch. Das war falsch. Die Service-Methode broadcastet zwar selbst nicht, aber ihr einziger Produktions-Aufrufer `WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:818`) sendet direkt danach `TaskUpdated` — seit Commit `c07c1f7` vom 2026-08-05, abgesichert durch `MergeHelperTaskHubTests.CreateMergeHelperTask_CreatesIdleManualTask_StampsBaseCommit_Broadcasts`. Der ursprüngliche Befund hatte den DB-Write gesehen, aber den Aufrufer nicht geprüft. Ein Broadcast im Service wäre ein Duplikat gewesen.
Dazu zwei kleinere Befunde:
- **Race im Delta-Pfad.** `OnWorkerTaskUpdated` ist `async void` und hängt an *zwei* Events (`TaskUpdatedEvent` und `WorktreeUpdatedEvent`, `TasksIslandViewModel.cs:117-118`). Der Full-Reload-Zweig ist per `_loadCts` gegen Überholen abgesichert, der Delta-Zweig nicht — ein älterer Read kann einen neueren überschreiben.
- ~~**Kein Busy-Timeout konfiguriert.**~~ **Widerlegt (2026-08-07, empirisch geprüft).** Die Vermutung war, die blanken Connection-Strings (`src/ClaudeDo.App/Program.cs:95`, `src/ClaudeDo.Worker/Program.cs:61`) ließen einen `SQLITE_BUSY` sofort durchschlagen. Das stimmt nicht: Microsoft.Data.Sqlite 8.0.11 setzt `DefaultTimeout` **von sich aus auf 30 Sekunden**, mit oder ohne das Keyword — gemessen an `SqliteConnectionStringBuilder("Data Source=x.db").DefaultTimeout``30`, ebenso `SqliteConnection.DefaultTimeout` und `SqliteCommand.CommandTimeout`. Ein Contention-Test (Writer hält 2s, zweiter Writer parallel) zeigt, dass der zweite wartet und nach ~2030 ms durchkommt, statt zu werfen. Der ursprünglich dafür gemachte Commit `f62dbb9` war ein No-op mit irreführendem Kommentar und wurde mit `ac58679` zurückgenommen. Die tatsächliche Absicherung gegen transiente Lesefehler leistet der Retry im Delta-Pfad, nicht ein Timeout.
- **`RunCreated` ist ein totes Event.** Wird in `TaskRunner.cs:358` gesendet, hat aber keinen einzigen Abonnenten in der UI.
### Performance: der Engpass ist das Rendering, nicht die Datenbank
SQLite ist hier **nicht** der Engpass, und ein DB-Wechsel würde nichts verbessern. Die Kosten verteilen sich so:
| Posten | bei 125 Zeilen |
|---|---|
| SQLite-Read, 125 Zeilen × ~30 Spalten, 2 Joins | < 1 ms |
| EF-Materialisierung | ~15 ms |
| **Avalonia baut ~8.500 Controls mit Bindings** | **~1.0001.500 ms** |
Eine `TaskRowView` erzeugt **~68 Controls eager**: ~50 für Struktur und Inhalt plus 18 `MenuItem`-Deklarationen des inline deklarierten ContextMenus (`TaskRowView.axaml:35-85`). Die Liste ist **nicht virtualisiert**: drei `ItemsControl` (Overdue/Open/Completed) liegen in einem gemeinsamen `ScrollViewer` ohne `ItemsPanel`-Override (`TasksIslandView.axaml:100,119,152`). `ItemsControl` nutzt per Default ein normales `StackPanel`, und der gemeinsame `ScrollViewer` gibt allen dreien unbegrenzte Höhe — deshalb würde auch ein bloßes Setzen von `VirtualizingStackPanel` nichts bewirken.
Zeilenhöhe ~68px, verfügbare Listenhöhe auf 2560×1440 ~1250px → **~19 Zeilen gleichzeitig sichtbar**.
| | Controls | geschätzt |
|---|---|---|
| heute, 125 Tasks | ~8.500 | 12 s |
| heute, 1000 Tasks | ~68.000 | ~10 s+ |
| virtualisiert (19 sichtbar + Overscan ≈ 25 Zeilen) | ~1.700 | ~250 ms |
| + ContextMenu lazy | ~1.250 | ~180 ms |
Entscheidend: die virtualisierten Werte sind **konstant** und gelten für 125 wie für 10.000 Zeilen.
Nebenbefund: `LoadForList` (`TasksIslandViewModel.cs:305-309`) hat **kein `.Where()` vor `ToListAsync()`** — es lädt die komplette `tasks`-Tabelle aller Listen mit zwei Joins und filtert danach in C#. Die vorhandenen Indizes (`idx_tasks_list_id`, `idx_tasks_status`) werden dadurch nie genutzt. Bei der heutigen DB-Größe unkritisch, aber es skaliert mit der DB-Gesamtgröße statt mit der Listengröße.
### Drag & Drop: architektonisch virtualisierungsfähig
Die Task-Liste nutzt **kein** Avalonia-`DragDrop` pro Item, sondern ein eigenes Ghost-Drag:
- Vier Pointer-Handler hängen **zentral** an der `TasksIslandView` (`TasksIslandView.axaml.cs:40-43`, Tunnel-Routing) — keine pro-Zeile registrierten Handler, die beim Container-Recycling leaken könnten.
- Das Ziel wird per `InputHitTest` live ermittelt (`TasksIslandView.axaml.cs:304-329`) — kein Index-Hack, recycling-sicher.
- Drop-Hints sind reine ViewModel-Properties (`TaskRowViewModel.cs:25-26`, `DropHintAbove`/`DropHintBelow`).
- Der Ghost ist ein `RenderTargetBitmap`-Snapshot in einem separaten Topmost-Fenster (`Views/Controls/TaskDragController.cs`), losgelöst vom Visual Tree.
Anzupassen sind:
- `FindNextInSameSection` und `SectionFor` (`TasksIslandViewModel.cs:366-374`, `:622-628`) iterieren per `IndexOf` über die drei UI-Collections. Die flache Master-Collection `Items` existiert bereits (`TasksIslandViewModel.cs:55`) und ist korrekt sortiert — das ist der Grund, warum das Flatten bezahlbar ist.
- **Auto-Scroll beim Ziehen fehlt komplett.** Fällt heute weniger auf, weil alle Container realisiert sind; bei einer virtualisierten Liste ist es Pflicht.
- **Bestehender Bug:** Zieht man über eine Gruppengrenze, prüft `ReorderAsync` (`TasksIslandViewModel.cs:560-563`) zwar die Sektion, verschiebt bei ungleichen Sektionen aber nur `Items` ohne anschließendes `Regroup()`. `SortOrder` landet in der DB, die UI zeigt nichts.
- **Kein Präzedenzfall:** im gesamten `ClaudeDo.Ui`-Projekt existiert kein `VirtualizingStackPanel` und kein `ItemsRepeater`.
### Drag-Optik
Heute laufen zwei Darstellungen derselben Zeile gleichzeitig: der Bitmap-Ghost am Cursor **und** die Originalzeile mit `Opacity 0.55`, `scale(1.03)`, BoxShadow und Accent-Rand (`IslandStyles.axaml:441-446`). `scale(1.03)` ändert kein Layout und überlappt daher die Nachbarzeilen. Das Feedback für das Drop-Ziel ist der ganz normale `:pointerover`-Hover (`IslandStyles.axaml:433-435`, setzt nur `BorderBrush`) — dasselbe Signal wie beim harmlosen Drüberfahren; einen eigenen `drop-target`-Style gibt es nur für die Lists-Island (`Border.list-item.drop-target`). Zusätzlich laufen `BrushTransition` (0.12s) und `ThicknessTransition` auf `Margin` (0.15s) auch während des Drags mit, was die Rückmeldung verschwimmen lässt.
## Verworfene Alternativen
| Alternative | Warum verworfen |
|---|---|
| **SQLite ersetzen** | Kein Engpass. Der DB-Read liegt unter 1 ms; die Zeit steckt zu >95% im Aufbau des Visual Tree. Monatelange Arbeit für null messbaren Gewinn. |
| **Nur Task-Titel laden, Rest lazy beim Öffnen** | Richtige Intuition, falsche Ebene. Spart Bytes aus einer lokalen Datei, die in unter 1 ms gelesen wird. Die 125 Zeilen werden weiterhin als 125 vollständige `TaskRowView` gebaut. Um wirklich zu sparen, müsste das Zeilen-Template entkernt werden — also genau die Chips und Icons entfallen, wegen derer die Liste nützlich ist. Als *Zusatz* (schlanke Projektion für Speicher/Materialisierung) sinnvoll, als Hauptmaßnahme nicht. |
| **Completed einklappen + „mehr laden"** | Billig und sofort wirksam, aber aufgeklappt mit 1000 Zeilen hängt es wieder. Bleibt als **Fallback**, falls der Virtualisierungs-Spike scheitert. |
| **Completed archivieren** | Löst das Problem durch Vermeidung; der Wunsch war ausdrücklich, 1000 erledigte Tasks sehen zu können. |
| **`Revision`-Spalte / Change-Feed** | Strukturell sauber (verlorene Events wären egal, Race gelöst), aber Migration plus Anpassung jedes Schreibpfads. Für eine Single-User-Desktop-App mit lokaler DB Overkill; der Reconcile-Tick erreicht dasselbe Ziel deutlich billiger. Bleibt als Eskalation, falls Phase 3 in der Praxis nicht reicht. |
| **Nur die Löcher stopfen (ohne Reconcile)** | Behebt die bekannten Fälle, lässt die Architektur „ein verlorener Event = permanent stale" aber intakt. Das nächste Loch kommt mit dem nächsten Feature. |
## Design
### Phase 1 — Reaktivitäts-Löcher schließen
Unabhängig von Phase 2 und 3, kann sofort starten.
| Fix | Ort |
|---|---|
| `catch { }` ersetzen durch Log + einmaligen Retry. **Kein** Footer-Error — das ist ein Hintergrund-Refresh, keine Nutzeraktion. | `TasksIslandViewModel.cs:224` |
| Catch-Block ruft `_state.FailAsync` (das selbst broadcastet), statt nur zu loggen; `OperationCanceledException` bleibt ausgenommen | `QueueService.cs:349-352` |
| `WorktreeUpdated` nach dem Insert broadcasten | `Runner/WorktreeManager.cs:103` |
| `TaskUpdated` nach dem Insert broadcasten | `Online/OnlineSyncService.cs:131` |
| Monotone Sequenznummer pro TaskId im Delta-Pfad; Ergebnisse mit veralteter Sequenz verwerfen | `OnWorkerTaskUpdated` |
| `RunCreated` ersatzlos entfernen (totes Event ohne Abonnent) | `HubBroadcaster`, `TaskRunner.cs:358` |
Der ungeschützte Setup-Block in `TaskRunner.ContinueAsync` (Zeilen 218238) wird **nicht** separat umgebaut: sobald `RunInSlotAsync` im Fehlerfall `FailAsync` ruft, ist jede dort geworfene Exception abgedeckt — der Task landet auf `Failed` und der Broadcast erfolgt. Ein zweiter Schutzwall wäre doppelt.
### Phase 2 — Flache virtualisierte Liste
**Datenmodell.** `Regroup()` erzeugt statt drei Collections **eine** `Rows`-Collection vom Union-Typ (`HeaderRow` | `TaskRowViewModel`), abgeleitet aus der bereits vorhandenen flachen `Items`. Gruppenüberschriften werden zu regulären Einträgen:
```
Rows
[0] HeaderRow "Überfällig (3)"
[1] TaskRow …
[4] HeaderRow "Offen (12)"
[17] HeaderRow "Erledigt (125)"
```
**View.** Eine `ListBox` mit `VirtualizingStackPanel` und einem DataTemplate-Selector (Header / Task) ersetzt die drei `ItemsControl` und den umschließenden `ScrollViewer`.
**Drag.** `FindNextInSameSection`, `SectionFor` und `ReorderAsync` rechnen gegen `Items` statt gegen die UI-Collections. Auto-Scroll beim Ziehen an den Listenrand wird neu gebaut. Der Cross-Section-Reorder-Bug wird im selben Zug behoben, da die Sektionsgrenzen in der flachen Struktur ohnehin explizit modelliert werden müssen.
**Zeilenkosten.** Das ContextMenu wird bei `ContextRequested` im Code-Behind aufgebaut statt als 18 `MenuItem`s pro Template-Instanz.
**Query.** `.Where()` wandert vor `ToListAsync()`, damit die Query mit der Listengröße statt der DB-Gesamtgröße skaliert. Erfordert Umbau von `ITaskListFilter` von In-Memory-Prädikaten (`Matches(TaskEntity)`) auf `IQueryable`-Expressions.
**Drag-Optik (Variante A).** Ghost folgt dem Cursor; die Originalzeile kollabiert zu einer leeren, gestrichelten Lücke, die beim Ziehen an die jeweilige Zielposition mitwandert.
```
┌──────────────────────┐
│ Fix login bug │
├──────────────────────┤
│ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ │ ← leerer Slot, wandert mit
├──────────────────────┤
│ Rebase branch │
└──────────────────────┘
┌────────────────┐
│ Update deps │ ← Ghost am Cursor
└────────────────┘
```
Begleitend: `scale(1.03)` und BoxShadow auf der gezogenen Zeile entfallen; der normale `:pointerover`-Hover wird während eines aktiven Drags per `dragging`-Klasse am Container unterdrückt; Transitions sind während des Drags aus.
### Phase 3 — Reconcile-Tick
Setzt Phase 2 voraus: auf einer Liste, die 12 s zum Laden braucht, würde ein periodischer Abgleich alles verschlimmern.
Ein Timer (35 s) gleicht die **sichtbaren** Rows gegen die lokale SQLite ab und **patcht ausschließlich Properties** — er baut nie Zeilen neu und löst nie einen `LoadForList` aus. Bei einer lokalen DB und wenigen Dutzend sichtbaren Zeilen ist das ein einzelner indizierter Query.
Damit ist jeder verlorene Event nach spätestens einem Tick geheilt, unabhängig davon, wo er fehlte. Derselbe Tick versorgt zusätzlich die langlebigen Overlays: Worktrees-Overview, LogVisualizer und die MergeHelper-Auswahl.
Kurzlebige Modals (Settings, ListSettings, RepoImport, WeeklyReport, ConflictResolver) bleiben bewusst statisch — ein Dialog, der sich unter den Fingern des Nutzers ändert, ist schlechter als einer, der den Stand vom Öffnen zeigt.
## Tests
- **Worker.Tests:** Broadcast-Assertions über einen Fake-Broadcaster auf allen in Phase 1 gefixten Pfaden — insbesondere, dass der Fehlerfall in `RunInSlotAsync` einen Broadcast auslöst.
- **Ui.Tests:** Der Reconcile-Tick patcht abweichende Properties; ein Ergebnis mit veralteter Sequenznummer wird verworfen; `Regroup()` erzeugt die korrekte `Rows`-Folge inklusive Header-Positionen und -Zählern.
- **Nicht automatisiert testbar:** Ladezeit und Drag-Optik. Beides erfordert eine manuelle Gegenmessung mit einer großen Liste durch den Nutzer.
Keine Tests, die die echte `claude`-CLI starten.
## Risiken
1. **Kein Virtualisierungs-Präzedenzfall im Projekt.** Das HitTest-basierte Custom-Drag ist theoretisch tragfähig, aber nie gegen recycelte Container verifiziert. **Die erste Aufgabe in Phase 2 ist ein Spike**, kein Umbau: eine virtualisierte Liste mit dem bestehenden Drag, inklusive Recycling während eines aktiven Drags und Auto-Scroll. Scheitert der Spike, ist der Fallback „Completed einklappen + nachladen".
2. **Variable Zeilenhöhen.** 68 px im Normalfall, 90110 px bei zweizeiligem Titel oder mehreren Badges. `VirtualizingStackPanel` beherrscht das, aber die Scrollbar springt dabei gern, weil die Gesamthöhe geschätzt wird. Muss im Spike mitgeprüft werden.
3. **`ITaskListFilter`-Umbau.** Die Umstellung auf `IQueryable`-Expressions berührt die Filter-Registry (`src/ClaudeDo.Data/Filtering/`) und damit auch die virtuellen Listen. Kann bei Bedarf aus Phase 2 herausgelöst und nachgezogen werden — der Performance-Gewinn liegt heute ohnehin fast vollständig beim Rendering.
## Reihenfolge
Phase 1 → Phase 2 (Spike zuerst) → Phase 3. Phase 1 ist unabhängig und kann parallel oder vorab laufen.
@@ -0,0 +1,262 @@
# Findings-Store — Design
Stand: 2026-08-10.
Persistenter, projektgebundener Speicher für **Fallen und Invarianten**, den Agents im
Moment des Schmerzes selbst befüllen und in späteren Sessions vor dem Erkunden lesen.
Ziel ist Token-Ersparnis durch verhinderte Fehlläufe.
---
## 1. Ausgangslage
Auslöser war die Frage, ob ein Code-Knowledge-Graph (Graphify-Bauart) das
Wiedererkunden in jeder Session einspart. Die Messung sagt: nein, nicht in dieser Form.
### Die Zahlen (aus `docs/usage-optimization.md`, 2026-08-05)
- Kontext-Resend = **99,3 %** des Rohverbrauchs. Jeder Kontext-Token wird im Schnitt
**~425× nachberechnet**. Das gilt symmetrisch: für gespartes *und* für geladenes Wissen.
- **Explore-note ersetzt Exploration:** Note 4,6k tok in Turn 5 eines 60-Turn-Runs
→ 4,6k × 55 ≈ **250k**. Ersparte Exploration (~8k tok Findings ab Turn 10) ≈ **400k**.
Netto ~150k, Faktor 1,6. Wird die Note geladen, ohne gebraucht zu werden: **250k**.
- **Ein verhinderter Retry:** ~11 Mio Token. **27 % der Tasks brauchten einen Retry.**
Faktor 27 zwischen den beiden Klassen. Deshalb ist der Inhalt dieses Stores **nicht**
„Architektur-Map, damit ich nicht neu erkunden muss" (400k-Klasse), sondern „Agent läuft
in die bekannte Falle und der Run ist Müll" (11-Mio-Klasse).
### Was heute schon da ist
| Ebene | Größe | Ladeverhalten |
|---|---:|---|
| `CLAUDE.md` (root) | 6,4 KB ≈ 1,6k tok | immer |
| `src/*/CLAUDE.md` (6×) | 1,514 KB | beim Arbeiten im Verzeichnis |
| `docs/explore-notes/` (6 Notes) | 1118 KB, 92 KB gesamt | on demand, **ganze Datei** |
| Auto-Memory (`~/.claude/projects/.../memory/`) | 55 Dateien, 397 KB | Index immer (nur interaktiv) |
Die Sektionen der Notes sind bereits 1236 Zeilen (~150450 tok) — also schon
knotengroß. Und `conpty-sessions.md` markiert Fallen bereits konventionell als
`## ⚠️ Gotcha: …` (5 Stück). Die anderen Notes tragen dieselbe Art Wissen unmarkiert im
Fließtext. **Das Wissen ist zu großen Teilen schon geschrieben, nur nicht einsammelbar.**
---
## 2. Verworfene Alternativen
- **Code-Knowledge-Graph (Graphify o. ä.).** Ein tree-sitter-Graph liefert Call-Kanten —
genau die Klasse, die Grep exakt und billig liefert (Grep = 58 % des Kontexts; Read =
6068 %, das Problem ist zu viel *lesen*, nicht Finden). Der wertvolle Inhalt ist
semantisch und nicht ableitbar („Approve = merge the whole unit", „no directory arg may
end in a separator"). Dazu: Build-/Staleness-Pipeline über N parallele Worktrees und
Merge-Konflikte auf einem Graph-Artefakt im Repo. AXAML wird ohnehin nicht geparst.
- **DB-Tabelle + UI-Editor.** Kuration passiert in VSCode, damit entfällt der einzige
Vorteil. Agent-Zugriff bräuchte ein Lese-Tool statt `Read`; Agents könnten veraltete
Einträge nicht selbst korrigieren.
- **Hook-Injektion der Findings in den System-Prompt.** Früh und groß in den Prefix ist
das teuerste Muster überhaupt (Befund 5), auch wenn nichts davon gebraucht wird.
- **Post-Merge-Trigger.** Ursprünglicher Entwurf. Hinfällig, weil der Aufrufer des Tools
den Kontext bereits hat — siehe §4.
- **Retro-Distill aus alten Transkripten.** Zurückgestellt, siehe §8.
---
## 3. Speicher
```
<working-dir>/.claudedo/
INDEX.md # eine Zeile je Finding — die einzige Datei, die immer gelesen wird
traps/<slug>.md # ein Finding je Datei, ~150300 tok
```
`maps/` ist reserviert, aber **nicht Teil von v1**.
Ordner liegt im Repo-Root, das er beschreibt — maximale Erkennbarkeit, reist bei
Bedarf mit. `.claudedo` ist als Name frei; die Worktrees liegen unter dem *Geschwister*-Pfad
`.claudedo-worktrees`, und `TranscriptUsageReader` prüft auf das exakte Segment, also kein
Fehlalarm bei der Scope-Erkennung.
### Finding-Format
```markdown
---
slug: conpty-arg-quoting
scope: src/ClaudeDo.Worker/Planning
source-task: 5d627df8
verified-against: e10634b
---
# Ein Verzeichnis-Argument, das auf `\` endet, frisst alle folgenden Argumente
<26 Sätze: was passiert, warum es nicht danach aussieht, was man stattdessen tut.>
```
### INDEX.md
```markdown
# Findings
- [conpty-arg-quoting](traps/conpty-arg-quoting.md) — Dir-Arg auf `\` escapet sein eigenes Quote
- [shared-worktree-partial-commit](traps/shared-worktree-partial-commit.md) — nie blank committen
```
Reine Routing-Ebene: Titel + einzeiliger Aufhänger + Pfad. Ablauf im Agenten:
Index lesen (~700 tok) → ein bis zwei Findings lesen (~400 tok), statt 4 600 für eine
ganze Note.
### Einchecken oder nicht — Toggle je Liste
Beim Anlegen einer Liste: **`.claudedo` einchecken"**, Default **aus**.
- **Aus**`/.claudedo/` wird nach `.git/info/exclude` geschrieben (per-Clone,
unsichtbar fürs Repo, **keine getrackte Datei angefasst**). Mechanik existiert bereits in
`SessionSkillSeeder.AppendExcludeLineAsync` (auflösen über
`git rev-parse --git-path info/exclude`) — wiederverwenden, nicht neu bauen.
- **An** → nichts tun, der Nutzer commitet den Ordner selbst.
### Worktree-Regeln (hart)
- **Geschrieben wird ausschließlich im Haupt-Checkout, nie in einem Worktree.** Sonst
entstehen `INDEX.md`-Merge-Konflikte über parallele Tasks — exakt das Problem, das oben
gegen den Graph-Ansatz spricht.
- **Gelesen wird über den absoluten Pfad auf den Haupt-Checkout.** Eine Regel für beide
Toggle-Stellungen; in „aus" existiert der Ordner im Worktree ohnehin nicht.
- Falls der Store getrackt ist und der Distiller committet: pfad-skopiert
(`git commit -- .claudedo`), nie blank — der Haupt-Checkout wird von parallelen
Sessions geteilt.
- Ein Finding je Datei heißt: parallele Schreiber kollidieren nicht. Umkämpft ist nur
`INDEX.md`; das Tool serialisiert dessen Rewrite prozessintern.
---
## 4. Schreibweg — ein MCP-Tool
`save_finding(slug, title, body, scope = "", list = "")` am bestehenden `claudedo`-MCP-Server.
Signatur folgt den zwei test-erzwungenen Konventionen des Servers: jeder optionale
Parameter hat einen C#-Defaultwert, und das Tool gibt kein blankes `Task` und keine
nullable Payload zurück (→ `docs/explore-notes/external-mcp.md`). Beschreibungsstil nach
`External/McpToolDocs.cs`.
### Welcher Store wird beschrieben?
Der MCP-Server ist global, der Store projektgebunden — die Zuordnung muss explizit sein:
1. **Autonomer Run:** aus dem Aufrufkontext (`TaskRunMcpContext`) → Task → Liste →
`working_dir`. Der `list`-Parameter wird ignoriert.
2. **Interaktive Session ohne Task-Kontext:** `list` (Name oder Id) entscheidet. Fehlt er
und es existiert **genau eine** Liste, wird diese genommen; bei mehreren gibt das Tool
einen Fehler zurück, der die verfügbaren Listen nennt. Nie raten.
Geschrieben wird immer in `<list.working_dir>/.claudedo/` — also den Haupt-Checkout,
auch wenn der Aufrufer in einem Worktree läuft.
Bewusst ein **dummes Schreib-Tool**, kein „generiere mir Findings"-Tool: der Aufrufer hat
den Kontext bereits. Ein zweiter Claude-Lauf (RefineRunner-Bauart) würde ihn aus
Transkripten rekonstruieren — teurer und schlechter.
**Genau ein Tool.** Gelesen wird mit `Read` auf die Dateien; das kostet nichts und braucht
kein Tool. Der Server exponiert bereits ~60 Tools, eines mehr ist im Prefix Rauschen —
fünf wären es nicht.
**Verhalten:**
- Gleicher `slug`**überschreiben**, nicht anlegen. Dedupe gehört ins Tool, nicht in die
Prompt-Disziplin.
- Schreibt `traps/<slug>.md` **und** aktualisiert die Zeile in `INDEX.md` atomar.
- `verified-against` wird vom Tool aus dem aktuellen HEAD des Haupt-Checkouts gesetzt,
nicht vom Aufrufer.
- `source-task` wird aus dem Aufrufkontext gesetzt, wenn vorhanden (autonome Runs),
sonst leer (interaktive Sessions).
### Aufrufwege
1. **Im Run.** Ein Task-Agent tritt in eine Falle und hält sie fest. Höchste Qualität —
er hat gerade Turns dafür verbrannt. Billigster Weg, kein zusätzlicher Prozess.
2. **Merge-Helper-Abschlussphase.** Der List-Handler sweept am Ende nach, was ihm über den
Batch hinweg auffiel. Er sieht Diffs, nicht das Straucheln — daher Ergänzung zu (1),
kein Ersatz.
3. **Interaktive Sessions.** Der `claudedo`-MCP ist global registriert, das Tool steht
dort ohne Weiteres zur Verfügung.
4. **VSCode.** Nutzer korrigiert und löscht direkt in den Dateien.
### Aufnahmehürde
Ein Finding ist nur, was **dauerhaft**, **nicht offensichtlich** und **verhaltensändernd**
ist: „X sieht aus wie Y, ist aber Z — mach stattdessen W". Ein gefixter Bug ist **kein**
Finding, das ist Git-Historie.
Diese Hürde steht in der Tool-Beschreibung. Sie ist kein Stilhinweis, sondern die
Betriebsbedingung: `INDEX.md` wird immer gelesen; bei ~15 tok je Zeile sind 50 Findings
≈ 750 tok, 300 Findings ≈ 4 500 tok — ab da kostet der Index so viel wie früher eine ganze
Note und die Ersparnis ist weg. Deshalb zusätzlich eine **Warnschwelle bei 80 Einträgen**,
die das Tool im Ergebnis zurückmeldet.
---
## 5. Leseweg
ClaudeDo hängt einen Satz an den System-Prompt seiner eigenen Runs
(`--append-system-prompt`, wie heute schon für andere Zwecke genutzt):
> Findings-Index für dieses Projekt: `<abs>/.claudedo/INDEX.md`. Lies ihn, bevor du den
> Code erkundest, und öffne nur die Findings, die zu deiner Aufgabe passen.
~30 Token, komplett in ClaudeDo gekapselt, **keine CLAUDE.md wird angefasst** — weder die
des Projekts noch die globale.
Interaktive Sessions bekommen diesen Zeiger nicht (ClaudeDo startet sie nicht). Sie können
schreiben, aber nicht automatisch lesen. Wer das will, setzt selbst eine Zeile in seine
globale CLAUDE.md — außerhalb des Scopes dieses Features.
---
## 6. UI
Ein Button an der Liste: **„Findings öffnen"** → öffnet `<working-dir>/.claudedo/` im
Datei-Explorer bzw. der Standardanwendung. **Kein In-App-Editor** — Kuration passiert in
VSCode.
Dazu der Toggle aus §3 im Dialog „Liste anlegen" und in den Listen-Einstellungen.
---
## 7. Scope-Grenze (bewusst akzeptiert)
Die Kapselung in ClaudeDo deckelt den Lese-Nutzen auf den Agent-Anteil: laut Messung
**18,4 %** des Verbrauchs (interaktiv = 81,6 %). Das ist kein Argument gegen das Feature —
Retries passieren ausschließlich auf der Agent-Seite, und dort sitzt der 11-Mio-Hebel.
Aber „Token-Ersparnis" heißt hier: Ersparnis in den 18 %, nicht auf dem Konto insgesamt.
---
## 8. Nicht in v1
- **`maps/`** — Subsystem-Maps. Ordner reserviert, Inhalt später. Die bestehenden
`docs/explore-notes/` bleiben unangetastet.
- **Retro-Distill aus Transkripten.** Erst interessant, wenn belegt ist, dass Findings
überhaupt wirken. Rohmaterial bleibt verfügbar: vollständiges NDJSON je Run unter
`~/.todo-app/logs/{taskId}_run{N}.ndjson`, `ResultMarkdown` in `task_runs`,
`WorktreeEntity.BaseCommit/HeadCommit/MergeCommit` für den nachträglichen Diff.
- **Ernte der bestehenden `⚠️ Gotcha:`-Sektionen** aus `docs/explore-notes/` in den Store.
Naheliegender erster Füllstand, aber eine eigene, manuelle Aktion.
- **Wirkungsmessung** (welches Finding wurde je gelesen, hat es einen Retry verhindert).
Braucht ein eigenes Konzept; Dateien tragen keinen Zähler.
- **Automatischer Staleness-Check** gegen `verified-against`. Frontmatter trägt den Commit,
ausgewertet wird er in v1 nur vom Menschen.
---
## 9. Testbarkeit
- `save_finding`: Neuanlage, Überschreiben bei gleichem Slug, Index-Update, Slug-Validierung
(kein Pfad-Escape), Warnschwelle bei 80 Einträgen, paralleler Index-Rewrite.
- Store-Auflösung: Task-Kontext schlägt `list`; genau eine Liste ohne `list` → Treffer;
mehrere Listen ohne `list` → Fehler statt Raten; Schreibziel ist der Haupt-Checkout,
auch wenn der Aufrufer in einem Worktree sitzt.
- Die beiden `external-mcp`-Konventionen (Default je optionalem Parameter, kein blankes
`Task`/nullable Payload) sind test-erzwungen — die bestehenden Tests greifen automatisch.
- Toggle: `.git/info/exclude`-Zeile wird genau einmal geschrieben (Idempotenz) — die
bestehenden Tests zu `SessionSkillSeeder` sind die Vorlage.
- Kein Test darf die echte `claude`-CLI starten.
- `IWorkerClient` / `WorkerHub` / ViewModel-Konstruktoren ändern → handgeschriebene Fakes in
**beiden** Testprojekten mitziehen.
@@ -0,0 +1,73 @@
# Dependency chain display — Design
**Date:** 2026-08-11
**Status:** approved, not implemented
## Problem
`DependsOnTaskId` is invisible in the UI. `TaskRowViewModel` has no `DependsOnTaskId` property
and `TaskRowView.axaml` renders nothing for it — a chained task looks exactly like an unrelated
one. The execution order the user declared is not readable anywhere in the list.
## Target
A chain renders as a group: the head full width, its dependents indented behind a vertical rail,
each carrying a small circular step badge on the rail.
```
┌──────────────────────────────────────────┐
│ chain head (full width, no badge) │
└──────────────────────────────────────────┘
╷ ┌────────────────────────────────────┐
(1)│ first dependent │
╷ └────────────────────────────────────┘
╷ ┌────────────────────────────────────┐
(2)│ second │
╷ └────────────────────────────────────┘
╷ ┌────────────────────────────────────┐
(2)│ parallel to the second │
╷ └────────────────────────────────────┘
╷ ┌────────────────────────────────────┐
(3)│ third │
└────────────────────────────────────┘
```
## Model reading
- **Step number = hop distance from the chain head**, not a running counter. `DependsOnTaskId` is
a single FK, so several tasks may share one predecessor → same depth → **same number**. Two
rows showing `2` means "these two are both unblocked by step 1", which is the intended reading.
- **Chain head** = a task with no `DependsOnTaskId` that at least one other task points at. A task
with no dependents and no dependency is not a chain and renders exactly as today.
- Cycles are impossible (`TaskStateService.SetDependsOnAsync` rejects them), so the depth walk
always terminates. Still cap the walk defensively.
## Decisions
**One indent level only — parent wins.** The 24 px indent track already exists for planning
children (`TaskRowView.axaml:22-28`, gated on `ShowAsChild`). A planning child that *also* has a
`DependsOnTaskId` keeps the parent indent and shows its chain membership as a small inline
chip (`after #123`) instead of a second indent level. No nesting, no 48 px rows.
**The group is pulled together; the head carries it.** Chain members are re-ordered to sit
directly under their head regardless of `SortOrder`, so displayed order always equals execution
order. Dragging the head moves the whole group; members are not individually draggable.
**Head not in view → no orphan rails.** Exact precedent exists: `ParentInView` /`ShowAsChild`
(`TaskRowViewModel.cs:75`, computed in `Regroup` at `TasksIslandViewModel.cs:543`). Same
treatment — if the head is filtered out (other list, My Day, completed group), the row renders
flat with the `after #123` chip instead of a dangling rail.
**Badge has no `#`.** If task numbers (`2026-08-11-task-numbers-design.md`) also land, a row
would show a rail badge and a `#412` title prefix. The rail badge is a step position, not an
identity — render it bare (`2`), never `#2`.
## Slices
| # | Slice | Depends on |
|---|---|---|
| 1 | VM: `DependsOnTaskId` + depth/head computation in `Regroup`, group pull-together, drag semantics | — |
| 2 | View: rail, step badge, `after …` chip, locale keys | 1 |
Slice 1 exposes the contract Slice 2 binds to: `ShowAsChainMember`, `ChainStep`,
`ChainAfterLabel`.
@@ -0,0 +1,313 @@
# Feedback für langlaufende Operationen
**Datum:** 2026-08-11
**Status:** Design freigegeben, Implementierung offen
**Auslöser:** Ein Merge über das Detail-Pane blockierte minutenlang ohne jede Anzeige.
## Problem
Langlaufende Operationen geben kein Feedback. Der Nutzer klickt, nichts passiert sichtbar,
die App liest sich als abgestürzt. Der gemeldete Fall war ein Merge mit Verify-Gate, aber das
ist ein Symptom eines Musters: Feedback wird **pro Fall ad-hoc** gebaut
(`PrepStarted/Line/Finished`, `RefineStarted/Finished`, `PlanningMerge*`, `MergeProgress`),
und dabei geht regelmäßig eine Hälfte verloren oder eine Fläche wird vergessen.
Belegend: `HubBroadcaster.MergeProgress` existierte bereits, während `WorkerClient` noch
keinen Listener hatte — Sender ohne Empfänger. Das wurde parallel zu diesem Design in einer
anderen Session nachgezogen (siehe *Abhängigkeit zur Parallelarbeit*), deckt aber nur das
MergeModal ab, nicht den Approve-Pfad im Detail-Pane.
## Vier Fehlerbilder
Die Analyse trennt vier Klassen, die **verschiedene** Lösungen brauchen. Sie zusammen als
„fehlendes Feedback" zu behandeln war der Fehler der bisherigen Einzelfall-Fixes.
### Bild 1 — Stiller Await im UI
Ein `[RelayCommand]` awaitet einen Call, ohne Busy-State, ohne Anzeige. Der Button bleibt
klickbar (Doppelklick-Risiko), es gibt keinen Hinweis, dass etwas läuft.
| Stelle | Befund |
|---|---|
| `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:1206` `ApproveReviewAsync` | **Der gemeldete Fall.** Kein Busy-Flag, kein Indikator; das Verify-Gate kann den Call Minuten halten |
| `DetailsIslandViewModel.cs:1244` `SubmitForReviewAsync` | kein Busy-Flag (`MissionControlViewModel` hat für denselben Call ein `IsSubmitPending`, das Detail-Pane nicht) |
| `DetailsIslandViewModel.cs` `RejectReviewAsync`, `ParkReviewAsync` | kein Busy-Flag; Fehler werden verschluckt (`catch { return; }`) |
| `src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs:121` `PreviewMergeAsync` | kein Indikator |
| `WorktreesOverviewModalViewModel` (`IsBusy` vorhanden) | `IsBusy` schaltet in `WorktreesOverviewModalView.axaml:107-108` **nur** Buttons aus — kein Spinner, kein Text. Betrifft Cleanup, Reset, ForceRemove, Refresh, Batch-Merge |
| Settings-Tabs: `SessionSkillsSettingsTabViewModel`, `FilesSettingsTabViewModel`, `OnlineInboxSettingsViewModel` | dito — `IsEnabled="{Binding !IsBusy}"`, keine Anzeige. Skill-Install ist ein `git clone` |
| `WeeklyReportModalViewModel`, `RepoImportModalViewModel` | `IsBusy` nur als CanExecute-Gate |
| Islands ↔ SQLite direkt | Die Islands sprechen direkt mit der DB (`new TaskRepository(ctx)` u.ä., ~15 Stellen allein in `DetailsIslandViewModel`). Meist schnell; `ClearCompleted`, Drag-Reorder über viele Zeilen und das Laden großer Listen nicht zwingend |
Bereits sauber gelöst und **nicht** anzufassen: `MergeModalViewModel` (Spinner + Phase +
Elapsed), `ConflictResolverViewModel` (`IsBusy` + Text), `DiffViewerViewModel.IsLoadingCombined`,
`UsageMonitorModalViewModel` (Spinner).
### Bild 2 — UI-Thread-Freeze
Eine andere Fehlerklasse: hier hilft kein Indikator, weil der Dispatcher blockiert ist und
der Spinner nicht gezeichnet werden könnte. Erst auslagern, dann anzeigen.
| Stelle | Befund |
|---|---|
| `src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs:182` und `:260` | `UnifiedDiffParser.Parse(raw)` läuft **synchron auf dem UI-Thread** |
| `src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs:102` | `DiffAlignment.Build(File?.Lines)` ebenfalls |
Im gesamten `ClaudeDo.Ui`-Projekt gibt es **zwei** `Task.Run`-Offloads (`RepoScanner`,
`RestartWorkerService`). CPU-Arbeit auf dem UI-Thread ist damit die Norm, nicht die Ausnahme.
### Bild 3 — Worker-Stille
Operationen ohne UI-Auslöser. Kein Client erfährt, dass sie laufen; die UI zeigt im
Startfall nur „reconnecting".
| Stelle | Warum langsam |
|---|---|
| 6× `src/ClaudeDo.Worker/Lifecycle/*Recovery.cs` beim Worker-Start (`OrphanRecovery`, `StaleTaskRecovery`, `PromptFileRecovery`, `AttachmentOrphanRecovery`, `PlanningLineageRecovery`, `LegacyWorktreeFolderRecovery`) | git + DB über alle Tasks/Worktrees |
| `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` — Worktree-Anlage beim Task-Start | `git worktree add` + Branch; stille Lücke zwischen `Queued` und erster Ausgabe |
| `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs` | Hintergrund-git über alle Worktrees |
| `TaskMergeService.RebaseOthersAfterMergeAsync` | läuft **innerhalb** des Merge-Calls, **nach** dem Phasen-Broadcast — vollständig unsichtbar |
| `PlanningAggregator`, `PlanningMergeOrchestrator` | ein Merge pro Subtask |
| `OnlineSyncService`, `UsageMonitorService`, `PrimeScheduler`, `QueueService` | periodisch, Netz/git |
Querverweis: `docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md`
führt `WorktreeManager.cs:103` als DB-Write-ohne-Broadcast (Loch 3). C3 muss prüfen, ob das
inzwischen geschlossen ist, statt es doppelt zu beheben.
### Bild 4 — MCP-Stille
Trifft nicht die UI, sondern Agenten-Runs. Nur drei Dateien reporten Progress:
`ExternalMcpService` (5 Tools), `TaskWaitMcpTools`, `TaskMergeService`. Ohne Progress laufen
u.a. die 8 `batch_*`-Tools, `cleanup_task_worktree`, `get_task_diff`, `preview_merge_set`.
Das ist die dokumentierte Ursache des Traps „MCP 300s idle abort leaves merges stuck": der
Client bricht bei Stille ab, während der Worker weiterarbeitet, und der Task bleibt hängen.
### Bewusst ausgeschlossen: der Installer
`ClaudeDo.Installer` hat eine vollständige `IProgress<string>`-Step-Pipeline mit Live-Anzeige
(`Core/InstallerService.cs`, `Steps/*`, `Pages/InstallPage`). Das ist die beste Progress-Fläche
im Repo und braucht nichts.
## Design
### 1. UI-Primitive: `OperationStatus`
Eine `ObservableObject`-Klasse, die ein ViewModel als Property exponiert. Mehrere pro VM sind
erlaubt und erwünscht — `WorktreesOverviewModalViewModel` braucht getrennte für Refresh,
Cleanup und Merge, sonst blockiert ein laufender Refresh die Cleanup-Anzeige.
| Property | Verhalten |
|---|---|
| `IsRunning` | sofort `true` → treibt `CanExecute`, verhindert Doppelklick |
| `ShowIndicator` | erst nach **300 ms** `true` → kein Flackern bei schnellen Calls |
| `Label` | lokalisierter Text, **mid-flight überschreibbar** (`Report(label)`) |
| `Elapsed` | `mm:ss`, **lokal getickt** |
| `IsStalled` | `true`, wenn **60 s ohne Aktualisierung** vergangen sind → Hinweis „läuft weiter, Worker antwortet noch" |
`IsStalled` bemisst sich an der Zeit seit dem letzten `Report`, **nicht** an der Gesamtdauer.
Sonst würde ein regulär mehrminütiges Verify-Gate fälschlich als hängend gemeldet. Eine
Operation ohne jeden `Report` gilt nach 60 s als stalled — das ist der Fall, der heute wie ein
Absturz aussieht.
Benutzung:
```csharp
using var op = Approve.Begin(Loc.T("ops.merge.merging"));
var result = await _worker.ApproveReviewAsync(...);
```
`Dispose` beendet die Operation auch im Exception-Fall.
**Zeitquelle ist injizierbar** (`TimeProvider`, in .NET 8 vorhanden), kein statischer
`DispatcherTimer`. Ein geteilter statischer Timer wäre genau die Sorte Shared State, die die
`Ui.Tests` reihenfolgen-abhängig flaky macht. Timer-Callbacks kommen vom Threadpool und müssen
auf den Dispatcher gepostet werden.
**Fehlerbehandlung bleibt unverändert.** Weiter über `ShowErrorAsync` / `ErrorReported` /
`FlashFooterError`. `OperationStatus` transportiert keine Fehler.
### 2. UI-Control: `OperationIndicator`
`src/ClaudeDo.Ui/Views/Controls/OperationIndicator.axaml` — Spinner (`Ellipse.spinner` aus
`IslandStyles`) + Label + Elapsed, gebunden an eine `OperationStatus`. Ohne das Control wird
die Spinner-StackPanel aus `MergeModalView.axaml:28-30` acht Mal von Hand nachgebaut.
Alle Texte über Locale-Keys im Namespace `ops.*`; en/de-Parität erzwingen die
`Localization.Tests`.
### 3. Worker-Kanal: ein generisches Progress-Event
Der Worker sendet nur, was das UI **nicht erraten kann** — interne Phasen und Zählstände. Beim
Klick weiß das UI selbst, was es angestoßen hat.
```
OperationProgress(string opKey, string phase, int current, int total)
```
- `opKey` = TaskId bei task-gebundenen Operationen, sonst ein stabiler String
(`"worktree-cleanup"`, `"startup-recovery"`, `"planning-integration:<taskId>"`)
- **Kein Elapsed auf der Leitung** — das tickt das UI
- `current`/`total` erlaubt echten Fortschritt („Worktree 3/12"), was das heutige
`MergeProgress` nicht kann
Dieses Event **ersetzt** `MergeProgress`. Begründung, dass sich die Verallgemeinerung lohnt:
es gibt drei Produzenten (Merge-Phasen, Worktree-Cleanup, Planning-Integration), nicht einen.
**Aber `IWorkerClient.MergeProgressEvent` bleibt als dünner Forwarder bestehen.** Sonst
zerstört C1 die Gruppen-Isolation: A1 abonniert dieses Event, und ein Rename würde C zwingen,
`DetailsIslandViewModel` zu ändern — eine Datei, die Gruppe A besitzt. Der Forwarder kostet
vier Zeilen und hält die vier Sessions unabhängig. Er darf später entfernt werden, wenn A und
C beide gemergt sind.
### 4. MCP: `ProgressReporter` als eigene Klasse
`TaskMergeService.RunReportingProgressAsync` (Zeile ~175) ist die einzige existierende
Progress-Schleife. Sie wird als eigenständige Klasse extrahiert, sodass jedes MCP-Tool sie
nutzen kann, statt die Schleife zu kopieren. Zusätzlich ein Overload für Element-Fortschritt
(`i/n`) für die `batch_*`-Tools.
## Festgelegte Grenzen
Bewusst **nicht** Teil dieses Designs:
- **Kein Cancel.** Nur Sichtbarkeit. Ein Abbruch wäre bei einem Merge ohnehin irreführend: das
Verify-Gate läuft, wenn der Merge-Commit längst geschrieben ist — „abbrechen" könnte nur
*aufhören zu warten* bedeuten, nicht zurückrollen. Ohne Cancel entfällt der
`opId`-Handshake; die Korrelation läuft über `taskId` bzw. das offene Modal.
- **Keine Footer-Anzeige für laufende Operationen.** Anzeige am auslösenden Control und an der
Task-Zeile / im Detail-Pane. Der Footer bleibt für Fehler (`FlashFooterError`) und den
Worker-Log.
- **Keine Prozent-Balken.** Die meisten Operationen haben kein sinnvolles Total.
`current/total` wird als Text gezeigt, nicht als Balken.
- **Keine EF-Migration.** Damit entfällt der Trap paralleler Migrationen.
## Messung
„Erst messen, dann instrumentieren" gilt für Bild 1 und 3 — aber an **einer** Stelle, nicht an
zwanzig:
1. Ein Timing-Hook in `WorkerClient` loggt jeden Hub-Invoke mit Dauer.
2. Ein zweiter im DB-Pfad der Islands.
Ein Tag Nutzung liefert eine sortierte Liste echter Ausreißer. A5 und C entscheiden danach,
statt zu raten. Bild 2 und 4 brauchen keine Messung — der Befund ist aus dem Code eindeutig.
Faustregel für den Zweifelsfall: instrumentiert wird, was git aufruft, Netz nutzt, einen
Prozess startet, oder O(n) über unbegrenzt viele DB-Zeilen läuft. Einzelzeilen-Reads nicht.
## Parallelisierung: vier Merge-Helper-Gruppen
Der Zuschnitt folgt dem **Datei-Eigentum**, nicht der Fachlichkeit — nur so können vier
Sessions gleichzeitig laufen, ohne sich zu überschreiben.
```
P0 (Fundament, muss allein zuerst landen)
├── Gruppe A Bild 1: UI-Stille (5 Pakete)
└── Gruppe B Bild 2: UI-Freeze (3 Pakete)
Gruppe D Bild 4: MCP (4 Pakete) ← braucht P0 NICHT, kann sofort starten
Gruppe C Bild 3: Worker (5 Pakete) ← nach P0
```
| Gruppe | Exklusiv besessene Dateien |
|---|---|
| **P0** | `OperationStatus.cs`, `OperationIndicator.axaml`, **beide `locales/*.json`** |
| **A** | Island-VMs, Modal-VMs und deren AXAML |
| **B** | `DiffViewerViewModel.cs`, `DiffTextView.axaml.cs` |
| **C** | `HubBroadcaster`, `WorkerHub`, `WorkerClient`, `IWorkerClient`, `StubWorkerClient`, `Lifecycle/*Recovery`, `WorktreeManager` |
| **D** | `Worker/External/*McpTools`, `ExternalMcpService` |
**Der einzige echte Konfliktkandidat sind die Locale-Dateien.** A und C brauchen Keys — D
nicht: MCP-Progress-Meldungen gehen an Agenten, nicht an den Nutzer, und bleiben englische
Klartext-Strings ohne Locale-Eintrag.
Regel: **P0 legt die Keys für A und C vorab an**, danach fasst keine Gruppe die JSONs mehr an.
Braucht eine Gruppe doch einen nicht vorgesehenen Key, hängt sie ihn als **letzte** Änderung
des Pakets an und behandelt ihn im Merge-Helper als bekannte Konfliktstelle.
Drei erzwungene Serialisierungen:
- `TaskMergeService` — die Parallelsession arbeitet dort; C1 danach.
- `WorkerClient.cs` — P0-2 setzt dort den Timing-Hook, C ändert dieselbe Datei. P0 liegt
ohnehin vor C, damit unkritisch — aber C darf nicht vor P0 starten.
- Innerhalb jeder Gruppe `serializeOnFileOverlap` auf der Liste setzen.
Ergänzung zum Datei-Eigentum: `Services/UpdateCheckService.cs` gehört zu **A** (Paket A3),
`Services/WorkerClient.cs` zu **C**. Beide liegen im selben Ordner, sind aber verschiedene
Dateien.
### Abhängigkeit zur Parallelarbeit
Während dieses Designs hat eine andere Session im gemeinsamen `main`-Checkout das
Merge-Feedback für das **MergeModal** implementiert — inzwischen committet als
`cad0582 fix(merge): conventional merge-commit default and live verify progress`:
Phasen-Broadcast in `TaskMergeService`,
`HubBroadcaster.MergeProgress`, `IWorkerClient.MergeProgressEvent`,
`MergeModalViewModel.ProgressMessage`, Spinner in `MergeModalView.axaml`, plus Tests.
Konsequenzen:
- Der Merge-Fall im MergeModal ist **Vorarbeit**, nicht neu zu bauen.
- Zwei Nachbesserungen bleiben: A1 schließt den Approve-Pfad im Detail-Pane an, C1 stellt
Elapsed auf UI-lokalen Tick um (die Implementierung lässt den Worker alle 30 s ticken —
die ersten 30 Sekunden zeigt sie nur „merging").
- Der Blocker ist damit aufgelöst: C hängt nur noch an P0, nicht mehr an der Parallelsession.
## Pakete
### P0 — Fundament
| # | Inhalt |
|---|---|
| P0-1 | `OperationStatus` + `OperationIndicator` + `ops.*`-Keys **für alle vier Gruppen vorab** + `Ui.Tests` mit Fake-`TimeProvider` |
| P0-2 | Timing-Hook in `WorkerClient` + im DB-Pfad der Islands |
| P0-3 | Regel in `src/ClaudeDo.Ui/CLAUDE.md` und `src/ClaudeDo.Worker/CLAUDE.md`: jeder `IWorkerClient`-Call in einem `[RelayCommand]` läuft durch eine `OperationStatus`; MCP-Tools über ~5 s reporten Progress |
### Gruppe A — Bild 1: UI-Stille
| # | Inhalt |
|---|---|
| A1 | Detail-Pane: Approve & Merge, Submit for review, Reject, Park, Preview-Merge. Abonniert das Merge-Phasen-Event zum Nachschärfen des Labels |
| A2 | WorktreesOverview: Refresh, Cleanup, Reset, ForceRemove, Batch-Merge (inkl. Zeilen-Status) |
| A3 | Settings-Tabs: SessionSkill install/update, Restore-Defaults, OnlineInbox-SignIn, RepoImport-Scan, Update-Check |
| A4 | Reports und Planning-UI: GenerateWeekReport, RunDailyPrepNow, BuildPlanningIntegrationBranch, GetPlanningAggregate, Finalize/QueuePlanningSubtasks |
| A5 | Island-DB-Pfade — **nur die, die P0-2 als Ausreißer zeigt** |
### Gruppe B — Bild 2: UI-Freeze
| # | Inhalt |
|---|---|
| B1 | `UnifiedDiffParser.Parse` nach `Task.Run` + Indikator im DiffViewer |
| B2 | `DiffAlignment.Build`: Grenze definieren (ab N Zeilen auslagern oder inkrementell aufbauen) |
| B3 | Guard-Test: Parse/Build über ein großes Fixture blockiert den Dispatcher nicht |
### Gruppe C — Bild 3: Worker-Stille
| # | Inhalt |
|---|---|
| C1 | `OperationProgress(opKey, phase, current, total)` ersetzt `MergeProgress`; Elapsed auf UI-lokalen Tick |
| C2 | Startup-Recovery sichtbar (6 Services) — Anzeige im Shell statt nur „reconnecting" |
| C3 | Worktree-Anlage beim Task-Start sichtbar an der Task-Zeile. Vorher prüfen, ob der Broadcast-Gap aus dem 08-07-Spec schon geschlossen ist |
| C4 | `RebaseOthersAfterMergeAsync` und `WorktreeMaintenanceService` in den Kanal |
| C5 | Periodische Dienste (Usage, OnlineSync, Prime, Queue): nur Aktivität und Fehler, kein Tick-Spam |
### Gruppe D — Bild 4: MCP
| # | Inhalt |
|---|---|
| D1 | `ProgressReporter` aus `TaskMergeService.RunReportingProgressAsync` extrahieren, inkl. `i/n`-Overload |
| D2 | Die 8 `batch_*`-Tools mit Element-Fortschritt |
| D3 | Worktree- und Diff-Tools: `cleanup_task_worktree`, `batch_cleanup_task_worktrees`, `get_task_diff`, `preview_merge_set` |
| D4 | Restliche Long-Runner + Doku-Regel im Worker-`CLAUDE.md` |
## Test-Strategie
- **`OperationStatus`**: reine Unit-Tests mit Fake-`TimeProvider` — 300-ms-Grace, 60-s-Stall,
Elapsed-Formatierung, `Dispose` im Exception-Fall, `Report` überschreibt Label.
- **VM-Tests** (`Ui.Tests`, headless): Command setzt `IsRunning`, `CanExecute` sperrt während
des Laufs, Zustand ist nach einer Exception zurückgesetzt. Fakes: `StubWorkerClient` muss
bei jedem neuen Event mitwachsen.
- **B3** ist der einzige Test, der Blockierung prüft: großes Diff-Fixture, Messung, dass der
Dispatcher weiter Nachrichten verarbeitet.
- **Worker/MCP** (`Worker.Tests`): Progress-Callbacks werden mit einem Fake-`IProgress`
gezählt; keine echte Claude-CLI, keine echten Timeouts.
- **Visuell**: jedes Paket in A und B hat eine offene visuelle Prüfung. Spinner, Grace-Periode
und Layout kann kein Test bestätigen — das läuft über den Nutzer.
@@ -0,0 +1,116 @@
# Task Numbers (`#123`) — Design
**Date:** 2026-08-11
**Status:** approved, not implemented
## Problem
Tasks are only addressable by GUID. Every report from Claude reads
"task `0ef5ga…` is done", which is unreadable and untraceable for the user. A short,
stable, human-speakable handle is needed.
## Decision
Add a **global, monotonically increasing integer** `TaskEntity.Number`, displayed as `#123`.
- **Global, not per list.** Per-list numbering would make `#123` ambiguous across the ~15
lists and would force a list argument into every lookup. Global costs nothing and is
unambiguous.
- **Alias, not identity.** The GUID stays the primary key and stays in branch names
(`claudedo/{id}`), worktree paths, and the `ClaudeDo-Task:` commit trailer. `Number` is
purely a display + lookup alias.
- **Immutable, never reused.** A number that appeared in a log must never later point at a
different task. Deleting tasks leaves gaps — that is correct and intended.
## Allocation
`MAX(number) + 1` is **wrong**: deleting the newest task frees its number for reuse.
Use a persistent counter instead.
- `app_settings.next_task_number` (INTEGER NOT NULL, singleton row — same table as
`MaxTurnsCeiling` etc.).
- Allocation is one statement, atomic under SQLite's single-writer model:
`UPDATE app_settings SET next_task_number = next_task_number + 1
WHERE id = 1 RETURNING next_task_number - 1`
executed in the **same transaction** as the task insert.
- `tasks.number` gets a **UNIQUE index** (`idx_tasks_number`) so a collision is a DB error,
not silent corruption. On a unique violation, retry the allocation (bounded, 5 attempts).
Only **two** insert sites exist and both must allocate — every other creation path
(UI, `add_task`, `batch_add_tasks`, Online Inbox sync, merge-helper handler task,
planning children) routes through one of them:
- `TaskRepository.AddAsync` (`src/ClaudeDo.Data/Repositories/TaskRepository.cs:20`)
- `TaskRepository.AddChildAsync` (same file, ~line 297)
Do **not** reuse `SortOrder` — it is per list and mutable via drag & drop.
## Migration + backfill
One migration, created **alone** (never in parallel with another migration — sibling
migrations off the same parent silently drop each other's columns on a SQLite table rebuild):
1. `ALTER TABLE tasks ADD COLUMN number INTEGER NOT NULL DEFAULT 0`
2. Backfill in creation order:
`UPDATE tasks SET number = (SELECT COUNT(*) FROM tasks t2
WHERE t2.created_at < tasks.created_at OR (t2.created_at = tasks.created_at AND t2.id <= tasks.id))`
(or the equivalent `ROW_NUMBER()` window function).
3. `ALTER TABLE app_settings ADD COLUMN next_task_number INTEGER NOT NULL DEFAULT 1`
then set it to `(SELECT COALESCE(MAX(number), 0) + 1 FROM tasks)`.
4. Create the unique index **after** the backfill.
⚠️ Tests use `EnsureCreated`, which bypasses migrations — the backfill needs a test that
explicitly runs `Migrate()` against a DB seeded with pre-migration rows.
## Resolution: `#123` → GUID
New `TaskIdResolver` in `src/ClaudeDo.Worker/External/`:
- Input `#123` or bare `123` (a GUID is never all-digits, so this is unambiguous) → look up
by number.
- Anything else → passed through as a GUID.
- Unknown number → a clear MCP error (`no task with number 123`), never a silent null.
Wired at the top of every MCP tool taking a task id (~44 parameters across `External/`),
including the id arrays of the `batch_*` tools.
## Output surface
Two central mappers cover most tools:
- `ExternalMcpService.ToDto` (line ~1629) → `TaskDto`
- `ExternalMcpService.GetTaskRefAsync` (line ~335) → `TaskRefDto`
Add `Number` to both. Then the DTOs that carry a bare task id and bypass those mappers:
`RunTaskNowResult`, `MergeTaskResultDto`, `PossibleDuplicateDto`, `SubsetRelationDto`,
`FileOverlapDto`, the `BatchMcpTools` results, `TaskWaitMcpTools`, `QueueStateMcpTools`,
`RunHistoryMcpTools`.
`McpToolDocs` gets a shared clause instructing the agent to **refer to tasks as `#<number>`
when reporting to the user**. Without this the number is present in the payload but never
spoken — this clause is the actual point of the feature.
## UI surface
- `TaskRowViewModel.Number` → dim `#123` in the row, tokenized (no inline literals).
- Detail pane header.
- `HubBroadcaster.WorkerLog` business events in `TaskRunner` / `TaskMergeService` /
`TaskResetService` say `#123` instead of the GUID.
## Out of scope (possible follow-up)
Branch names and worktree folder names (`claudedo/123-<slug>`, `123-<slug>`). Cheap once
numbers exist, but a separate change — it touches merge, self-heal, and the merge helper.
## Slices
| # | Slice | Depends on |
|---|---|---|
| 1 | Schema, counter, allocator, backfill migration + tests | — |
| 2 | MCP output: `Number` in all task-carrying DTOs | 1 |
| 3 | MCP input: `TaskIdResolver` + `McpToolDocs` reporting clause | 2 |
| 4 | UI: row/detail display + worker-log messages | 1 |
| 5 | Docs: `CLAUDE.md` (Data, Worker), explore-notes | 3, 4 |
2 and 3 both edit `ExternalMcpService.cs` heavily, so 3 waits for 2 rather than running
beside it. 4 touches disjoint files and can run beside 2.
+196
View File
@@ -0,0 +1,196 @@
# Usage-Optimierung — Befunde und Messmethodik
Stand: 2026-08-05. Ausgangsfrage: Wie lassen sich autonome ClaudeDo-Agents
token-effizient betreiben? Abrechnung läuft über **Abo-Session-Limits, nicht über
die API** — relevant ist roher Token-Verbrauch gegen 5h-/7d-Fenster, nicht Geld.
Dieses Dokument ist bewusst kompakt gehalten. Was eine Session früh in den Prefix
lädt, wird mit jeder weiteren Nachricht multipliziert (siehe Befund 5) — ein
30k-Handover-Dokument würde das Problem reproduzieren, das es beschreibt.
---
## 1. Verbrauchsstruktur
Gemessen über alle Transcripts unter `C:\Users\mika.kuns\.claude\projects`.
| Posten | Roh-Token | Anteil |
|---|---:|---:|
| Cache-Read | 3.118.025.644 | 95,6 % |
| Cache-Write | 140.783.227 | 4,3 % |
| Output | 22.466.702 | 0,7 % |
| Input frisch | 141.496 | 0,0 % |
**Kontext-Resend = 99,3 % des Rohverbrauchs.** Auch unter API-Preisgewichtung
(read 0,1× / write 1,25× / out 5×) bleibt es bei 81,3 %. Die Rangfolge ist gegen
jede plausible Gewichtung robust.
Verstärkung: ~3,6 Mio einzigartiger Inhalt → 1,59 Mrd abgerechnete Prompt-Token
in ClaudeDo-Sessions. **Jeder Kontext-Token wird im Schnitt ~425× erneut
abgerechnet.**
## 2. Scope-Split — wer verbraucht
| Scope | Roh-Token | Anteil |
|---|---:|---:|
| INTERAKTIV (Mikas eigene Sessions) | 2.690.028.785 | **81,6 %** |
| AGENT (ClaudeDo-Runs) | 606.222.055 | **18,4 %** |
Interaktiv nach Modell: opus-4-8 33,0 % · opus-5 28,5 % · sonnet-5 14,0 % ·
fable-5 5,2 %. Agent-Runs fahren überwiegend sonnet-5 — der Default greift, die
Modelldisziplin auf Agent-Seite ist in Ordnung.
**Konsequenz:** ClaudeDo-Optimierung adressiert maximal 18 % des Limits. Der
größere Block sind die eigenen Opus-Sessions.
## 3. Session-Länge ist der Treiber
Verbrauch ≈ Nachrichten × Ø-Kontext, und der Kontext wächst mit den Nachrichten →
**quadratisch**. Eine Session in k Teile schneiden bringt grob 1/k.
Interaktiv: **Top 20 von 255 Sessions = 55,9 % des Verbrauchs.**
| Roh-Token | Msgs | Ø Kontext | Modell |
|---:|---:|---:|---|
| 199.516.097 | 716 | 278.653 | opus-4-8 |
| 138.020.247 | 440 | 313.682 | opus-5 |
| 123.586.522 | 463 | 266.925 | opus-4-8 |
## 4. Was den Kontext füllt
| Tool | Anteil (Agent) | Anteil (interaktiv) |
|---|---:|---:|
| Read | 59,8 % | 68,2 % |
| Bash | 16,3 % | 15,4 % |
| Grep | 8,3 % | 5,2 % |
Read ohne `offset`/`limit`: **57 % (Agent) / 62 % (interaktiv)**.
Re-Reads derselben Datei in derselben Session: 18 % der Read-Calls.
Subagent-Nutzung: nur 40 Calls bei 1.325 Reads (Agent-Seite) — die
Kontext-Firewall ist praktisch ungenutzt. Interaktiv sind Subagent-Sessions
dagegen 20 % des Verbrauchs.
Teuerste Read-Ziele sind God-Files: `TasksIslandViewModel.cs` (1121 Z.),
`ExternalMcpService.cs` (1002 Z.), `WorkerHub.cs` (979 Z.).
## 5. Prefix-Größe × Nachrichtenzahl schlägt alles
Fallstudie an der Analyse-Session selbst (136 Msgs, 43.492.835 Roh-Token):
```
msg 1 : 39.914
msg 3 : 273.822 (+233.908) <-- claude-api-Skill geladen
msg 136: 380.327
```
Der Skill in Nachricht 3 wurde danach 136× mitgelesen: **31,8 Mio Token = 73 %
der Session**. Zum Vergleich: **alle tool_results zusammen = 21.600 Token
(0,05 %)**.
Discovery ist praktisch gratis, wenn man aggregiert statt Dateien dumpt. Teuer ist
ausschließlich, was früh und groß in den Prefix wandert. Derselbe Block in
Nachricht 120 geladen hätte ein Zwanzigstel gekostet.
## 6. Amortisation von Planungssessions
| Posten | Token |
|---|---:|
| Analyse-Session gesamt | 43,5 Mio |
| davon vermeidbarer Prefix-Ballast | 31,8 Mio |
| echte Planungsleistung | ≈ 11,7 Mio |
Gegenrechnung: **10 von 37 Tasks brauchten einen Retry (27 %)**, 13 von 54 Runs
sind Wiederholungen. Ein Agent-Run liegt im Schnitt bei ~11 Mio Roh-Token — ein
vermiedener Retry spart also grob eine ganze Planungssession. Dazu ersparte
Rediscovery: ein Fund von ~8k Token, den ein Agent in Turn 10 eines 60-Turn-Runs
selbst machen müsste, wird danach ~50× mitgelesen = ~400k pro Fund.
**Fazit: gründliche Planung rechnet sich — aber nur bei schlankem Prefix.**
---
## Was NICHT hilft (geprüft und verworfen)
- **`--effort` senken.** Thinking ist 12.536 Token über alle interaktiven
Sessions = **0,00 %** des Verbrauchs. Effort zu senken spart nichts und kostet
Qualität. Endgültig erledigt.
- **Skill-Trigger entschärfen.** Der `claude-api`-Skill kostete in einer Session
73 % — feuerte aber nur **2× in 3 Wochen** (2026-07-14, 2026-08-05). Erwarteter
Nutzen vernachlässigbar, Risiko (Antworten aus veraltetem Prior) real. Er hat
sich in dieser Session sogar bezahlt gemacht: der Hinweis, dass `input_tokens`
nur der uncached Rest ist, hat den Accounting-Bug aufgedeckt.
- **God-Files splitten.** Eigenes Risiko; Read-Disziplin entschärft das Symptom
billiger.
## Offene Hebel — noch nicht untersucht
- Interaktive Session-Hygiene: ab wann lohnt `/clear`, messbar an Ø-Kontext?
- Modellrouting interaktiv (opus-4-8 + opus-5 = 61,5 % des Accounts).
- Subagent-Ökonomie: Firewall-Nutzen gegen Eigenverbrauch (20 % interaktiv).
- MCP-Tool-Definitionen im Prefix: Umfang bei ~200 deferred Tools nicht gemessen.
- Kalibrierung der tatsächlichen Limit-Gewichtung gegen die Rohtoken-Zahlen
(braucht `/usage`-Ausgabe; der OAuth-Endpoint ist für Claude nicht zugänglich,
weil `~/.claude/.credentials.json` hart blockiert ist).
---
## Messmethodik (reproduzierbar)
Datenquelle: `C:\Users\mika.kuns\.claude\projects\**\*.jsonl`, ein Record je
Message, Verbrauch in `message.usage`.
```python
raw = (u.get('input_tokens',0) + u.get('output_tokens',0)
+ u.get('cache_read_input_tokens',0) + u.get('cache_creation_input_tokens',0))
```
Kontextgröße einer Message = `input_tokens + cache_read + cache_write` (ohne
Output). Der Verlauf über die Session zeigt Prefix-Sprünge.
### Fallstricke
1. **Scope-Filter.** NICHT nach Pfadname `claudedo` filtern — das zieht
interaktive Sessions im ClaudeDo-Repo mit rein und verfälscht den Split massiv
(53 % statt korrekt 18,4 %). Agent-Runs erkennt man an `claudedo-worktrees`
oder `sandbox` im Projektordner, so wie es `TranscriptUsageReader` macht.
2. **`<synthetic>`-Messages** überspringen — keine echten API-Calls.
3. **`task_runs.tokens_in` ist unbrauchbar** — liest nur `input_tokens`, also den
uncached Rest. Lag bei einer 79-Mio-Token-Session bei 200 (Faktor ~400.000).
`tokens_out` zusätzlich 3,8× zu niedrig. Siehe Task `38394081`.
4. **In Bash absolute Pfade verwenden**`$HOME`/`~` zeigt auf dieser Maschine
auf das P:-Laufwerk, nicht auf `C:\Users\mika.kuns`.
5. **DB nie direkt lesen** während die App läuft — Kopie ziehen (`todo.db` +
`todo.db-wal`).
6. **Grundrate prüfen, bevor ein Hebel empfohlen wird.** Ein teurer Einzelfall ist
kein Muster (siehe Skill-Trigger oben).
---
## Abgeleitete Tasks (Liste „Claude do")
Empfohlene Reihenfolge:
| # | ID | Titel |
|---|---|---|
| 1 | `1b599d67` | Prompt-Dateien frieren Default ein — `SuggestImprovement`/`AskUser` tot |
| 2 | `38394081` | Limit-Verbrauch pro Run sichtbar machen (Cache-Token in `task_runs`) |
| 3 | `0d0aa8b0` | Read-Disziplin + Explorer-Subagent im System-Prompt |
| 4 | `2de2f008` | `max_turns` deckeln (Ceiling, Defaults, UI-Warnung) |
| 5 | `87105f5e` | UsageGate: Parallelität stufenweise drosseln |
(1) zuerst, weil es ein echter Funktionsbug ist und Voraussetzung dafür, dass (3)
den Nutzer überhaupt erreicht. (2) als Nächstes, weil ohne korrektes Accounting
nichts messbar ist.
### Ist-Zustand zum Zeitpunkt der Analyse
- `app_settings`: `default_model` = sonnet, `default_max_turns` = 100,
`max_parallel_executions` = 3, `model_presets` = **null** (fällt auf Defaults
zurück: haiku 20 / sonnet 30 / opus 40 / fable 25 — greifen faktisch nie).
- 15 Tasks überschreiben `max_turns` nach oben: 10× auf 100, 5× auf 200.
- UsageGate existiert bereits: 5h @ 80 %, 7d @ 90 %
(Migration `20260805074906_AddUsageGateAndRunModel`, dieselbe Migration hat auch
die `model`-Spalte auf `task_runs` gebracht).
- `~/.todo-app/prompts/system.md` stammt vom 2026-06-04 und weicht vom
`SystemDefault` in `PromptFiles.cs` ab; `agent.md` und `planning.md` dort sind
verwaiste Reste eines alten Namensschemas.
+1
View File
@@ -22,6 +22,7 @@
<converters:BoolToItalicConverter x:Key="BoolToItalic"/>
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
<converters:LogKindForegroundConverter x:Key="LogKindForeground"/>
<converters:KeepLastNumberConverter x:Key="KeepLastNumber"/>
</ResourceDictionary>
</Application.Resources>
+16 -1
View File
@@ -3,7 +3,9 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using ClaudeDo.Ui;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.Views;
@@ -40,9 +42,22 @@ public partial class App : Application
// modeless Mission Control window is still open.
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
var shell = services.GetRequiredService<IslandsShellViewModel>();
// Last-resort backstop: an exception escaping a dispatcher job kills the process, and
// ClaudeDo hosts third-party UI (the ConPTY terminal control) whose async void key and
// render handlers have done exactly that — one bad keystroke took the whole app down,
// losing every open session. Swallowing is the lesser evil here: the failing job is
// already dead either way, and the user still gets told via the footer error strip.
Dispatcher.UIThread.UnhandledException += (_, e) =>
{
e.Handled = true;
shell.FlashFooterError(Loc.T("vm.shell.unexpectedError", e.Exception.Message));
};
desktop.MainWindow = new MainWindow
{
DataContext = services.GetRequiredService<IslandsShellViewModel>(),
DataContext = shell,
};
// Kick off the SignalR retry loop — reconnects indefinitely if the worker
+4 -10
View File
@@ -8,19 +8,13 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
- `App.axaml` / `App.axaml.cs` — Avalonia application lifecycle, main window creation, static `ServiceProvider` accessor
- `ViewLocator.cs` — reflection-based IDataTemplate that maps ViewModels to Views by naming convention
## Dependencies
- Avalonia 12.0.0 (Desktop, Fluent theme, Inter fonts)
- CommunityToolkit.Mvvm 8.4.1
- Microsoft.Extensions.DependencyInjection 8.0.1
- Microsoft.AspNetCore.SignalR.Client 8.0.11
- Microsoft.Data.Sqlite 8.0.11
- Project references: ClaudeDo.Data, ClaudeDo.Ui
Project references: `ClaudeDo.Data`, `ClaudeDo.Ui`. Package versions are in the `.csproj` — see
the root CLAUDE.md for the tech stack.
## DI Registration Pattern
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `UpdateCheckService`, `IPrimeScheduleApi`/`WorkerPrimeScheduleApi`, `INotesApi`/`WorkerNotesApi`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WeeklyReportModalViewModel`, `DiffViewerViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation (`Func<DiffViewerViewModel>` for the diff viewer); `ConflictResolverViewModel` via a `Func<string, ConflictResolverViewModel>` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`)
- **Singletons** `IDbContextFactory`, all repositories, `GitService`, `WorkerClient`, `IReleaseClient`, `UpdateCheckService`, `IPrimeScheduleApi`, `INotesApi`, `InstallerLocator`/`WorkerLocator`, the three island VMs, and `IslandsShellViewModel` (the window's DataContext)
- **Transients** — modal VMs, several exposed as `Func<T>` factories for on-demand dialog creation (e.g. `Func<DiffViewerViewModel>`). `ConflictResolverViewModel` uses a `Func<string, ConflictResolverViewModel>` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`).
## Notes
+25 -7
View File
@@ -52,8 +52,14 @@ sealed class Program
// Dispose the container so WorkerClient.DisposeAsync runs —
// cancels the retry loop and closes the SignalR connection cleanly
// instead of abandoning it.
try { services.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
catch { /* best effort on shutdown */ }
try
{
services.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
catch
{
/* best effort on shutdown */
}
}
}
@@ -102,7 +108,9 @@ sealed class Program
sc.AddSingleton<IWorkerClient>(sp => sp.GetRequiredService<WorkerClient>());
// Release check + installer update
sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) });
sc.AddSingleton<HttpClient>(_ =>
new HttpClient(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
{ Timeout = TimeSpan.FromSeconds(10) });
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
sc.AddSingleton<InstallerLocator>();
sc.AddSingleton<WorkerLocator>();
@@ -111,7 +119,9 @@ sealed class Program
var releases = sp.GetRequiredService<IReleaseClient>();
var informational = Assembly.GetEntryAssembly()?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
// Strip MinVer build metadata ("+sha") and any prerelease suffix for the update-compare.
// Strip MinVer build metadata ("+sha") only — keep the prerelease suffix
// (e.g. "-alpha.0.14"), VersionComparer needs it to tell a dev build on
// main apart from a tagged release of the same numeric core.
var version = (informational ?? "0.0.0").Split('+')[0];
return new UpdateCheckService(releases, version);
});
@@ -124,7 +134,8 @@ sealed class Program
sc.AddTransient<DiffViewerViewModel>();
sc.AddTransient<Func<DiffViewerViewModel>>(sp => () => sp.GetRequiredService<DiffViewerViewModel>());
sc.AddTransient<WorktreesOverviewModalViewModel>();
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp =>
() => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
sc.AddTransient<MergeHelperSelectionModalViewModel>();
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
sc.AddSingleton<INotesApi, WorkerNotesApi>();
@@ -137,7 +148,11 @@ sealed class Program
sc.AddTransient<RepoImportModalViewModel>();
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
sc.AddTransient<WeeklyReportModalViewModel>();
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp =>
() => sp.GetRequiredService<WeeklyReportModalViewModel>());
sc.AddTransient<UsageMonitorModalViewModel>();
sc.AddTransient<Func<UsageMonitorModalViewModel>>(sp =>
() => sp.GetRequiredService<UsageMonitorModalViewModel>());
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
sp.GetRequiredService<IWorkerClient>(), taskId));
@@ -159,10 +174,13 @@ sealed class Program
sp,
sp.GetRequiredService<INotesApi>(),
sp.GetRequiredService<IMergeCoordinator>()));
sc.AddSingleton<UsagePillViewModel>(sp =>
new UsagePillViewModel(sp.GetRequiredService<IWorkerClient>()));
sc.AddSingleton<MissionControlViewModel>(sp =>
new MissionControlViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<IWorkerClient>()));
sp.GetRequiredService<IWorkerClient>(),
sp.GetRequiredService<UsagePillViewModel>()));
sc.AddSingleton<IslandsShellViewModel>(sp =>
{
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
+84 -27
View File
@@ -4,51 +4,108 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
## Models
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual (reminder only the user can do — automation skips it), Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
- **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), DependsOnTaskId (FK to a user/MCP-declared predecessor, distinct from BlockedByTaskId), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId, Number (global immutable human-readable alias).
- Status / PlanningPhase / BlockedByTaskId / DependsOnTaskId semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model.
- `HandlerBaseCommit`/`HandlerHeadCommit` = the review range for a **worktree-less "list handler" host task** ("Let Claude handle it"), which commits straight into the list's working dir instead of a per-task worktree. Everything that reads a task's diff falls back to this pair whenever `Worktree` is null → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
- `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
- `Number` (INTEGER NOT NULL, unique index `idx_tasks_number`) — a global, monotonically increasing display alias for tasks. **Never reused**: deleting a task leaves a gap in numbering, ensuring that a task number that appears in a log or report always points to the same task (if it exists). Allocated by `TaskNumberAllocator.AddWithNumberAsync` on every insert via a persistent counter (`app_settings.next_task_number`). **Why not `MAX(number) + 1`:** deleting the highest-numbered task would free its number for reuse, breaking the immutability guarantee. The allocator uses an `UPDATE…RETURNING` statement to claim numbers atomically, retrying up to 5 times if a uniqueness collision occurs (the counter advances on each attempt, so retries always allocate fresh numbers). ⚠️ Tests use `EnsureCreated`, which bypasses migrations — the backfill needs a migration that runs `Migrate()` explicitly (not tested by default; see `worker-task-pipeline` notes).
- Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill via the `RetireLegacyTaskStatus` migration.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`)
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable)
- **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)
- **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, stored as `days_of_week` int), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on the selected weekdays; no date range.
- **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → table `daily_notes`
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → table `week_reports`, unique index on (start_date, end_date)
- **TaskAttachmentEntity** — Id, TaskId (FK to tasks, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → table `task_attachments`
- **AppSettingsEntity** also carries `ReportExcludedPaths` (string?, JSON array of excluded path prefixes, column `report_excluded_paths`), `StandupWeekday` (int DayOfWeek, default Wednesday, column `standup_weekday`), and `DailyPrepMaxTasks` (int, default 5, column `daily_prep_max_tasks` — hard cap on how many open tasks the daily-prep / "Prime Claude" feature may place in MyDay) and `ModelPresets` (string?, JSON array of `ModelPreset` rows, column `model_presets`)
- **ModelPresets** / **ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`): one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1200) and never throw — a malformed settings row must not stop a run. `For(presets, model)` always returns a usable row. Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25.
- **EffortRegistry** — the `--effort` levels (`low|medium|high|xhigh|max`) + `NormalizeLevel` (blank → null = don't pass the flag)
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md).
- **WorktreeEntity** — TaskId (PK, 1:1), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable — SHA of the merge commit this branch produced; the only thing making `revert_merge` possible without searching `git log`), State (`Active|Merged|Discarded|Kept`)
- **TaskRunEntity** — per-run record: session_id, turns, result, structured output, exit code, log path, nullable `Model` (what the run actually executed with), and `TokensIn`/`TokensOut`/`CacheReadTokens`/`CacheWriteTokens`. ⚠️ Token fields come from the **session transcript**, not the stream-json event, as a per-run delta → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
- **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, column `days_of_week`), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on selected weekdays; no date range.
- **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → `daily_notes`
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → `week_reports`, unique index on (start_date, end_date)
- **TaskAttachmentEntity** — Id, TaskId (FK, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → `task_attachments`
- **SubtaskEntity**, **AgentInfo** — subtasks / record for scanned agent files
### AppSettingsEntity
Beyond the basics it carries:
| Property | Column | Default | Note |
|---|---|---|---|
| `DefaultMaxTurns` | `default_max_turns` | **40** | Lowered from 100; `AddMaxTurnsCeiling` backfilled the seeded row. |
| `MaxTurnsCeiling` | `max_turns_ceiling` | 80 | Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run. `UpdateAsync` clamps to min 1. |
| `ModelPresets` | `model_presets` | seeded | JSON array of `ModelPreset` rows. ⚠️ `AppSettingsRepository.GetAsync` **backfills shipping defaults on the first read after it's null**, so it's never null once a run has started. |
| `UsageGateFiveHourPct` / `UsageGateSevenDayPct` | `usage_gate_*_pct` | 80 / 90 | Queue pause thresholds; `0` = off. |
| `UsageThrottle{FiveHour,SevenDay}{Soft,Hard}Pct` | `usage_throttle_{five_hour,seven_day}_{soft,hard}_pct` | 50 / 65 per bucket | Staged parallelism below the hard gate, **per bucket**; `0` = that stage off. Edited by dragging the usage-monitor gauges. |
| `DailyPrepMaxTasks` | `daily_prep_max_tasks` | 5 | Hard cap on MyDay tasks the daily prep may place. |
| `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. |
| `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. |
| `NextTaskNumber` | `next_task_number` | 1 | Persistent counter for `TaskEntity.Number` allocation. Incremented atomically in `TaskNumberAllocator.AddWithNumberAsync` on every task insert. **Monotonic only:** the counter itself advances across all tasks (no per-list numbering), and it never resets. A deleted task leaves a gap. |
All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`.
Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
### Model / effort registries
- **ModelPresets / ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`), one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1200) and **never throw** — a malformed settings row must not stop a run. `For(presets, model, fallbackMaxTurns)` always returns a usable row; resolution order and the fallback trap → [worker-task-pipeline](../../docs/explore-notes/worker-task-pipeline.md).
- **ModelRegistry**`NormalizeAlias` is the strict, **throwing** validator for `add_task`/planning model input. `TryNormalizeAlias` is the non-throwing counterpart for the run path (exact alias match, then substring match against a full model id, else `null`). `ByCostAscending` = the cost order the prompts use.
- **EffortRegistry** — the `--effort` levels (`low|medium|high|xhigh|max`) + `NormalizeLevel` (blank → null = don't pass the flag).
## Repositories
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Queued -> Running` claim lives in the Worker's `QueuePicker` (uses `FromSqlRaw`), not here.
All use EF Core LINQ via `ClaudeDoDbContext`. The atomic `Queued Running` claim lives in the
Worker's `QueuePicker` (`FromSqlRaw`), **not** here.
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
- **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
- **DailyNoteRepository**`ListByDayAsync`, `ListBetweenAsync`, `AddAsync`, `UpdateAsync`, `DeleteAsync`
- **WeekReportRepository**`GetByRangeAsync`, `UpsertAsync`
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync`. ⚠️ Status-mutation primitives (`MarkRunningAsync`/`MarkDoneAsync`/`MarkFailedAsync`/`FlipAllRunningToFailedAsync`) are **`internal`** — only the Worker's `TaskStateService` may call them. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`. **Task-number allocation:** `AddAsync` (line 20) and `CreateChildAsync` (line 276) are the two sole task-creation paths; both call `TaskNumberAllocator.AddWithNumberAsync` to allocate a number atomically during the same transaction as the insert. Every other creation path (UI, MCP `add_task`, `batch_add_tasks`, Online Inbox sync, merge-helper handler tasks, planning children) routes through one of these two.
- **ListRepository** — CRUD, `GetConfigAsync`/`SetConfigAsync` (upsert)/`DeleteConfigAsync` for `list_config`
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged **and** stamps MergeCommit in one update — the only writer of MergeCommit)
- **TaskAttachmentRepository**`AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`, `DeleteAllForTaskAsync`
- **DailyNoteRepository**, **WeekReportRepository**, **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
`TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment
dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
## Infrastructure
- **ClaudeDoDbContext** — EF Core DbContext; configured with WAL mode and foreign keys via `UseSqlite` options
- **IDbContextFactory<ClaudeDoDbContext>** — registered in DI; used by singleton consumers (e.g. Worker hosted service)
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
- **ClaudeDoDbContext** — EF Core DbContext; WAL mode + foreign keys via `UseSqlite` options
- **IDbContextFactory\<ClaudeDoDbContext\>** — registered in DI; used by singleton consumers (e.g. the Worker hosted service)
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root `~/.todo-app`
- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
- **AttachmentStore** — dependency-free file store; default root `~/.todo-app/attachments/<taskId>/`. `SaveAsync` enforces a 5 MB cap and path-traversal/containment guard. Also exposes `DeleteFile`, `DeleteTaskDir`, `TaskDir`, `Root`, and `EnumerateTaskIds` (used by the worker orphan sweep). Attachment files live outside git worktrees intentionally.
- **AttachmentStore** — dependency-free file store, default root `~/.todo-app/attachments/<taskId>/`. `SaveAsync` enforces a 5 MB cap and a path-traversal/containment guard. Also `DeleteFile`, `DeleteTaskDir`, `TaskDir`, `Root`, `EnumerateTaskIds` (used by the worker orphan sweep). Attachment files live **outside** git worktrees intentionally.
## Git
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Worktree ops (add — serialized to avoid a commondir race —, remove, prune, list paths for branch), branch ops (current, list local, checkout, delete), staging/commit (status porcelain, add-all, add-path, commit via stdin), diffs (working tree, branch vs base, commit range `base..head` — used to show a merged task's diff after the worktree is gone —, per-file, diff-stat, committed files, has-changes), merge (ff-only, no-ff, abort, mid-merge detection, conflicted files), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo
**GitService** — async wrapper around the git CLI (`ProcessStartInfo`, no shell):
- Worktrees: add (**serialized** to avoid a commondir race), remove, prune, list paths for branch
- Branches: current, list local, checkout, delete
- Staging/commit: status porcelain, add-all, add-path, commit via stdin
- Diffs: working tree, branch vs base, commit range `base..head` (shows a merged task's diff after the worktree is gone), per-file, diff-stat, committed files, has-changes
- Merge: ff-only, no-ff, abort, mid-merge detection (`MERGE_HEAD`), conflicted files
- Revert: `RevertMergeCommitAsync` (`git revert --no-edit -m 1 <sha>`), `RevertAbortAsync`, `IsMidRevertAsync` (`REVERT_HEAD`, mirrors `IsMidMergeAsync`)
- `PreviewMergeAsync` (non-destructive check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo
⚠️ **Revert never resets or rewrites** — it always produces a new commit, because the working
directory it operates on is shared with other concurrent sessions.
## Schema
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
Tables (one per line so parallel migrations don't collide on the same line):
- `lists`
- `tasks`
- `worktrees`
- `list_config`
- `task_runs`
- `subtasks`
- `app_settings`
- `prime_schedules`
- `daily_notes`
- `week_reports`
- `task_attachments`
Managed by EF Core migrations in `Migrations/`**`ls Migrations/` is the authoritative history**;
don't maintain a changelog here. `tasks` holds `status`, `planning_phase` (default `none`),
`blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`), and `depends_on_task_id` (same FK
shape, but a separate column — see Worker/CLAUDE.md → Status Model for why it isn't unified with
`blocked_by_task_id`).
## Conventions
- Enum <-> string mapping via EF Core `ValueConverter` (configured in `IEntityTypeConfiguration<T>`)
- Entity configurations live in the `Configuration/` folder
- Enum string mapping via EF Core `ValueConverter`, configured in `IEntityTypeConfiguration<T>`
- Entity configurations live in `Configuration/`
- Primary keys are `init`-only strings (GUIDs assigned at creation)
- All methods are async with CancellationToken where applicable
+1
View File
@@ -17,6 +17,7 @@
<ItemGroup>
<InternalsVisibleTo Include="ClaudeDo.Worker" />
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
<InternalsVisibleTo Include="ClaudeDo.Data.Tests" />
</ItemGroup>
</Project>
@@ -13,15 +13,21 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever();
builder.Property(s => s.NextTaskNumber)
.HasColumnName("next_task_number").IsRequired().HasDefaultValue(1);
builder.Property(s => s.DefaultClaudeInstructions)
.HasColumnName("default_claude_instructions").IsRequired().HasDefaultValue(string.Empty);
builder.Property(s => s.DefaultModel)
.HasColumnName("default_model").IsRequired().HasDefaultValue("sonnet");
builder.Property(s => s.DefaultMaxTurns)
.HasColumnName("default_max_turns").IsRequired().HasDefaultValue(30);
.HasColumnName("default_max_turns").IsRequired().HasDefaultValue(40);
builder.Property(s => s.DefaultPermissionMode)
.HasColumnName("default_permission_mode").IsRequired().HasDefaultValue("bypassPermissions");
builder.Property(s => s.MaxTurnsCeiling)
.HasColumnName("max_turns_ceiling").IsRequired().HasDefaultValue(80);
builder.Property(s => s.MaxParallelExecutions)
.HasColumnName("max_parallel_executions").IsRequired().HasDefaultValue(1);
@@ -47,6 +53,20 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.SessionSkills).HasColumnName("session_skills");
builder.Property(s => s.ModelPresets).HasColumnName("model_presets");
builder.Property(s => s.UsageGateFiveHourPct)
.HasColumnName("usage_gate_five_hour_pct").IsRequired().HasDefaultValue(80);
builder.Property(s => s.UsageGateSevenDayPct)
.HasColumnName("usage_gate_seven_day_pct").IsRequired().HasDefaultValue(90);
builder.Property(s => s.UsageThrottleFiveHourSoftPct)
.HasColumnName("usage_throttle_five_hour_soft_pct").IsRequired().HasDefaultValue(50);
builder.Property(s => s.UsageThrottleFiveHourHardPct)
.HasColumnName("usage_throttle_five_hour_hard_pct").IsRequired().HasDefaultValue(65);
builder.Property(s => s.UsageThrottleSevenDaySoftPct)
.HasColumnName("usage_throttle_seven_day_soft_pct").IsRequired().HasDefaultValue(50);
builder.Property(s => s.UsageThrottleSevenDayHardPct)
.HasColumnName("usage_throttle_seven_day_hard_pct").IsRequired().HasDefaultValue(65);
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
}
}
@@ -17,5 +17,8 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
builder.Property(c => c.AgentPath).HasColumnName("agent_path");
builder.Property(c => c.MaxTurns).HasColumnName("max_turns");
builder.Property(c => c.SessionSkills).HasColumnName("session_skills");
builder.Property(c => c.VerifyCommand).HasColumnName("verify_command");
builder.Property(c => c.SerializeOnFileOverlap).HasColumnName("serialize_on_file_overlap")
.IsRequired().HasDefaultValue(false);
}
}
@@ -18,6 +18,7 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked").IsRequired().HasDefaultValue(false);
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
@@ -66,6 +66,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).HasColumnName("id");
builder.Property(t => t.Number).HasColumnName("number").IsRequired();
builder.Property(t => t.ListId).HasColumnName("list_id").IsRequired();
builder.Property(t => t.Title).HasColumnName("title").IsRequired();
builder.Property(t => t.Description).HasColumnName("description");
@@ -74,10 +75,14 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
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.DependsOnTaskId).HasColumnName("depends_on_task_id");
builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for");
builder.Property(t => t.Result).HasColumnName("result");
builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback");
builder.Property(t => t.RoadblockCount).HasColumnName("roadblock_count").HasDefaultValue(0);
builder.Property(t => t.FailureReason).HasColumnName("failure_reason");
builder.Property(t => t.FailureTurnsUsed).HasColumnName("failure_turns_used");
builder.Property(t => t.FailureMaxTurns).HasColumnName("failure_max_turns");
builder.Property(t => t.LogPath).HasColumnName("log_path");
builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(t => t.StartedAt).HasColumnName("started_at");
@@ -93,6 +98,10 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.Notes).HasColumnName("notes");
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(t => t.SessionSkills).HasColumnName("session_skills");
builder.Property(t => t.ScopeGlobs).HasColumnName("scope_globs");
builder.Property(t => t.HandlerBaseCommit).HasColumnName("handler_base_commit");
builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");
builder.Property(t => t.InteractiveSessionId).HasColumnName("interactive_session_id");
builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id");
builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id");
@@ -112,6 +121,13 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
.HasForeignKey(t => t.BlockedByTaskId)
.OnDelete(DeleteBehavior.SetNull);
// DependsOn: user/MCP-declared predecessor. SetNull on delete so the dependent becomes
// pickable rather than blocked forever on a task that no longer exists.
builder.HasOne<TaskEntity>()
.WithMany()
.HasForeignKey(t => t.DependsOnTaskId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(t => t.List)
.WithMany(l => l.Tasks)
.HasForeignKey(t => t.ListId)
@@ -121,10 +137,12 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
.WithOne(w => w.Task)
.HasForeignKey<WorktreeEntity>(w => w.TaskId);
builder.HasIndex(t => t.Number).IsUnique().HasDatabaseName("idx_tasks_number");
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
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 => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");
builder.HasIndex(t => t.DependsOnTaskId).HasDatabaseName("idx_tasks_depends_on");
}
}
@@ -24,9 +24,15 @@ public class TaskRunEntityConfiguration : IEntityTypeConfiguration<TaskRunEntity
builder.Property(r => r.TurnCount).HasColumnName("turn_count");
builder.Property(r => r.TokensIn).HasColumnName("tokens_in");
builder.Property(r => r.TokensOut).HasColumnName("tokens_out");
builder.Property(r => r.CacheReadTokens).HasColumnName("cache_read_tokens");
builder.Property(r => r.CacheWriteTokens).HasColumnName("cache_write_tokens");
builder.Property(r => r.LogPath).HasColumnName("log_path");
builder.Property(r => r.StartedAt).HasColumnName("started_at");
builder.Property(r => r.FinishedAt).HasColumnName("finished_at");
builder.Property(r => r.Model).HasColumnName("model");
builder.Property(r => r.ResultSubtype).HasColumnName("result_subtype");
builder.Property(r => r.TerminalReason).HasColumnName("terminal_reason");
builder.Property(r => r.Errors).HasColumnName("errors");
builder.HasOne(r => r.Task)
.WithMany(t => t.Runs)
@@ -35,6 +35,7 @@ public class WorktreeEntityConfiguration : IEntityTypeConfiguration<WorktreeEnti
builder.Property(w => w.BaseCommit).HasColumnName("base_commit").IsRequired();
builder.Property(w => w.HeadCommit).HasColumnName("head_commit");
builder.Property(w => w.DiffStat).HasColumnName("diff_stat");
builder.Property(w => w.MergeCommit).HasColumnName("merge_commit");
builder.Property(w => w.State).HasColumnName("state").IsRequired()
.HasDefaultValue(WorktreeState.Active)
.HasConversion(StateConverter);
@@ -0,0 +1,116 @@
using SysEnvironment = System.Environment;
namespace ClaudeDo.Data.Environment;
public sealed record ResolvedExecutable(string Path, bool IsShim);
public sealed record ShimStartInfo(string FileName, string Arguments);
/// <summary>
/// Resolves a command the way Windows' CreateProcess/PATH search does, but also finds
/// non-.exe shims (.cmd/.bat/.ps1) that UseShellExecute=false alone would miss.
/// </summary>
public static class ExecutableResolver
{
private const string DefaultPathExt = ".COM;.EXE;.BAT;.CMD";
// Known npm/claude install locations to try when PATH search comes up empty.
private static readonly string[] FallbackDirectoryTemplates =
{
"%APPDATA%\\npm",
"%LOCALAPPDATA%\\Programs\\claude",
"%USERPROFILE%\\.local\\bin",
};
public static ResolvedExecutable? Resolve(string command, string? pathOverride = null, string? pathExtOverride = null)
{
var pathExts = ParsePathExt(pathExtOverride);
if (LooksLikePath(command))
{
return ResolveAsPath(command, pathExts);
}
var directories = ParsePath(pathOverride);
foreach (var dir in directories)
{
var resolved = ResolveInDirectory(dir, command, pathExts);
if (resolved is not null) return resolved;
}
foreach (var template in FallbackDirectoryTemplates)
{
var dir = SysEnvironment.ExpandEnvironmentVariables(template);
var resolved = ResolveInDirectory(dir, command, pathExts);
if (resolved is not null) return resolved;
}
return null;
}
/// <summary>Expanded fallback directories tried when PATH search comes up empty (for diagnostics).</summary>
public static IReadOnlyList<string> FallbackDirectories() =>
FallbackDirectoryTemplates.Select(SysEnvironment.ExpandEnvironmentVariables).ToList();
public static ShimStartInfo BuildShimStartInfo(string shimPath, IReadOnlyList<string> arguments)
{
var parts = new List<string> { "/c", Quote(shimPath) };
parts.AddRange(arguments.Select(Quote));
return new ShimStartInfo("cmd.exe", string.Join(' ', parts));
}
private static bool LooksLikePath(string command) =>
command.Contains(Path.DirectorySeparatorChar) || command.Contains(Path.AltDirectorySeparatorChar);
private static ResolvedExecutable? ResolveAsPath(string command, IReadOnlyList<string> pathExts)
{
if (File.Exists(command)) return new ResolvedExecutable(command, IsShimExtension(Path.GetExtension(command)));
if (Path.HasExtension(command)) return null;
foreach (var ext in pathExts)
{
var candidate = command + ext;
if (File.Exists(candidate)) return new ResolvedExecutable(candidate, IsShimExtension(ext));
}
return null;
}
private static ResolvedExecutable? ResolveInDirectory(string directory, string command, IReadOnlyList<string> pathExts)
{
if (!Directory.Exists(directory)) return null;
if (Path.HasExtension(command))
{
var candidate = Path.Combine(directory, command);
return File.Exists(candidate) ? new ResolvedExecutable(candidate, IsShimExtension(Path.GetExtension(candidate))) : null;
}
foreach (var ext in pathExts)
{
var candidate = Path.Combine(directory, command + ext);
if (File.Exists(candidate)) return new ResolvedExecutable(candidate, IsShimExtension(ext));
}
return null;
}
private static bool IsShimExtension(string extension) =>
!extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)
&& !extension.Equals(".com", StringComparison.OrdinalIgnoreCase);
private static IReadOnlyList<string> ParsePathExt(string? pathExtOverride)
{
var raw = pathExtOverride ?? SysEnvironment.GetEnvironmentVariable("PATHEXT") ?? DefaultPathExt;
return raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static IReadOnlyList<string> ParsePath(string? pathOverride)
{
var raw = pathOverride ?? SysEnvironment.GetEnvironmentVariable("PATH") ?? "";
return raw.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
}
@@ -1,13 +1,12 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class ReviewFilter : ITaskListFilter
public sealed class ReviewFilter : TaskListFilterBase
{
public string Id => "virtual:review";
public bool Matches(TaskEntity t) =>
t.Status == TaskStatus.WaitingForReview;
public bool ShouldCount(TaskEntity t) => Matches(t);
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
public override string Id => "virtual:review";
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.Status == TaskStatus.WaitingForReview;
public override bool ShouldCount(TaskEntity t) => Matches(t);
}
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -7,10 +8,11 @@ namespace ClaudeDo.Data.Filtering.Filters;
/// 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 sealed class SmartFlagFilter(string id, Expression<Func<TaskEntity, bool>> flag) : TaskListFilterBase
{
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;
private readonly Func<TaskEntity, bool> _flag = flag.Compile();
public override string Id => id;
protected override Expression<Func<TaskEntity, bool>> MatchExpr => flag;
public override bool ShouldCount(TaskEntity t) => _flag(t) && t.Status != TaskStatus.Done;
}
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -7,12 +8,12 @@ namespace ClaudeDo.Data.Filtering.Filters;
/// 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 sealed class StatusFilter(string id, TaskStatus status) : TaskListFilterBase
{
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) =>
public override string Id => id;
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.Status == status;
public override bool ShouldCount(TaskEntity t) => t.Status == status;
public override bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
}
@@ -0,0 +1,27 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// Base for <see cref="ITaskListFilter"/> implementations: subclasses express their
/// primary-match condition once as an expression tree (<see cref="MatchExpr"/>), which
/// doubles as a SQL-translatable predicate (<see cref="MatchExpression"/>) and, compiled
/// on first use, as the in-memory <see cref="Matches"/> predicate.
/// </summary>
public abstract class TaskListFilterBase : ITaskListFilter
{
private Func<TaskEntity, bool>? _compiled;
public abstract string Id { get; }
protected abstract Expression<Func<TaskEntity, bool>> MatchExpr { get; }
public Expression<Func<TaskEntity, bool>> MatchExpression => MatchExpr;
public bool Matches(TaskEntity t) => (_compiled ??= MatchExpr.Compile())(t);
public abstract bool ShouldCount(TaskEntity t);
public virtual bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -7,7 +8,7 @@ namespace ClaudeDo.Data.Filtering.Filters;
/// Filter for any user-defined list. Constructed on demand from the list id —
/// one instance per list.
/// </summary>
public sealed class UserListFilter : ITaskListFilter
public sealed class UserListFilter : TaskListFilterBase
{
private readonly string _listId;
@@ -17,8 +18,7 @@ public sealed class UserListFilter : ITaskListFilter
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;
public override string Id { get; }
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.ListId == _listId;
public override bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done;
}
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Filtering;
@@ -15,6 +16,9 @@ public interface ITaskListFilter
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
bool Matches(TaskEntity t);
/// <summary>The primary-match predicate as an expression tree, so EF Core can push it into SQL.</summary>
Expression<Func<TaskEntity, bool>> MatchExpression { get; }
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
bool ShouldCount(TaskEntity t);
+12 -6
View File
@@ -25,10 +25,13 @@ public sealed record MergeSegment
/// <summary>"Theirs" side (the incoming branch) when <see cref="IsConflict"/> is true.</summary>
public string Theirs { get; init; } = "";
public static MergeSegment Stable(string text) => new() { Text = text };
/// <summary>1-based line number in the file where this segment starts (the marker line for a conflict).</summary>
public int StartLine { get; init; } = 1;
public static MergeSegment Conflict(string ours, string? @base, string theirs) =>
new() { IsConflict = true, Ours = ours, Base = @base, Theirs = theirs };
public static MergeSegment Stable(string text, int startLine = 1) => new() { Text = text, StartLine = startLine };
public static MergeSegment Conflict(string ours, string? @base, string theirs, int startLine = 1) =>
new() { IsConflict = true, Ours = ours, Base = @base, Theirs = theirs, StartLine = startLine };
}
/// <summary>
@@ -49,22 +52,25 @@ public static class ConflictMarkerParser
var segments = new List<MergeSegment>();
var lines = SplitKeepLineEndings(fileText);
var stable = new StringBuilder();
var stableStartLine = 1;
var i = 0;
while (i < lines.Count)
{
if (!IsMarker(lines[i], OursMarker))
{
if (stable.Length == 0) stableStartLine = i + 1;
stable.Append(lines[i++]);
continue;
}
if (stable.Length > 0)
{
segments.Add(MergeSegment.Stable(stable.ToString()));
segments.Add(MergeSegment.Stable(stable.ToString(), stableStartLine));
stable.Clear();
}
var conflictStartLine = i + 1;
i++; // consume "<<<<<<<"
var ours = new StringBuilder();
while (i < lines.Count && !IsMarker(lines[i], BaseMarker) && !IsMarker(lines[i], SepMarker))
@@ -88,11 +94,11 @@ public static class ConflictMarkerParser
if (i < lines.Count && IsMarker(lines[i], TheirsMarker)) i++; // consume ">>>>>>>"
segments.Add(MergeSegment.Conflict(ours.ToString(), @base, theirs.ToString()));
segments.Add(MergeSegment.Conflict(ours.ToString(), @base, theirs.ToString(), conflictStartLine));
}
if (stable.Length > 0)
segments.Add(MergeSegment.Stable(stable.ToString()));
segments.Add(MergeSegment.Stable(stable.ToString(), stableStartLine));
return segments;
}
+197 -18
View File
@@ -3,7 +3,10 @@ using System.Text;
namespace ClaudeDo.Data.Git;
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
// TreeOid is only populated when Clean — the tree `merge-tree --write-tree` would produce,
// usable to materialize the merge result into a scratch worktree without touching the real
// working tree, index, or refs (see GitService.CommitTreeAsync / WorktreeAddDetachedAsync).
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles, string? TreeOid = null);
public sealed class GitService
{
@@ -26,6 +29,32 @@ public sealed class GitService
return stdout.Trim();
}
/// <summary>
/// True if <paramref name="ancestorSha"/> is an ancestor of (or equal to) <paramref name="descendantSha"/>,
/// via `git merge-base --is-ancestor`. Null means the answer can't be determined (e.g. the commit is
/// unknown in this repo) — callers must treat that as "unknown", never as "not an ancestor".
/// </summary>
public async Task<bool?> IsAncestorAsync(string repoDir, string ancestorSha, string descendantSha, CancellationToken ct = default)
{
var (exitCode, _, _) = await RunGitAsync(repoDir, ["merge-base", "--is-ancestor", ancestorSha, descendantSha], ct);
return exitCode switch
{
0 => true,
1 => false,
_ => null,
};
}
/// <summary>
/// The merge base of two refs, or null when git can't find one (e.g. an unresolvable ref) —
/// callers treat that as "can't evaluate", not "no common history".
/// </summary>
public async Task<string?> MergeBaseAsync(string repoDir, string refA, string refB, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["merge-base", refA, refB], ct);
return exitCode == 0 ? stdout.Trim() : null;
}
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
{
await WorktreeAddGate.WaitAsync(ct);
@@ -54,9 +83,12 @@ public sealed class GitService
}
}
// --untracked-files=all: without it, a brand-new untracked directory collapses into a single
// "?? dir/" entry instead of listing the files inside it — callers matching against specific
// paths (e.g. the untracked-collision guard) need the individual files.
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain"], ct);
var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain", "--untracked-files=all"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
return stdout;
@@ -107,16 +139,20 @@ public sealed class GitService
throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}");
}
public async Task<string> GetDiffAsync(string worktreePath, CancellationToken ct = default)
public async Task<string> GetDiffAsync(
string worktreePath, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
["diff", "HEAD"], ct);
var args = new List<string> { "diff", "HEAD" };
AppendPathFilter(args, paths);
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, ct);
if (exitCode != 0)
throw new InvalidOperationException($"git diff HEAD failed (exit {exitCode}): {stderr}");
// If nothing staged vs HEAD, try the index (untracked is never in diff)
if (string.IsNullOrWhiteSpace(stdout))
{
var (e2, s2, _) = await RunGitAsync(worktreePath, ["diff", "--cached"], ct);
var cachedArgs = new List<string> { "diff", "--cached" };
AppendPathFilter(cachedArgs, paths);
var (e2, s2, _) = await RunGitAsync(worktreePath, cachedArgs, ct);
if (e2 == 0) return s2;
}
return stdout;
@@ -127,14 +163,19 @@ public sealed class GitService
/// (committed-on-branch changes + uncommitted work). Used for viewing a Claude
/// task's total impact relative to where the branch started.
/// </summary>
public async Task<string> GetBranchDiffAsync(string worktreePath, string baseRef, CancellationToken ct = default)
public async Task<string> GetBranchDiffAsync(
string worktreePath, string baseRef, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(worktreePath,
["diff", baseRef], ct);
var args = new List<string> { "diff", baseRef };
AppendPathFilter(args, paths);
var (exitCode, stdout, _) = await RunGitAsync(worktreePath, args, ct);
if (exitCode == 0 && !string.IsNullOrWhiteSpace(stdout))
return stdout;
// Fallback: whatever the worktree has vs HEAD (uncommitted only).
return await GetDiffAsync(worktreePath, ct);
// Fallback: whatever the worktree has vs HEAD (uncommitted only). The same pathspec has to
// come along — a filter makes an empty ranged diff the common case (the caller asked for
// paths this range never touched), and an unfiltered fallback would answer that with the
// whole worktree diff, the exact opposite of what was requested.
return await GetDiffAsync(worktreePath, paths, ct);
}
/// <summary>
@@ -142,24 +183,38 @@ public sealed class GitService
/// task's changes after its worktree has been merged away (the commits survive on
/// the target branch even though the worktree directory and branch ref are gone).
/// </summary>
public async Task<string> GetCommitRangeDiffAsync(string repoDir, string baseCommit, string headCommit, CancellationToken ct = default)
public async Task<string> GetCommitRangeDiffAsync(
string repoDir, string baseCommit, string headCommit, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
["diff", $"{baseCommit}..{headCommit}"], ct);
var args = new List<string> { "diff", $"{baseCommit}..{headCommit}" };
AppendPathFilter(args, paths);
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, args, ct);
if (exitCode != 0)
throw new InvalidOperationException($"git diff {baseCommit}..{headCommit} failed (exit {exitCode}): {stderr}");
return stdout;
}
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
public async Task<string> DiffStatAsync(
string worktreePath, string baseCommit, string headCommit, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
["diff", "--stat", $"{baseCommit}..{headCommit}"], ct);
var args = new List<string> { "diff", "--stat", $"{baseCommit}..{headCommit}" };
AppendPathFilter(args, paths);
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, ct);
if (exitCode != 0)
throw new InvalidOperationException($"git diff --stat failed (exit {exitCode}): {stderr}");
return stdout.Trim();
}
// Appends a `-- <paths>` pathspec filter so git itself narrows the diff instead of the
// caller filtering the result after the fact (works identically for --stat and full diffs).
// No-op when paths is null/empty so existing callers see no behavior change.
private static void AppendPathFilter(List<string> args, IReadOnlyList<string>? paths)
{
if (paths is not { Count: > 0 }) return;
args.Add("--");
args.AddRange(paths);
}
public async Task<string> GetFileDiffAsync(string worktreePath, string? baseCommit, string relativePath, CancellationToken ct = default)
{
string[] args = string.IsNullOrEmpty(baseCommit)
@@ -280,6 +335,36 @@ public sealed class GitService
throw new InvalidOperationException($"git merge --abort failed (exit {exitCode}): {stderr}");
}
public async Task<bool> IsMidRevertAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
if (exitCode != 0) return false;
var gitDir = stdout.Trim();
if (!Path.IsPathRooted(gitDir))
gitDir = Path.Combine(repoDir, gitDir);
return File.Exists(Path.Combine(gitDir, "REVERT_HEAD"));
}
/// <summary>
/// Reverts a single commit with `-m 1` (diff against its first parent) — the form needed to
/// revert a merge commit. On success this creates a new commit with the inverse changes;
/// the original commit and all history stay intact (no rewrite, no reset).
/// </summary>
public async Task<(int ExitCode, string Stderr)> RevertMergeCommitAsync(
string repoDir, string mergeCommitSha, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["-c", "merge.conflictStyle=diff3", "revert", "--no-edit", "-m", "1", mergeCommitSha], ct);
return (exitCode, stderr);
}
public async Task RevertAbortAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["revert", "--abort"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git revert --abort failed (exit {exitCode}): {stderr}");
}
public async Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
@@ -311,7 +396,8 @@ public sealed class GitService
["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct);
if (exitCode == 0)
return new MergePreview(true, true, Array.Empty<string>());
// stdout is just the written tree's oid on a clean merge.
return new MergePreview(true, true, Array.Empty<string>(), stdout.Trim());
if (exitCode == 1)
{
@@ -331,6 +417,62 @@ public sealed class GitService
return new MergePreview(false, false, Array.Empty<string>());
}
/// <summary>Resolves <paramref name="revision"/> (a branch, tag, or SHA) to a full commit SHA.</summary>
public async Task<string> RevParseAsync(string repoDir, string revision, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, ["rev-parse", revision], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git rev-parse '{revision}' failed (exit {exitCode}): {stderr}");
return stdout.Trim();
}
/// <summary>
/// Creates a new commit object wrapping <paramref name="treeOid"/> with <paramref name="parentSha"/>
/// as its single parent, writing only a loose object — no ref is created or moved.
/// </summary>
public async Task<string> CommitTreeAsync(
string repoDir, string treeOid, string parentSha, string message, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
["commit-tree", treeOid, "-p", parentSha, "-m", message], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git commit-tree failed (exit {exitCode}): {stderr}");
return stdout.Trim();
}
/// <summary>
/// Materializes <paramref name="commitish"/> into a new, branchless worktree at
/// <paramref name="worktreePath"/> (detached HEAD) — used to build/verify a merge-tree
/// result without ever creating a branch or touching the real working tree.
/// </summary>
public async Task WorktreeAddDetachedAsync(
string repoDir, string worktreePath, string commitish, CancellationToken ct = default)
{
await WorktreeAddGate.WaitAsync(ct);
try
{
const int maxAttempts = 3;
for (var attempt = 1; ; attempt++)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["worktree", "add", "--detach", worktreePath, commitish], ct);
if (exitCode == 0)
return;
var transient = stderr.Contains("commondir", StringComparison.OrdinalIgnoreCase)
|| stderr.Contains("failed to read", StringComparison.OrdinalIgnoreCase);
if (!transient || attempt >= maxAttempts)
throw new InvalidOperationException($"git worktree add --detach failed (exit {exitCode}): {stderr}");
await Task.Delay(150 * attempt, ct);
}
}
finally
{
WorktreeAddGate.Release();
}
}
/// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
public async Task<int> CountChangedFilesAsync(
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
@@ -343,6 +485,43 @@ public sealed class GitService
.Count(s => s.Length > 0);
}
/// <summary>Files that differ between two exact refs (2-dot, no merge-base resolution) -- used to see
/// what a target branch itself picked up since a task's fork point, as opposed to
/// <see cref="CountChangedFilesAsync"/>'s 3-dot count of a branch's own changes.</summary>
public async Task<IReadOnlyList<string>> GetChangedFileNamesAsync(
string repoDir, string fromRef, string toRef, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
["diff", "--name-only", $"{fromRef}..{toRef}"], ct);
if (exitCode != 0) return Array.Empty<string>();
return stdout
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(s => s.Length > 0)
.ToList();
}
/// <summary>
/// Rebases the branch checked out at <paramref name="worktreePath"/> onto <paramref name="ontoRef"/>.
/// On conflict or any other failure the rebase is aborted before returning, so the worktree is left
/// exactly as it was rather than stranded mid-rebase; <c>ConflictFiles</c> is best-effort and only
/// populated when the failure was an actual conflict.
/// </summary>
public async Task<(int ExitCode, string Stderr, IReadOnlyList<string> ConflictFiles)> RebaseAsync(
string worktreePath, string ontoRef, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["rebase", ontoRef], ct);
if (exitCode == 0)
return (0, stderr, Array.Empty<string>());
List<string> conflictFiles;
try { conflictFiles = await ListConflictedFilesAsync(worktreePath, ct); }
catch { conflictFiles = new(); }
await RunGitAsync(worktreePath, ["rebase", "--abort"], ct);
return (exitCode, stderr, conflictFiles);
}
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
{
@@ -0,0 +1,810 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260805065217_AddHandlerCommitRange")]
partial class AddHandlerCommitRange
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddHandlerCommitRange : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "handler_base_commit",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "handler_head_commit",
table: "tasks",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "handler_base_commit",
table: "tasks");
migrationBuilder.DropColumn(
name: "handler_head_commit",
table: "tasks");
}
}
}
@@ -0,0 +1,828 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260805074906_AddUsageGateAndRunModel")]
partial class AddUsageGateAndRunModel
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddUsageGateAndRunModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "model",
table: "task_runs",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "usage_gate_five_hour_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 80);
migrationBuilder.AddColumn<int>(
name: "usage_gate_seven_day_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 90);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
columns: new[] { "usage_gate_five_hour_pct", "usage_gate_seven_day_pct" },
values: new object[] { 80, 90 });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "model",
table: "task_runs");
migrationBuilder.DropColumn(
name: "usage_gate_five_hour_pct",
table: "app_settings");
migrationBuilder.DropColumn(
name: "usage_gate_seven_day_pct",
table: "app_settings");
}
}
}
@@ -0,0 +1,832 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260805090016_AddVerifyCommand")]
partial class AddVerifyCommand
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddVerifyCommand : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "verify_command",
table: "list_config",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "verify_command",
table: "list_config");
}
}
}
@@ -0,0 +1,832 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260805091910_AddWorktreeMergeCommit")]
partial class AddWorktreeMergeCommit
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddWorktreeMergeCommit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "merge_commit",
table: "worktrees",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "merge_commit",
table: "worktrees");
}
}
}
@@ -0,0 +1,843 @@
// <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("20260805132052_AddMaxTurnsCeiling")]
partial class AddMaxTurnsCeiling
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.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<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddMaxTurnsCeiling : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "default_max_turns",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 40,
oldClrType: typeof(int),
oldType: "INTEGER",
oldDefaultValue: 30);
migrationBuilder.AddColumn<int>(
name: "max_turns_ceiling",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 80);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
columns: new[] { "default_max_turns", "max_turns_ceiling" },
values: new object[] { 40, 80 });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "max_turns_ceiling",
table: "app_settings");
migrationBuilder.AlterColumn<int>(
name: "default_max_turns",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 30,
oldClrType: typeof(int),
oldType: "INTEGER",
oldDefaultValue: 40);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
column: "default_max_turns",
value: 100);
}
}
}
@@ -0,0 +1,844 @@
// <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("20260805132801_AddRunCacheTokens")]
partial class AddRunCacheTokens
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddRunCacheTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "cache_read_tokens",
table: "task_runs",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "cache_write_tokens",
table: "task_runs",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "cache_read_tokens",
table: "task_runs");
migrationBuilder.DropColumn(
name: "cache_write_tokens",
table: "task_runs");
}
}
}
@@ -0,0 +1,850 @@
// <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("20260805133429_AddUsageThrottleThresholds")]
partial class AddUsageThrottleThresholds
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_hard_pct");
b.Property<int>("UsageThrottleSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleHardPct = 65,
UsageThrottleSoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddUsageThrottleThresholds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "usage_throttle_hard_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 65);
migrationBuilder.AddColumn<int>(
name: "usage_throttle_soft_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 50);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
columns: new[] { "usage_throttle_hard_pct", "usage_throttle_soft_pct" },
values: new object[] { 65, 50 });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "usage_throttle_hard_pct",
table: "app_settings");
migrationBuilder.DropColumn(
name: "usage_throttle_soft_pct",
table: "app_settings");
}
}
}
@@ -0,0 +1,869 @@
// <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("20260806111454_AddInteractiveSessionId")]
partial class AddInteractiveSessionId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.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<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_hard_pct");
b.Property<int>("UsageThrottleSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleHardPct = 65,
UsageThrottleSoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddInteractiveSessionId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "interactive_session_id",
table: "tasks",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "interactive_session_id",
table: "tasks");
}
}
}
@@ -0,0 +1,883 @@
// <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("20260806141710_SplitUsageThrottlePerBucket")]
partial class SplitUsageThrottlePerBucket
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.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<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class SplitUsageThrottlePerBucket : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "usage_throttle_soft_pct",
table: "app_settings",
newName: "usage_throttle_seven_day_soft_pct");
migrationBuilder.RenameColumn(
name: "usage_throttle_hard_pct",
table: "app_settings",
newName: "usage_throttle_seven_day_hard_pct");
migrationBuilder.AddColumn<int>(
name: "usage_throttle_five_hour_hard_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 65);
migrationBuilder.AddColumn<int>(
name: "usage_throttle_five_hour_soft_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 50);
// The old single soft/hard pair was compared against whichever bucket was more utilized,
// so carrying it into BOTH buckets keeps an existing install behaving exactly as before
// the split — the rename above already preserved it for the 7d side.
migrationBuilder.Sql(
"""
UPDATE app_settings
SET usage_throttle_five_hour_soft_pct = usage_throttle_seven_day_soft_pct,
usage_throttle_five_hour_hard_pct = usage_throttle_seven_day_hard_pct;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "usage_throttle_five_hour_hard_pct",
table: "app_settings");
migrationBuilder.DropColumn(
name: "usage_throttle_five_hour_soft_pct",
table: "app_settings");
migrationBuilder.RenameColumn(
name: "usage_throttle_seven_day_soft_pct",
table: "app_settings",
newName: "usage_throttle_soft_pct");
migrationBuilder.RenameColumn(
name: "usage_throttle_seven_day_hard_pct",
table: "app_settings",
newName: "usage_throttle_hard_pct");
}
}
}
@@ -0,0 +1,889 @@
// <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("20260810080113_AddFindingsTracked")]
partial class AddFindingsTracked
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.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<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddFindingsTracked : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "findings_tracked",
table: "lists",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "findings_tracked",
table: "lists");
}
}
}
@@ -0,0 +1,899 @@
// <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("20260810113643_AddScopeOverlapFields")]
partial class AddScopeOverlapFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.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<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<bool>("SerializeOnFileOverlap")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("serialize_on_file_overlap");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("ScopeGlobs")
.HasColumnType("TEXT")
.HasColumnName("scope_globs");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddScopeOverlapFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "scope_globs",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "serialize_on_file_overlap",
table: "list_config",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "scope_globs",
table: "tasks");
migrationBuilder.DropColumn(
name: "serialize_on_file_overlap",
table: "list_config");
}
}
}
@@ -0,0 +1,905 @@
// <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("20260810115110_AddTaskDependency")]
partial class AddTaskDependency
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.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<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("ScopeGlobs")
.HasColumnType("TEXT")
.HasColumnName("scope_globs");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("DependsOnTaskId")
.HasDatabaseName("idx_tasks_depends_on");
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<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("DependsOnTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddTaskDependency : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "depends_on_task_id",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "idx_tasks_depends_on",
table: "tasks",
column: "depends_on_task_id");
migrationBuilder.AddForeignKey(
name: "FK_tasks_tasks_depends_on_task_id",
table: "tasks",
column: "depends_on_task_id",
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_tasks_tasks_depends_on_task_id",
table: "tasks");
migrationBuilder.DropIndex(
name: "idx_tasks_depends_on",
table: "tasks");
migrationBuilder.DropColumn(
name: "depends_on_task_id",
table: "tasks");
}
}
}
@@ -0,0 +1,913 @@
// <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("20260810115437_AddFailureReason")]
partial class AddFailureReason
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.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<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<int?>("FailureMaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("failure_max_turns");
b.Property<string>("FailureReason")
.HasColumnType("TEXT")
.HasColumnName("failure_reason");
b.Property<int?>("FailureTurnsUsed")
.HasColumnType("INTEGER")
.HasColumnName("failure_turns_used");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<string>("Errors")
.HasColumnType("TEXT")
.HasColumnName("errors");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<string>("ResultSubtype")
.HasColumnType("TEXT")
.HasColumnName("result_subtype");
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<string>("TerminalReason")
.HasColumnType("TEXT")
.HasColumnName("terminal_reason");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddFailureReason : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "failure_max_turns",
table: "tasks",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "failure_reason",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "failure_turns_used",
table: "tasks",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "errors",
table: "task_runs",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "result_subtype",
table: "task_runs",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "terminal_reason",
table: "task_runs",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "failure_max_turns",
table: "tasks");
migrationBuilder.DropColumn(
name: "failure_reason",
table: "tasks");
migrationBuilder.DropColumn(
name: "failure_turns_used",
table: "tasks");
migrationBuilder.DropColumn(
name: "errors",
table: "task_runs");
migrationBuilder.DropColumn(
name: "result_subtype",
table: "task_runs");
migrationBuilder.DropColumn(
name: "terminal_reason",
table: "task_runs");
}
}
}
@@ -0,0 +1,950 @@
// <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("20260811081530_AddTaskNumbers")]
partial class AddTaskNumbers
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.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<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<int>("NextTaskNumber")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("next_task_number");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
NextTaskNumber = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<bool>("SerializeOnFileOverlap")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("serialize_on_file_overlap");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<int?>("FailureMaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("failure_max_turns");
b.Property<string>("FailureReason")
.HasColumnType("TEXT")
.HasColumnName("failure_reason");
b.Property<int?>("FailureTurnsUsed")
.HasColumnType("INTEGER")
.HasColumnName("failure_turns_used");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<int>("Number")
.HasColumnType("INTEGER")
.HasColumnName("number");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("ScopeGlobs")
.HasColumnType("TEXT")
.HasColumnName("scope_globs");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("DependsOnTaskId")
.HasDatabaseName("idx_tasks_depends_on");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Number")
.IsUnique()
.HasDatabaseName("idx_tasks_number");
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<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<string>("Errors")
.HasColumnType("TEXT")
.HasColumnName("errors");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<string>("ResultSubtype")
.HasColumnType("TEXT")
.HasColumnName("result_subtype");
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<string>("TerminalReason")
.HasColumnType("TEXT")
.HasColumnName("terminal_reason");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("DependsOnTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddTaskNumbers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "number",
table: "tasks",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "next_task_number",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 1);
// Backfill in creation order (tiebreak by id) so pre-existing tasks get a stable,
// gapless 1..N numbering. Must run before the unique index below.
migrationBuilder.Sql("""
UPDATE tasks
SET number = (
SELECT COUNT(*) FROM tasks t2
WHERE t2.created_at < tasks.created_at
OR (t2.created_at = tasks.created_at AND t2.id <= tasks.id)
);
""");
migrationBuilder.Sql("""
UPDATE app_settings
SET next_task_number = (SELECT COALESCE(MAX(number), 0) + 1 FROM tasks)
WHERE id = 1;
""");
migrationBuilder.CreateIndex(
name: "idx_tasks_number",
table: "tasks",
column: "number",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "idx_tasks_number",
table: "tasks");
migrationBuilder.DropColumn(
name: "number",
table: "tasks");
migrationBuilder.DropColumn(
name: "next_task_number",
table: "app_settings");
}
}
}
@@ -43,7 +43,7 @@ namespace ClaudeDo.Data.Migrations
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasDefaultValue(40)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
@@ -66,10 +66,22 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<int>("NextTaskNumber")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("next_task_number");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
@@ -88,6 +100,42 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@@ -117,11 +165,19 @@ namespace ClaudeDo.Data.Migrations
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
NextTaskNumber = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
@@ -176,6 +232,12 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<bool>("SerializeOnFileOverlap")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("serialize_on_file_overlap");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
@@ -184,6 +246,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
@@ -206,6 +272,12 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@@ -408,14 +480,42 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<int?>("FailureMaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("failure_max_turns");
b.Property<string>("FailureReason")
.HasColumnType("TEXT")
.HasColumnName("failure_reason");
b.Property<int?>("FailureTurnsUsed")
.HasColumnType("INTEGER")
.HasColumnName("failure_turns_used");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@@ -455,6 +555,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<int>("Number")
.HasColumnType("INTEGER")
.HasColumnName("number");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
@@ -496,6 +600,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("ScopeGlobs")
.HasColumnType("TEXT")
.HasColumnName("scope_globs");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
@@ -529,9 +637,16 @@ namespace ClaudeDo.Data.Migrations
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("DependsOnTaskId")
.HasDatabaseName("idx_tasks_depends_on");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Number")
.IsUnique()
.HasDatabaseName("idx_tasks_number");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
@@ -550,10 +665,22 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<string>("Errors")
.HasColumnType("TEXT")
.HasColumnName("errors");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
@@ -572,6 +699,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
@@ -581,6 +712,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<string>("ResultSubtype")
.HasColumnType("TEXT")
.HasColumnName("result_subtype");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
@@ -602,6 +737,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("TerminalReason")
.HasColumnType("TEXT")
.HasColumnName("terminal_reason");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
@@ -681,6 +820,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
@@ -738,6 +881,11 @@ namespace ClaudeDo.Data.Migrations
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("DependsOnTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
+22 -1
View File
@@ -6,11 +6,20 @@ public sealed class AppSettingsEntity
public int Id { get; set; } = SingletonId;
// Persistent counter backing TaskNumberAllocator: the next task number to hand out. Lives on
// the singleton row so allocation is a single atomic UPDATE...RETURNING in the task's own
// insert transaction, never a MAX(number)+1 recompute.
public int NextTaskNumber { get; set; } = 1;
public string DefaultClaudeInstructions { get; set; } = string.Empty;
public string DefaultModel { get; set; } = "sonnet";
public int DefaultMaxTurns { get; set; } = 100;
public int DefaultMaxTurns { get; set; } = 40;
public string DefaultPermissionMode { get; set; } = "auto";
// Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run
// starts. Guards against runaway sessions regardless of what a task/list override requests.
public int MaxTurnsCeiling { get; set; } = 80;
public int MaxParallelExecutions { get; set; } = 1;
public string WorktreeStrategy { get; set; } = "sibling";
@@ -34,4 +43,16 @@ public sealed class AppSettingsEntity
// JSON array of ModelPreset rows (model → effort + max turns). Supplies the global effort and
// turn defaults per model; list-/task-level max-turns overrides still win. Null = ship defaults.
public string? ModelPresets { get; set; }
// Percentage of the 5h/7d Claude usage window at which the autonomous queue pauses. 0 = gate off.
public int UsageGateFiveHourPct { get; set; } = 80;
public int UsageGateSevenDayPct { get; set; } = 90;
// Percentage at which the queue starts throttling parallelism ahead of the hard gate above.
// Tracked per bucket, because the 5h and 7d windows fill at very different rates — soft caps
// parallelism at 2 slots, hard at 1. 0 = that stage off for that bucket.
public int UsageThrottleFiveHourSoftPct { get; set; } = 50;
public int UsageThrottleFiveHourHardPct { get; set; } = 65;
public int UsageThrottleSevenDaySoftPct { get; set; } = 50;
public int UsageThrottleSevenDayHardPct { get; set; } = 65;
}
@@ -8,6 +8,12 @@ public sealed class ListConfigEntity
public string? AgentPath { get; set; }
public int? MaxTurns { get; set; }
public string? SessionSkills { get; set; }
public string? VerifyCommand { get; set; }
// Off by default: tasks in this list run in parallel exactly as before. When on, the queue
// picker holds back a queued task whose declared ScopeGlobs overlaps a running or
// awaiting-merge sibling in the same list instead of claiming it.
public bool SerializeOnFileOverlap { get; set; }
// Navigation property
public ListEntity List { get; set; } = null!;
+3
View File
@@ -11,6 +11,9 @@ public sealed class ListEntity
// A manual list holds reminders rather than Claude work: new tasks created here start out
// manual (see TaskEntity.IsManual).
public bool IsManual { get; set; }
// When true the project's .claudedo/ findings store is committed with the repo; when false it
// is kept out of git via .git/info/exclude (see FindingsStore).
public bool FindingsTracked { get; set; }
// Navigation properties
public ListConfigEntity? Config { get; set; }
+11 -5
View File
@@ -46,20 +46,26 @@ public static class ModelPresets
}
/// <summary>The preset for <paramref name="model"/>, or the default row for it. Never null so
/// callers don't have to special-case an unconfigured or unknown model.</summary>
public static ModelPreset For(IReadOnlyList<ModelPreset> presets, string? model)
/// callers don't have to special-case an unconfigured or unknown model. <paramref name="model"/>
/// is resolved through <see cref="ModelRegistry.TryNormalizeAlias"/> first, so a full CLI model
/// id (e.g. "claude-sonnet-4-6") still hits its alias's preset row instead of falling through.
/// If <paramref name="model"/> normalizes to nothing recognized, <paramref name="fallbackMaxTurns"/>
/// (typically <c>AppSettings.DefaultMaxTurns</c>) is used instead of a hardcoded number, so a
/// truly unknown model is no harsher than the configured global default.</summary>
public static ModelPreset For(IReadOnlyList<ModelPreset> presets, string? model, int fallbackMaxTurns = 30)
{
var alias = (model ?? ModelRegistry.DefaultAlias).Trim();
var alias = ModelRegistry.TryNormalizeAlias(model) ?? (model ?? ModelRegistry.DefaultAlias).Trim();
foreach (var p in presets)
if (string.Equals(p.Model, alias, StringComparison.OrdinalIgnoreCase))
return p;
foreach (var p in Defaults)
if (string.Equals(p.Model, alias, StringComparison.OrdinalIgnoreCase))
return p;
return new ModelPreset(alias, EffortRegistry.DefaultLevel, 30);
return new ModelPreset(alias, EffortRegistry.DefaultLevel, fallbackMaxTurns);
}
public static ModelPreset For(string? json, string? model) => For(Parse(json), model);
public static ModelPreset For(string? json, string? model, int fallbackMaxTurns = 30)
=> For(Parse(json), model, fallbackMaxTurns);
// One row per known alias, canonical order, clamped values — so an edited or partial list
// still round-trips into something every consumer can rely on.
+22 -1
View File
@@ -5,10 +5,13 @@ public static class ModelRegistry
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku", "fable" };
/// <summary>Model aliases ordered cheapest → most capable. Single source for prompt cost guidance.</summary>
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus" };
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus", "fable" };
public const string DefaultAlias = "sonnet";
public const string PlanningAlias = "opus";
public const string HandlerTriageAlias = "opus";
public const string HandlerWaitAlias = "sonnet";
public const string HandlerMergeAlias = "opus";
public const string ListDefaultSentinel = "(default)";
public const string TaskInheritSentinel = "(inherit)";
@@ -26,4 +29,22 @@ public static class ModelRegistry
return alias;
throw new ArgumentException($"Unknown model '{model}'. Allowed: {string.Join(", ", Aliases)}.");
}
/// <summary>
/// Best-effort alias lookup for run-path model resolution, where a full CLI model id
/// (e.g. "claude-sonnet-4-6") is just as likely as a bare alias and a run must never be
/// blocked by an unrecognized one. Null/blank/unrecognized → null; never throws.
/// </summary>
public static string? TryNormalizeAlias(string? model)
{
var m = model?.Trim();
if (string.IsNullOrEmpty(m)) return null;
foreach (var alias in Aliases)
if (string.Equals(alias, m, StringComparison.OrdinalIgnoreCase))
return alias;
foreach (var alias in Aliases)
if (m.Contains(alias, StringComparison.OrdinalIgnoreCase))
return alias;
return null;
}
}
+38
View File
@@ -22,16 +22,33 @@ public enum PlanningPhase
public sealed class TaskEntity
{
public required string Id { get; init; }
// Global, monotonically increasing, never-reused display/lookup handle shown as `#123`.
// Allocated by TaskNumberAllocator from AppSettingsEntity.NextTaskNumber -- never MAX(number)+1,
// which would reissue a deleted task's number. The GUID Id stays the real identity everywhere else.
public int Number { get; set; }
public required string ListId { get; init; }
public required string Title { get; set; }
public string? Description { get; set; }
public TaskStatus Status { get; set; } = TaskStatus.Idle;
public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None;
public string? BlockedByTaskId { get; set; }
// A user/MCP-declared predecessor, distinct from BlockedByTaskId (the planning chain's own
// internal link): the picker also skips a Queued task while this is set and the referenced
// task's Status isn't Done. Unlike the chain, a Failed/Cancelled dependency does NOT cascade
// or auto-resolve -- the dependent just stays blocked and reports why (see TaskStateService.
// SetDependsOnAsync and QueuePicker).
public string? DependsOnTaskId { get; set; }
public DateTime? ScheduledFor { get; set; }
public string? Result { get; set; }
public string? ReviewFeedback { get; set; }
public int RoadblockCount { get; set; }
// Denormalized from the failing run, same pattern as RoadblockCount, so get_task/
// batch_get_tasks can report why a Failed task stopped without a second query.
// Null on a non-Failed task, or on a Failed task predating this field ("unknown").
public string? FailureReason { get; set; }
public int? FailureTurnsUsed { get; set; }
public int? FailureMaxTurns { get; set; }
public string? LogPath { get; set; }
public required DateTime CreatedAt { get; init; }
public DateTime? StartedAt { get; set; }
@@ -51,6 +68,27 @@ public sealed class TaskEntity
public int SortOrder { get; set; }
public string? SessionSkills { get; set; }
// Newline-separated declared file paths/globs this task expects to touch. User-supplied only
// (never inferred). Only consulted by the queue picker when the list's
// ListConfigEntity.SerializeOnFileOverlap is on, and only for this task's own overlap check --
// an unset value means "no basis to hold this task back", not "touches nothing".
public string? ScopeGlobs { get; set; }
// Review range for a worktree-less task hosting an interactive "list handler" run
// (Mission Control's "Let Claude handle it"): the handler commits its own changes
// straight to the list's working dir, so there is no per-task worktree to diff. These
// capture the repo's HEAD at session start / submit-for-review instead, so the normal
// diff/get_task_diff paths can show `HandlerBaseCommit..HandlerHeadCommit` over the
// list's working dir exactly like a merged task's commit-range diff.
public string? HandlerBaseCommit { get; set; }
public string? HandlerHeadCommit { get; set; }
// The claude session id an embedded ConPTY interactive task session is (or was last)
// running under -- generated up front and persisted before launch so a closed/aborted
// session can be resumed even if the process never got past startup. Cleared implicitly
// whenever the task's worktree is recreated (a fresh worktree has nothing to resume into).
public string? InteractiveSessionId { get; set; }
public string? ParentTaskId { get; set; }
public string? PlanningSessionId { get; set; }
public string? PlanningSessionToken { get; set; }
@@ -15,9 +15,17 @@ public sealed class TaskRunEntity
public int? TurnCount { get; set; }
public int? TokensIn { get; set; }
public int? TokensOut { get; set; }
public int? CacheReadTokens { get; set; }
public int? CacheWriteTokens { get; set; }
public string? LogPath { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; }
public string? Model { get; set; }
// Raw diagnostics from the CLI's stream-json result event (StreamAnalyzer), kept verbatim
// for get_run/get_task_log — TaskEntity.FailureReason is the classified, MCP-facing summary.
public string? ResultSubtype { get; set; }
public string? TerminalReason { get; set; }
public string? Errors { get; set; }
// Navigation property
public TaskEntity Task { get; set; } = null!;
@@ -16,6 +16,7 @@ public sealed class WorktreeEntity
public required string BaseCommit { get; set; }
public string? HeadCommit { get; set; }
public string? DiffStat { get; set; }
public string? MergeCommit { get; set; }
public WorktreeState State { get; set; } = WorktreeState.Active;
public required DateTime CreatedAt { get; init; }
+19 -3
View File
@@ -11,20 +11,36 @@ public static class Paths
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Path must not be empty.", nameof(path));
var expanded = Environment.ExpandEnvironmentVariables(path);
var expanded = System.Environment.ExpandEnvironmentVariables(path);
if (expanded.StartsWith("~", StringComparison.Ordinal))
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile);
expanded = home + expanded[1..];
}
if (!Path.IsPathRooted(expanded))
expanded = Path.GetFullPath(expanded, baseDir ?? Environment.CurrentDirectory);
expanded = Path.GetFullPath(expanded, baseDir ?? System.Environment.CurrentDirectory);
return Path.GetFullPath(expanded);
}
/// <summary>
/// Strips trailing directory separators off a path bound for a Windows command line. Windows
/// argv rules read <c>\"</c> as an escaped quote, so a quoted token ending in '\' never closes
/// (<c>"C:\repo\"</c> parses as <c>C:\repo"</c>) and either the path itself or every argument
/// after it is corrupted. A bare root ("C:\", "/") is all separator and is returned untouched;
/// so is null/blank.
/// </summary>
public static string? TrimTrailingSeparator(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return path;
var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return trimmed.Length == 0 || trimmed.EndsWith(':') ? path : trimmed;
}
/// <summary>~/.todo-app — parent directory for db, logs, config, sandbox, worktrees.</summary>
public static string AppDataRoot() =>
Expand("~/.todo-app");
+319 -60
View File
@@ -1,39 +1,188 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial }
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelperTriage, MergeHelperWait, MergeHelperMerge, MergeHelperInitial, MergeHelperHandoff }
/// <summary>
/// How a prompt kind's on-disk override (if any) relates to the bundled default.
/// </summary>
public enum PromptFileState
{
/// <summary>No override file — the bundled default is in effect.</summary>
Missing,
/// <summary>File exists and is byte-for-byte (normalized) the current default.</summary>
MatchesCurrentDefault,
/// <summary>File exists, differs from today's default, but was recorded as an unedited copy of a past default — safe to reconcile away.</summary>
MatchesKnownPastDefault,
/// <summary>File exists and diverges from the default with no matching recorded hash — a real user customization.</summary>
Edited
}
public static class PromptFiles
{
public static string Root => Path.Combine(Paths.AppDataRoot(), "prompts");
public static string PathFor(PromptKind kind) => kind switch
public static string PathFor(PromptKind kind, string? root = null) =>
Path.Combine(root ?? Root, FileNameFor(kind));
private static string FileNameFor(PromptKind kind) => kind switch
{
PromptKind.System => Path.Combine(Root, "system.md"),
PromptKind.Planning => Path.Combine(Root, "planning-system.md"),
PromptKind.PlanningInitial => Path.Combine(Root, "planning-initial.md"),
PromptKind.Retry => Path.Combine(Root, "retry.md"),
PromptKind.DailyPrep => Path.Combine(Root, "daily-prep.md"),
PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"),
PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"),
PromptKind.Refine => Path.Combine(Root, "refine.md"),
PromptKind.MergeHelper => Path.Combine(Root, "merge-helper-system.md"),
PromptKind.MergeHelperInitial => Path.Combine(Root, "merge-helper-initial.md"),
PromptKind.System => "system.md",
PromptKind.Planning => "planning-system.md",
PromptKind.PlanningInitial => "planning-initial.md",
PromptKind.Retry => "retry.md",
PromptKind.DailyPrep => "daily-prep.md",
PromptKind.WeeklyReport => "weekly-report.md",
PromptKind.ImprovementChild => "improvement-child.md",
PromptKind.Refine => "refine.md",
PromptKind.MergeHelperTriage => "merge-helper-triage.md",
PromptKind.MergeHelperWait => "merge-helper-wait.md",
PromptKind.MergeHelperMerge => "merge-helper-merge.md",
PromptKind.MergeHelperInitial => "merge-helper-initial.md",
PromptKind.MergeHelperHandoff => "merge-helper-handoff.md",
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
public static void EnsureExists(PromptKind kind)
/// <summary>Classify an override file against the bundled default and the recorded default-hash log.</summary>
public static PromptFileState Classify(PromptKind kind, string? root = null)
{
Directory.CreateDirectory(Root);
var path = PathFor(kind);
if (File.Exists(path)) return;
File.WriteAllText(path, DefaultFor(kind));
var effectiveRoot = root ?? Root;
var path = PathFor(kind, effectiveRoot);
if (!File.Exists(path)) return PromptFileState.Missing;
var normalized = Normalize(File.ReadAllText(path));
if (normalized == Normalize(DefaultFor(kind))) return PromptFileState.MatchesCurrentDefault;
var hashes = LoadDefaultHashes(effectiveRoot);
if (hashes.TryGetValue(kind.ToString(), out var recorded) && recorded == HashOf(normalized))
return PromptFileState.MatchesKnownPastDefault;
return PromptFileState.Edited;
}
public static string? ReadOrNull(PromptKind kind)
/// <summary>Write an explicit override. If the content matches today's default, its hash is recorded so a
/// later default change can reconcile it away automatically instead of freezing it forever.</summary>
public static void Save(PromptKind kind, string content, string? root = null)
{
var path = PathFor(kind);
var effectiveRoot = root ?? Root;
Directory.CreateDirectory(effectiveRoot);
File.WriteAllText(PathFor(kind, effectiveRoot), content);
var normalized = Normalize(content);
var hashes = LoadDefaultHashes(effectiveRoot);
if (normalized == Normalize(DefaultFor(kind)))
hashes[kind.ToString()] = HashOf(normalized);
else
hashes.Remove(kind.ToString());
SaveDefaultHashes(effectiveRoot, hashes);
}
/// <summary>Delete the override file (if any) so the bundled default takes effect again.</summary>
public static void ResetToDefault(PromptKind kind, string? root = null)
{
var effectiveRoot = root ?? Root;
var path = PathFor(kind, effectiveRoot);
if (File.Exists(path)) File.Delete(path);
var hashes = LoadDefaultHashes(effectiveRoot);
if (hashes.Remove(kind.ToString())) SaveDefaultHashes(effectiveRoot, hashes);
}
/// <summary>Startup sweep: any override file that only ever matched a past (now superseded) default,
/// and was never actually edited, is dropped so the current default reaches real runs again.</summary>
public static void ReconcileStaleDefaults(string? root = null)
{
var effectiveRoot = root ?? Root;
foreach (var kind in Enum.GetValues<PromptKind>())
if (Classify(kind, effectiveRoot) == PromptFileState.MatchesKnownPastDefault)
ResetToDefault(kind, effectiveRoot);
}
/// <summary>Startup sweep: moves any *.md file under the prompts root that doesn't match a known
/// <see cref="PromptKind"/> path (leftovers from a retired naming scheme) into a "_orphans" subfolder.
/// Never deletes them outright. Returns the destination paths of files it moved.</summary>
public static IReadOnlyList<string> QuarantineOrphans(string? root = null)
{
var effectiveRoot = root ?? Root;
if (!Directory.Exists(effectiveRoot)) return Array.Empty<string>();
var known = Enum.GetValues<PromptKind>()
.Select(k => PathFor(k, effectiveRoot))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var moved = new List<string>();
foreach (var file in Directory.EnumerateFiles(effectiveRoot, "*.md", SearchOption.TopDirectoryOnly))
{
if (known.Contains(file)) continue;
var orphansDir = Path.Combine(effectiveRoot, "_orphans");
Directory.CreateDirectory(orphansDir);
var dest = Path.Combine(orphansDir, Path.GetFileName(file));
if (File.Exists(dest))
dest = Path.Combine(orphansDir,
$"{Path.GetFileNameWithoutExtension(file)}-{HashOf(file)[..8]}{Path.GetExtension(file)}");
File.Move(file, dest);
moved.Add(dest);
}
return moved;
}
/// <summary>Compact, non-LCS diff (common prefix/suffix trimmed, differing middle shown +/-) between the
/// bundled default and the on-disk override, for surfacing a customization in the Files settings tab.</summary>
public static string DiffAgainstDefault(PromptKind kind, string? root = null)
{
var effectiveRoot = root ?? Root;
var defaultLines = Normalize(DefaultFor(kind)).Split('\n');
var fileLines = Normalize(ReadOrNull(kind, effectiveRoot) ?? "").Split('\n');
var prefix = 0;
while (prefix < defaultLines.Length && prefix < fileLines.Length && defaultLines[prefix] == fileLines[prefix])
prefix++;
var suffix = 0;
while (suffix < defaultLines.Length - prefix && suffix < fileLines.Length - prefix &&
defaultLines[^(suffix + 1)] == fileLines[^(suffix + 1)])
suffix++;
var sb = new StringBuilder();
for (var i = prefix; i < defaultLines.Length - suffix; i++) sb.AppendLine("- " + defaultLines[i]);
for (var i = prefix; i < fileLines.Length - suffix; i++) sb.AppendLine("+ " + fileLines[i]);
return sb.ToString().TrimEnd();
}
internal static string Normalize(string s) => s.Replace("\r\n", "\n").Trim();
internal static string HashOf(string content) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
private static string DefaultsHashPath(string root) => Path.Combine(root, ".defaults.json");
private static Dictionary<string, string> LoadDefaultHashes(string root)
{
var path = DefaultsHashPath(root);
if (!File.Exists(path)) return new();
try
{
return JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(path)) ?? new();
}
catch (JsonException)
{
return new();
}
}
private static void SaveDefaultHashes(string root, Dictionary<string, string> hashes)
{
Directory.CreateDirectory(root);
File.WriteAllText(DefaultsHashPath(root), JsonSerializer.Serialize(hashes));
}
public static string? ReadOrNull(PromptKind kind, string? root = null)
{
var path = PathFor(kind, root ?? Root);
if (!File.Exists(path)) return null;
var content = File.ReadAllText(path).Trim();
return string.IsNullOrEmpty(content) ? null : content;
@@ -46,14 +195,16 @@ public static class PromptFiles
public static string Render(PromptKind kind, IReadOnlyDictionary<string, string> values)
=> RenderTemplate(ReadOrDefault(kind), values);
/// <summary>Replace only the given {name} tokens; any other braces pass through untouched.</summary>
private static readonly Regex TokenPattern = new(@"\{(\w+)\}", RegexOptions.Compiled);
/// <summary>Replace only the given {name} tokens; any other braces pass through untouched.
/// Single pass over the template, so a token appearing inside a substituted VALUE is never
/// re-substituted. That matters because the values are user-authored task titles and
/// descriptions: a sharpened description mentioning a literal {repo} must survive verbatim,
/// and it must not depend on the caller happening to order its dictionary correctly.</summary>
public static string RenderTemplate(string template, IReadOnlyDictionary<string, string> values)
{
var sb = new StringBuilder(template);
foreach (var (key, val) in values)
sb.Replace("{" + key + "}", val);
return sb.ToString();
}
=> TokenPattern.Replace(template, m =>
values.TryGetValue(m.Groups[1].Value, out var val) ? val : m.Value);
public static string DefaultFor(PromptKind kind) => kind switch
{
@@ -65,8 +216,11 @@ public static class PromptFiles
PromptKind.WeeklyReport => WeeklyReportDefault,
PromptKind.ImprovementChild => ImprovementChildDefault,
PromptKind.Refine => RefineDefault,
PromptKind.MergeHelper => MergeHelperDefault,
PromptKind.MergeHelperTriage => MergeHelperTriageDefault,
PromptKind.MergeHelperWait => MergeHelperWaitDefault,
PromptKind.MergeHelperMerge => MergeHelperMergeDefault,
PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
PromptKind.MergeHelperHandoff => MergeHelperHandoffDefault,
_ => ""
};
@@ -91,6 +245,10 @@ public static class PromptFiles
trivial/mechanical work, 'sonnet' for normal coding, 'opus' only for genuinely
complex work (cheapest to most capable: haiku < sonnet < opus).
This tool only exists for a standalone top-level task. A child task or a task in a
planning chain does not have it improvements are one layer deep. If you don't have
it, name the follow-up in your final report instead of trying to file it.
## Working in the repo
- Read a file before editing it. Match the conventions already in this codebase
they override generic defaults.
@@ -98,10 +256,30 @@ public static class PromptFiles
just restate the code.
- Validate only at real boundaries (user input, external APIs).
## Reading efficiently
- Locate before reading: use Grep/Glob to find the relevant spot instead of
opening files to look around.
- Read narrowly: pass `offset`/`limit` for the relevant section. Read a whole file
only when you already know you need all of it for files over ~400 lines that's
rarely the case.
- Don't re-read: check whether the content is already in context before reading it
again.
- For orientation questions ("where is X", "how does Y work", "which files touch
Z"), dispatch an exploration subagent instead of reading broadly yourself — the
file dump stays in its context, only the summary comes back. Skip the subagent
for a single targeted read or edit; the overhead isn't worth it there.
## Finishing
- Before claiming done, verify: run the build and relevant tests, confirm they
pass, and report what you ran. If you couldn't verify something, say so plainly.
- Make focused commits using the repository's existing commit-message convention.
You are on this task's own branch in its own worktree a commit here is the
deliverable, not an unrequested auto-commit; a rule against auto-committing
protects `main` and shared checkouts, neither of which is this. Still never
push, never commit on `main`, and never `git add -A` or a bare `git commit`
in a checkout other sessions share.
- Report the real outcome, including the commit SHA if you made one. Don't say
no commit was made when there was.
## Safety
- Never force-push, hard-reset, or delete branches/files beyond the task's scope
@@ -140,7 +318,10 @@ public static class PromptFiles
- Touch as few files as possible. Do not restructure unrelated code.
- Do NOT file further improvements improvements are one layer deep.
- Verify the build and relevant tests before finishing, and report what you ran.
- Make one focused commit using the repository's commit-message convention.
- Make one focused commit in this task's own worktree using the repository's
commit-message convention that commit is the deliverable, not an unrequested auto-commit.
Report the real outcome, including the commit SHA; don't say no commit was
made when there was.
""";
private const string PlanningSystemDefault = """
@@ -154,8 +335,13 @@ public static class PromptFiles
until the user has approved the design.
You can ONLY shape this task's plan you cannot edit files or touch other tasks.
The tools available to you are: CreateChildTask, ListChildTasks, UpdateChildTask,
DeleteChildTask, UpdatePlanningTask, and Finalize. Use nothing else.
To shape it you have the ClaudeDo planning tools: CreateChildTask, ListChildTasks,
UpdateChildTask, DeleteChildTask, UpdatePlanningTask and Finalize. You also have
Skill (needed for the brainstorming skill above), Read, Grep, Glob, WebFetch and
WebSearch. You do NOT have Write, Edit or Bash.
Use Read/Grep/Glob to ground the plan in the actual repo a subtask that names the
real files and symbols involved runs far better than one written from guesswork.
Once the design is approved, create the child tasks with CreateChildTask, then
call Finalize. Keep each subtask concrete and self-contained with a clear
@@ -168,6 +354,12 @@ public static class PromptFiles
- sonnet normal coding work; the sensible default when unsure.
- opus only for genuinely complex, cross-cutting, or hard-to-debug work.
Do not default everything to opus most subtasks are haiku or sonnet.
Only for a subtask you can tell will need noticeably more turns than the
default budget (a large or sprawling piece of work), also pass CreateChildTask's
`maxTurns` argument with a generous turn count, so the run doesn't die mid-work
at the turn limit. Leave `maxTurns` null for everything else it inherits the
list/global default.
""";
private const string PlanningInitialDefault = """
@@ -224,30 +416,41 @@ public static class PromptFiles
not already present in the current subtasks above.
Use ONLY these tools: mcp__claudedo__get_task, mcp__claudedo__update_task,
mcp__claudedo__add_subtask, and read-only Read/Grep/Glob. When you have updated the
task, stop.
mcp__claudedo__add_subtask, and only when a repository is available read-only
Read/Grep/Glob. When you have updated the task, stop.
""";
private const string MergeHelperDefault = """
You are the ClaudeDo list handler, running as an interactive session with the user watching. Ask them questions whenever you are unsure that is the point of this session.
private const string MergeHelperTriageDefault = """
You are the ClaudeDo list handler, running as an interactive session with the user watching. Work autonomously and decide things yourself by default. Ask the user only for decisions that are genuinely theirs to make: merging a duplicate task, or a task whose intent is too unclear to act on safely. Everything else, decide and keep moving.
Your job: take the tasks listed in the brief and drive the whole set to merged, Done work reading them first, removing duplicates, sharpening what stays, running it, then reviewing and merging each result. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. All tasks belong to that one list and one repo.
Your job: take the tasks listed in the brief and get the set ready to run reading them all first, removing duplicates, then sharpening what stays. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. All tasks belong to that one list and one repo.
Work the five phases in order. Do not start a phase before the previous one is finished.
A second session takes over after you to run, review and merge these tasks. Your deliverable is a clean, sharpened set of task descriptions you never run or merge anything yourself, and you touch no git state.
Work the three phases in order. Do not start a phase before the previous one is finished.
## Phase 0 Read everything
Call batch_get_tasks with every id from the brief and read each task's title, description, status and parent/child links. Do not act on any single task before you have read them all Phase 1 needs the whole set in view.
The brief is the primary source: it already lists every task's title, id, status and full description. Read it in full before acting. Only call batch_get_tasks if you need something the brief does not carry for a specific task, e.g. parent/child links. Do not act on any single task before you have read them all Phase 1 needs the whole set in view.
## Phase 1 Dedupe
Compare the tasks pairwise for overlap: same goal stated twice, one task fully contained in another, two tasks that would edit the same thing for the same reason.
Print a table of the candidate pairs with, for each, the reason it looks like a duplicate. Then ask the user about EACH pair, one at a time:
If no pair looks like a duplicate, say so in one sentence and move straight to Phase 2 do not ask the user to confirm the absence of duplicates.
If at least one pair looks like a duplicate, print a table of the candidate pairs with, for each, the reason it looks like a duplicate. Then ask the user about EACH pair, one at a time:
- merge fold whatever the loser says that the survivor does not into the survivor via update_task, then update_task_status(loserId, "Cancelled"). Cancelled keeps the task visible and resettable; never use delete_task for this.
- keep both note why and move on.
Cancel nothing without an explicit answer. If there are no duplicates, say so and go on.
Cancel nothing without an explicit answer.
## Phase 2 Enhance for execution
Before touching any task, check the list's verify command ONCE for this whole run (not per task): call get_list_config. If verifyCommand is already set, leave it alone never overwrite an existing one and move straight to sharpening tasks below.
If no verifyCommand is set:
1. Determine the repo's canonical, WIDE build-and-test command read its CLAUDE.md/README. It must be a full build plus the complete test suite, deliberately NOT something narrowed to this run's tasks: a narrow verify only checks the files a task touched and lets test collisions slip through that a src-only build would never see.
2. Run that command yourself once via Bash to prove it actually works in this repo.
3. Only then ask the user to confirm it, and write it with set_list_config(verifyCommand: ...). This value outlives the run and applies to every future task on this list, so confirm never set it silently.
Each surviving task is about to be run by an autonomous agent with no further input. Sharpen it so that run can succeed. For each task, rewrite title and description to carry:
- concrete acceptance criteria what must be true when it is done,
- the files and areas actually involved, found with Read/Grep/Glob in the repo. Do not guess paths; look them up.
@@ -257,43 +460,83 @@ public static class PromptFiles
Rules: do not change what the user asked for, and do not invent requirements. You are making the existing intent precise, not adding to it. If a task is too vague to sharpen without guessing, ASK instead of guessing. Report a short before/after per task.
## Phase 3 Run
Do NOT use run_task_now for a batch there is a single override slot and the second call fails with "override slot busy".
If a task visibly bundles several independent features, or has a blocker that is not resolved by anything in its own description, do not force it into one description. Propose splitting it to the user; if they agree, create the pieces with add_task/add_subtask and only move the pieces the user confirmed into "surviving tasks" for the phases below. Split only what the task already asks for the "do not invent requirements" rule still applies.
Read get_app_settings and tell the user how many parallel execution slots are configured (maxParallelExecutions). If it is 1, say plainly that the tasks will execute one after another and that the value is changeable in ClaudeDo's settings.
## Handoff
Once every surviving task is enhanced, print your triage summary one line per task from the brief:
title dedupe action (kept / merged into X / cancelled as duplicate of X) enhanced (yes/no).
Then, for each surviving task:
- Idle or Failed update_task_status(id, "Queued"). For a Failed task ask first whether to reset_failed_task and re-queue it, or skip it.
- Queued leave it; it is already waiting for a slot.
- Running or WaitingForChildren leave it; only poll.
- WaitingForReview leave it; it goes straight to Phase 4.
Then call handoff_list_handler(taskId, survivingTaskIds, nextPhase: "wait"). That opens a fresh session to carry out the wait/merge phases with just that list, without dragging along this session's dedupe/rewrite context. Say a short goodbye line, then stop do not continue into phase 3 yourself.
""";
Poll get_task until every task has left Queued and Running WaitingForReview on success, Failed on error. Report progress as tasks land; do not poll silently for minutes.
private const string MergeHelperWaitDefault = """
You are the ClaudeDo list handler, running as an interactive session with the user watching. This is the Wait phase the shortest and cheapest of the three list-handler roles. Your only job is to queue the tasks you were handed and wait for them to finish running; you do not read diffs, merge anything, or restart anything. The Merge session that runs after you owns all of those decisions.
You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. A prior session already sharpened these task descriptions do not re-read or re-edit them.
## Phase 3 Queue and wait
1. Queue every task handed to you: update_task_status(id, "Queued") for each one.
2. Wait with wait_for_task_change instead of sleeping and polling get_task yourself. Pass every handed-off id, set treatWaitingForChildrenAsBusy=true without that flag a task with children is reported done the moment it reaches WaitingForChildren while its children are still working and use timeoutSeconds 900. The call blocks server-side up to 900s and pings MCP progress every 30s, so one long wait survives your client's own idle-silence abort and costs one turn where several short ones would cost several.
3. Repeat step 2 with whatever ids are still Queued, Running or WaitingForChildren, until none remain.
Touch nothing else: do not read a diff, merge, reset, restart, or cancel any task. A task that lands in Failed is simply noted and passed on deciding what to do about it belongs to the Merge session, not you.
MaxParallelExecutions is deliberately not reported here the queue enforces it on its own; that reporting was the old combined phase's job and no longer belongs to this role.
## Handoff
Once every task has left Queued/Running/WaitingForChildren, call handoff_list_handler(taskId, <every task id from this round>, nextPhase: "merge") or nextPhase: "merge_final" if your own phase was "wait_final". Then stop your turn immediately.
""";
private const string MergeHelperMergeDefault = """
You are the ClaudeDo list handler, running as an interactive session with the user watching. Work autonomously and decide things yourself by default. Ask the user only for decisions that are genuinely theirs to make: a diff that looks wrong or risky, or a conflict resolution you cannot resolve with confidence. Everything else, decide and keep moving.
Your job: review and merge the tasks a prior Wait session ran, then decide what if anything deserves one rerun. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. The handed-off id list is only the SET of tasks not their current state: call batch_get_tasks yourself to read where each one actually landed.
## Phase 4 Review and merge
One task at a time, in the order the brief lists them.
1. Inspect the change with get_task_diff (stat first, then the full diff if it is non-trivial) and sanity-check it against the task's title and description.
2. If the change looks wrong, incomplete, or risky, STOP and ask the user before merging offer reject_rerun (with feedback) or skip.
3. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true).
- Clean merge the task is Done; move on.
- Conflict (markers left in the working tree, repoPath returned) resolve it.
Merge everything mergeable BEFORE starting any rerun. A rerun's fresh worktree forks from the CURRENT main, so running it after the merges lets it fork from the already-updated base and merge conflict-free; a rerun started before the merges forks from the old base and walks straight into a conflict with the merges still to come.
Before merging anything, call preview_merge_set with every candidate task's id (the same targetBranch you are about to merge into). It tells you, per task, whether a clean merge-tree preview is even possible (status/conflictFiles/changedFileCount/behind) and which files more than one task changed (overlaps). Read the overlaps: a file two tasks both touch is where a same-branch collision could happen. This is a HINT, not proof it only catches same-file overlap, not a cross-file break (e.g. one task deletes a symbol another task's file still references), and a clean preview never guarantees the result compiles or passes tests.
One task at a time. Default to the order the brief lists them. If the overlap check shows two or more tasks touching the same file, tell the user which tasks collide and merge those in an order you can justify (e.g. the one making the smaller change first) deviate from brief order only with that stated reason.
1. Never read a task's diff yourself. Instead start a sonnet subagent (Task tool) per task to inspect get_task_diff and sanity-check it against the task's title and description; only its short verdict (clean / risky + reason) comes back into your own context. If preview_merge_set flagged this task in an overlap, tell the subagent to also skim the diff of the other task(s) sharing that file.
2. Before approving, cross-check changedFileCount from preview_merge_set/get_task_diff. 0 changed files means an empty branch, not a clean success do not approve it; treat it like a risky diff instead.
3. If the subagent's verdict is risky, or the change looks wrong or incomplete, STOP and ask the user before merging offer reject_rerun (with feedback) or skip.
4. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true).
- Clean merge, verify gate passes (or no verify command is set) the task is Done; move on.
- Clean merge, verify gate fails the merge stands, but the Done transition is deliberately withheld. Report this task as merged-but-not-Done and move on do NOT try to fix the verify failure yourself; that is separate follow-up work.
- Conflict (markers left in the working tree, repoPath returned) resolve it below.
After each merge, main has moved a clean preview_merge_set result from before this merge is now stale for the remaining tasks. If you are unsure whether an earlier overlap warning still matters, call preview_merge for the next task again before merging it.
Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted.
Resolving a conflict:
Resolving a conflict do this yourself, never via a subagent, since every subagent would share this session's same main checkout:
- Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId). If markers remain it tells you fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve.
- For a task WITH children (a unit merge), pass the PARENT task id to continue_merge / abort_merge.
- If a resolution is non-obvious, ambiguous, or might drop someone's work, ASK THE USER before continuing.
- Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions.
Rules for the whole session:
- Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish.
- Ask the user for anything ambiguous, risky, or destructive.
## Failed tasks only after every merge above is done
For each task that landed in Failed, read terminal_reason before deciding anything Failed is a catch-all covering an account/usage limit, a max-turns cutoff, and a genuine crash, and each deserves a different response. Never restart one blind:
- continue_task(taskId) resumes the same session and keeps its work the right call when the session simply ran out of turns or hit a transient limit mid-progress.
- reset_failed_task(taskId) followed by update_task_status(taskId, "Queued") discards the worktree and starts clean use it only when the existing work is not worth keeping (e.g. a genuine crash early on).
- Leave it alone and report it the right call when neither of the above looks safe, or the failure needs a human decision.
Allow at most ONE rerun per task in this session.
## Handoff / end
- If you started any reruns: call handoff_list_handler(taskId, <the rerun task ids>, nextPhase: "wait_final"), then stop your turn immediately do not merge anything yet that you just restarted.
- If your own phase was "merge_final": never start a rerun, no matter how tempting just merge what you can, report the rest, and print the summary below.
- Otherwise (no reruns started, phase was "merge"): print the summary below and stop; there is nothing left to hand off.
## Phase 5 Summary
Print one line per task from the original brief:
title dedupe action (kept / merged into X / cancelled as duplicate of X) enhanced (yes/no) final status merge commit (if any) conflicts resolved (if any).
Print one line per task from the brief:
title final status merge commit (if any) conflicts resolved (if any) verify-failed (yes/no).
Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks.
""";
@@ -311,6 +554,22 @@ public static class PromptFiles
When every task is handled, print the summary.
""";
private const string MergeHelperHandoffDefault = """
# List handler handoff
Scope: {scope}
Repo: {repo}
A prior session already read, deduped and enhanced this list's tasks. Pick up at phase 3
for the tasks below their descriptions are already sharpened.
{tasks}
Start with phase 3 (run), continuing through review/merge and the summary as your
instructions describe.
{finalNote}
""";
private const string WeeklyReportDefault = """
You are generating a concise weekly standup report for a software developer,
covering {start} to {end}.
@@ -14,25 +14,45 @@ public sealed class AppSettingsRepository
{
var row = await _context.AppSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
if (row is not null) return row;
if (row is null)
{
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId, ModelPresets = ModelPresets.SerializeDefaults() };
_context.AppSettings.Add(row);
try
{
await _context.SaveChangesAsync(ct);
_context.Entry(row).State = EntityState.Detached;
}
catch (DbUpdateException)
{
// Concurrent process already inserted the singleton — discard our attempt and re-read.
_context.Entry(row).State = EntityState.Detached;
row = await _context.AppSettings.AsNoTracking()
.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
}
return row;
}
// First read after upgrading from a null model_presets column: persist the shipping
// defaults so the Settings UI shows real, editable rows instead of a code-only fallback.
if (row.ModelPresets is null)
row.ModelPresets = await BackfillModelPresetsAsync(ct);
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
_context.AppSettings.Add(row);
try
{
await _context.SaveChangesAsync(ct);
_context.Entry(row).State = EntityState.Detached;
}
catch (DbUpdateException)
{
// Concurrent process already inserted the singleton — discard our attempt and re-read.
_context.Entry(row).State = EntityState.Detached;
row = await _context.AppSettings.AsNoTracking()
.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
}
return row;
}
private async Task<string> BackfillModelPresetsAsync(CancellationToken ct)
{
var defaults = ModelPresets.SerializeDefaults();
var tracked = await GetOrCreateTrackedRowAsync(ct);
if (tracked.ModelPresets is null)
{
tracked.ModelPresets = defaults;
await _context.SaveChangesAsync(ct);
}
return defaults;
}
private async Task<AppSettingsEntity> GetOrCreateTrackedRowAsync(CancellationToken ct)
{
var row = await _context.AppSettings
@@ -52,6 +72,7 @@ public sealed class AppSettingsRepository
row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty;
row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel;
row.DefaultMaxTurns = updated.DefaultMaxTurns;
row.MaxTurnsCeiling = updated.MaxTurnsCeiling < 1 ? 1 : updated.MaxTurnsCeiling;
row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode)
? "auto" : updated.DefaultPermissionMode;
row.MaxParallelExecutions = updated.MaxParallelExecutions < 1 ? 1 : updated.MaxParallelExecutions;
@@ -65,6 +86,12 @@ public sealed class AppSettingsRepository
row.StandupWeekday = updated.StandupWeekday;
row.DailyPrepMaxTasks = updated.DailyPrepMaxTasks < 1 ? 1 : updated.DailyPrepMaxTasks;
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
row.UsageGateFiveHourPct = Math.Clamp(updated.UsageGateFiveHourPct, 0, 100);
row.UsageGateSevenDayPct = Math.Clamp(updated.UsageGateSevenDayPct, 0, 100);
row.UsageThrottleFiveHourSoftPct = Math.Clamp(updated.UsageThrottleFiveHourSoftPct, 0, 100);
row.UsageThrottleFiveHourHardPct = Math.Clamp(updated.UsageThrottleFiveHourHardPct, 0, 100);
row.UsageThrottleSevenDaySoftPct = Math.Clamp(updated.UsageThrottleSevenDaySoftPct, 0, 100);
row.UsageThrottleSevenDayHardPct = Math.Clamp(updated.UsageThrottleSevenDayHardPct, 0, 100);
await _context.SaveChangesAsync(ct);
}
@@ -16,16 +16,25 @@ public sealed class ListRepository
public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
{
NormalizeWorkingDir(entity);
_context.Lists.Add(entity);
await _context.SaveChangesAsync(ct);
}
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
{
NormalizeWorkingDir(entity);
_context.Lists.Update(entity);
await _context.SaveChangesAsync(ct);
}
// A working dir is user-supplied (typed, folder-picked, imported, or set over MCP) and every
// consumer eventually hands it to the claude CLI or wt.exe as one quoted command-line token —
// where a trailing '\' escapes its own closing quote. Normalizing here covers all writers;
// see Paths.TrimTrailingSeparator.
private static void NormalizeWorkingDir(ListEntity entity) =>
entity.WorkingDir = Paths.TrimTrailingSeparator(entity.WorkingDir);
public async Task DeleteAsync(string listId, CancellationToken ct = default)
{
var taskIds = await _context.Tasks
@@ -78,6 +87,8 @@ public sealed class ListRepository
existing.AgentPath = config.AgentPath;
existing.MaxTurns = config.MaxTurns;
existing.SessionSkills = config.SessionSkills;
existing.VerifyCommand = config.VerifyCommand;
existing.SerializeOnFileOverlap = config.SerializeOnFileOverlap;
}
await _context.SaveChangesAsync(ct);
}
@@ -26,8 +26,7 @@ public sealed class TaskRepository
.MaxAsync(ct);
entity.SortOrder = (maxSort ?? -1) + 1;
_context.Tasks.Add(entity);
await _context.SaveChangesAsync(ct);
await TaskNumberAllocator.AddWithNumberAsync(_context, entity, ct);
}
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
@@ -50,6 +49,11 @@ public sealed class TaskRepository
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
}
public async Task<TaskEntity?> GetByNumberAsync(int number, CancellationToken ct = default)
{
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Number == number, ct);
}
public async Task<List<TaskEntity>> GetByListIdAsync(string listId, CancellationToken ct = default)
{
return await _context.Tasks
@@ -276,8 +280,12 @@ public sealed class TaskRepository
string? commitType,
string? createdBy = null,
string? model = null,
int? maxTurns = null,
CancellationToken ct = default)
{
if (maxTurns.HasValue && maxTurns.Value <= 0)
throw new ArgumentException($"maxTurns must be positive, got {maxTurns.Value}.");
// AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which
// bypasses the change tracker; a tracked Find would return stale data.
var parent = await _context.Tasks.AsNoTracking()
@@ -303,9 +311,9 @@ public sealed class TaskRepository
SortOrder = (maxSort ?? -1) + 1,
CreatedBy = createdBy,
Model = ModelRegistry.NormalizeAlias(model),
MaxTurns = maxTurns,
};
_context.Tasks.Add(child);
await _context.SaveChangesAsync(ct);
await TaskNumberAllocator.AddWithNumberAsync(_context, child, ct);
return child;
}
@@ -384,6 +392,32 @@ public sealed class TaskRepository
.SetProperty(t => t.PlanningSessionId, sessionId), ct);
}
// Stamps the review range's head commit for a worktree-less "list handler" host task
// (Mission Control's "Let Claude handle it") when its ConPTY session is submitted for review.
public async Task SetHandlerHeadCommitAsync(
string taskId,
string headCommit,
CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.HandlerHeadCommit, headCommit), ct);
}
// Persists the claude session id a fresh embedded ConPTY interactive task session will run
// under, written BEFORE launch so a closed/aborted session still leaves a resumable id.
public async Task SetInteractiveSessionIdAsync(
string taskId,
string? sessionId,
CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.InteractiveSessionId, sessionId), ct);
}
public async Task<TaskEntity?> FindByPlanningTokenAsync(
string token,
CancellationToken ct = default)
@@ -36,6 +36,26 @@ public sealed class WorktreeRepository
.ExecuteUpdateAsync(s => s.SetProperty(w => w.State, state), ct);
}
public async Task SetMergedAsync(string taskId, string mergeCommit, CancellationToken ct = default)
{
await _context.Worktrees
.Where(w => w.TaskId == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(w => w.State, WorktreeState.Merged)
.SetProperty(w => w.MergeCommit, mergeCommit), ct);
}
/// <summary>Records a successful auto-rebase: the branch's fork point moves to the rebased-onto
/// commit and its head moves to the new tip <c>git rebase</c> produced.</summary>
public async Task SetRebasedAsync(string taskId, string baseCommit, string headCommit, CancellationToken ct = default)
{
await _context.Worktrees
.Where(w => w.TaskId == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(w => w.BaseCommit, baseCommit)
.SetProperty(w => w.HeadCommit, headCommit), ct);
}
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
{
await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
+87
View File
@@ -0,0 +1,87 @@
using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data;
// Allocates TaskEntity.Number and inserts the task. Never MAX(number)+1 -- that would reissue a
// deleted task's number. Allocation is a single atomic UPDATE...RETURNING against the
// app_settings singleton row, which SQLite's single-writer model already serializes.
//
// The allocation and the insert are deliberately two separate statements, each committed on its
// own, rather than one shared transaction: a shared transaction would roll the counter bump back
// together with a failed insert, so a retry would hand out the exact same (still colliding)
// number every time and the bounded retry below would be pointless. Committing the bump
// unconditionally means a retry after a collision always advances to a fresh number, at the cost
// of burning the collided number as an unused gap -- which is fine, gaps are expected.
public static class TaskNumberAllocator
{
private const int MaxAttempts = 5;
public static async Task AddWithNumberAsync(
ClaudeDoDbContext context, TaskEntity entity, CancellationToken ct = default)
{
for (var attempt = 1; ; attempt++)
{
var settings = await context.AppSettings.FromSqlRaw(
"""
UPDATE app_settings SET next_task_number = next_task_number + 1
WHERE id = {0}
RETURNING *
""", AppSettingsEntity.SingletonId).AsNoTracking().ToListAsync(ct);
if (settings.Count == 0)
{
// The singleton row is missing (e.g. a corrupted/hand-edited DB) -- recreate it,
// as its own separate insert/commit, and retry the UPDATE above rather than
// indexing into an empty result. Doesn't count against MaxAttempts: it's not a
// number collision.
await EnsureSingletonRowAsync(context, ct);
continue;
}
entity.Number = settings[0].NextTaskNumber - 1;
context.Tasks.Add(entity);
try
{
await context.SaveChangesAsync(ct);
return;
}
catch (DbUpdateException ex)
{
context.Entry(entity).State = EntityState.Detached;
if (attempt < MaxAttempts && IsNumberCollision(ex))
continue;
throw;
}
}
}
private static async Task EnsureSingletonRowAsync(ClaudeDoDbContext context, CancellationToken ct)
{
var row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
context.AppSettings.Add(row);
try
{
await context.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
// A concurrent process already inserted the singleton -- fine, the next UPDATE
// attempt above will find it.
}
finally
{
context.Entry(row).State = EntityState.Detached;
}
}
// Distinguishes the expected unique-index collision on idx_tasks_number (worth retrying with
// a fresh number) from any other DbUpdateException -- an FK violation, a NOT NULL violation,
// etc. -- which must surface immediately instead of silently burning up to MaxAttempts numbers
// on a failure a retry can never fix.
private static bool IsNumberCollision(DbUpdateException ex) =>
ex.InnerException is SqliteException { SqliteErrorCode: 19 } sqliteEx
&& sqliteEx.Message.Contains("tasks.number", StringComparison.Ordinal);
}
+41 -1
View File
@@ -4,13 +4,18 @@ using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Windows;
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Checks.Interfaces;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Core.Interfaces;
using ClaudeDo.Installer.Localization;
using ClaudeDo.Localization;
using ClaudeDo.Releases;
using ClaudeDo.Installer.Pages.DiagnosePage;
using ClaudeDo.Installer.Pages.InstallPage;
using ClaudeDo.Installer.Pages.PathsPage;
using ClaudeDo.Installer.Pages.ServicePage;
using ClaudeDo.Installer.Pages.SystemCheckPage;
using ClaudeDo.Installer.Pages.UiSettingsPage;
using ClaudeDo.Installer.Pages.WelcomePage;
using ClaudeDo.Installer.Steps;
@@ -27,6 +32,13 @@ public partial class App : Application
{
base.OnStartup(e);
// The app relaunches us via ShellExecute without an explicit working directory, so we
// inherit its CWD — which the Start Menu shortcut sets to <InstallDir>\app. A process's
// current directory is locked by the OS, so we would block DownloadAndExtractStep's
// Directory.Move of app\ against ourselves (fails on every attempt, reboot included).
// Step out of the install tree before anything else touches it.
try { Environment.CurrentDirectory = Path.GetTempPath(); } catch { /* best effort */ }
// --- Initialize localizer as early as possible so all windows can use {loc:Tr} ---
var localesDir = Path.Combine(AppContext.BaseDirectory, "locales");
var localeStore = LocaleStore.Load(localesDir);
@@ -114,17 +126,45 @@ public partial class App : Application
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
sc.AddSingleton<InstallModeDetector>();
// Environment checks — stateless, so their infrastructure is shared; ClaudeCliLookup is
// rebuilt per EnvironmentCheckService instance so a re-check doesn't reuse a stale result.
sc.AddSingleton<IProcessRunner, ProcessRunnerAdapter>();
sc.AddSingleton<IProcessLauncher, ProcessLauncher>();
sc.AddSingleton<IPortOwnerResolver, NetstatPortOwnerResolver>();
sc.AddSingleton<ClaudeHelpLauncher>();
sc.AddTransient<Func<EnvironmentCheckService>>(sp => () =>
{
var processRunner = sp.GetRequiredService<IProcessRunner>();
var claudeLookup = new ClaudeCliLookup(processRunner);
return new EnvironmentCheckService(new IEnvironmentCheck[]
{
new GitCheck(processRunner),
new GitIdentityCheck(processRunner),
new PortCheck(sp.GetRequiredService<IPortOwnerResolver>()),
new WriteAccessCheck(),
new ClaudeCliCheck(claudeLookup),
new ClaudeVersionCheck(claudeLookup),
new ClaudeAuthCheck(claudeLookup),
new PermissionModeAutoCheck(claudeLookup),
});
});
// Pages
sc.AddSingleton<IInstallerPage, WelcomePageViewModel>();
sc.AddSingleton<IInstallerPage, SystemCheckPageViewModel>();
sc.AddSingleton<IInstallerPage, PathsPageViewModel>();
sc.AddSingleton<IInstallerPage, ServicePageViewModel>();
sc.AddSingleton<IInstallerPage, UiSettingsPageViewModel>();
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
sc.AddSingleton<IInstallerPage>(sp => new DiagnosePageViewModel(
sp.GetRequiredService<InstallContext>(),
sp.GetRequiredService<Func<EnvironmentCheckService>>(),
InstallerWorkerConfig.Load));
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
// Double-registered as both IInstallStep and concrete type so the Update pipeline
// can pull them out individually via GetRequiredService<T>().
sc.AddSingleton<DownloadAndExtractStep>();
sc.AddSingleton(sp => new DownloadAndExtractStep(sp.GetRequiredService<IReleaseClient>()));
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
+81 -11
View File
@@ -21,20 +21,18 @@ Note: this is the one project where `System.Windows` is correct (WPF, not Avalon
3. Open `WizardWindow` (FreshInstall / Update) or `SettingsWindow` (Config)
The installer does **not** self-update. Each release ships a stable-named
`ClaudeDo.Installer.exe` asset (permanent URL
`…/releases/latest/download/ClaudeDo.Installer.exe`); the installer never checks for or
replaces itself on launch. The in-app "Update" button relaunches the on-disk installer to
run the app update — the installer binary itself only changes when the user downloads a
fresh copy. App-update detection is unaffected: `WriteInstallManifestStep` records
`ctx.InstalledVersion` (the release tag from `DownloadAndExtractStep`), which
`InstallModeDetector` compares against the latest tag.
`ClaudeDo.Installer.exe` asset (permanent URL `…/releases/latest/download/ClaudeDo.Installer.exe`);
the binary only changes when the user downloads a fresh copy. The in-app "Update" button relaunches
the on-disk installer to run the *app* update. App-update detection is unaffected:
`WriteInstallManifestStep` records `ctx.InstalledVersion` (the release tag from
`DownloadAndExtractStep`), which `InstallModeDetector` compares against the latest tag.
## Modes (`Core/InstallerMode.cs`)
| Mode | Condition | Window |
|---|---|---|
| `FreshInstall` | No `install.json` | Full wizard (all pages) |
| `Update` | `install.json` present + newer release available | Wizard — Welcome + Install pages only |
| `FreshInstall` | No `install.json` | Full wizard: Welcome → **SystemCheck** → Paths → Service → UiSettings → Install |
| `Update` | `install.json` present + newer release available | Wizard — Welcome + Install pages only (SystemCheck **not** shown) |
| `Config` | Current version, or Gitea API unreachable | `SettingsWindow` (settings / repair / uninstall) |
## Install Pipelines
@@ -62,7 +60,8 @@ Installer/
ConfigModels, InstallerService, UninstallRunner, PageResolver,
AutostartShortcut, ShortcutFactory, ProcessRunner, DarkTitleBar
Interfaces/ — IInstallStep + StepResult/StepStatus/StepProgress, IInstallerPage
Pages/ WelcomePage, PathsPage, ServicePage, UiSettingsPage, InstallPage
Checks/ — environment preflight checks, see "Environment Checks" below
Pages/ — WelcomePage, SystemCheckPage, PathsPage, ServicePage, UiSettingsPage, InstallPage
(each: ViewModel + View.xaml)
Views/ — WizardWindow(+WizardViewModel), SettingsWindow(+SettingsViewModel)
```
@@ -76,12 +75,23 @@ claude mcp add --transport http --scope user claudedo http://127.0.0.1:{External
```
Non-fatal if `claude` CLI is missing or too old (prints the manual command). Server name: `claudedo`.
**`RegisterAutostartStep`** — creates a per-user Startup-folder shortcut `ClaudeDo Worker.lnk` (`Environment.SpecialFolder.Startup`). Also migrates away from legacy mechanisms:
**`RegisterAutostartStep`** — creates a per-user Startup-folder shortcut `ClaudeDo Worker.lnk` (`Environment.SpecialFolder.Startup`); `AutostartShortcut.Install` skips the rewrite (and reports it) when the shortcut already points at the current worker exe, so update/repair runs don't touch it needlessly. Also migrates away from legacy mechanisms, unconditionally on every run (no cached "already migrated" flag — see the comment in the step):
- Deletes legacy Windows service: `sc.exe stop/delete ClaudeDoWorker`
- Deletes legacy scheduled task: `schtasks /Delete /TN ClaudeDoWorker`
No new service or scheduled task is created. Rationale: the worker must run in the user's interactive session so Claude CLI auth works.
**`DownloadAndExtractStep`** — fetches `checksums.txt` first and only touches the install dir after
the zip verifies. The zip is cached in `%TEMP%\ClaudeDo-download-cache` (ctor takes an override for
tests) and reused on a retry when its SHA-256 still matches, so a failed attempt doesn't cost
another full download. Cache is dropped after a successful install, a bad download is deleted
immediately, other versions are pruned. `app\`/`worker\` are stashed to `*.bak` before extraction
and restored if it fails.
### Gotcha: the installer must never run from inside the install dir
`App.OnStartup` sets `Environment.CurrentDirectory` to `%TEMP%`, and the UI passes an explicit `WorkingDirectory` when it relaunches us. A process's current directory is locked by Windows: inheriting the app's CWD (`<InstallDir>\app`, from the Start Menu shortcut's "start in") made the installer block its own `app``app.bak` rename, so every update failed with "Could not replace the existing files" — unaffected by retries or a reboot. Keep both guards.
## `InstallContext` Defaults
| Property | Default |
@@ -115,3 +125,63 @@ No new service or scheduled task is created. Rationale: the worker must run in t
| `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\ClaudeDo Worker.lnk` | Worker autostart |
The Apps & Features uninstall string and "Rerun Installer" both point at `<InstallDir>\uninstaller\ClaudeDo.Installer.exe` with no `/uninstall` flag — Config mode is detected from `install.json`.
## Environment Checks
`Checks/` holds one `IEnvironmentCheck` per concern, run in parallel by `EnvironmentCheckService.RunAllAsync`:
| Check | Severity | What it verifies |
|---|---|---|
| `GitCheck` | Error | `git` resolvable (via `ExecutableResolver`) and runs |
| `WriteAccessCheck` | Error | install dir + `%APPDATA%` (or first existing parent) are writable |
| `ClaudeCliCheck` | Error | `claude` resolvable on PATH, including npm `.cmd`/`.bat`/`.ps1` shims |
| `ClaudeVersionCheck` | Error | resolved `claude --version``ClaudeVersionCheck.MinimumVersion` (currently `2.1.220`) |
| `ClaudeAuthCheck` | Error | `claude auth status --json` reports `loggedIn: true` (never sends a prompt) |
| `GitIdentityCheck` | Warning | `git config user.name`/`user.email` are set |
| `PortCheck` | Warning | `SignalRPort`/`ExternalMcpPort` are free, or already owned by a running `ClaudeDo.Worker` |
| `PermissionModeAutoCheck` | Warning | CLI's `--help` still lists `auto` as a `--permission-mode` choice |
Each check returns a `CheckResult` with `CheckStatus` (`Ok` / `Failed` / `Unknown`). A check that
throws is caught by `EnvironmentCheckService` and turned into `Unknown`, never a crash.
**Gating rule:** `EnvironmentCheckReport.HasBlockingError` is true only when a check with
`Severity == Error` has `Status == Failed`. Warnings never block, and `Unknown` never blocks
regardless of severity (an indeterminate result — e.g. the CLI not found, so version/auth/auto-mode
can't be checked — must not strand the user; the underlying `Error`-severity check for the CLI
itself, `ClaudeCliCheck`, is what blocks in that case).
The check-row rendering and the check-run logic (busy state, summary text, Recheck command) live
in one place — `Checks/CheckListViewModel.cs` + `Checks/CheckListView.xaml` — composed by every
page that hosts a check list, not duplicated per page.
`SystemCheckPage` (`Pages/SystemCheckPage/`) hosts the check list in the **FreshInstall** wizard
only, registered via `PageResolver` at `Order = 1` (directly after `WelcomePage`); `WizardViewModel`
filters it back out in `Update` mode along with Paths/Service/UiSettings. Checks run automatically
on page entry (`LoadAsync`, guarded against double-entry). "Next" is disabled via
`IInstallerPage.BlocksNavigation` (`IsRunning || HasBlockingError`) — `WizardViewModel.CanGoNext`
subscribes to `PropertyChanged` on the current page so a live recheck can flip it back. A "Recheck"
button re-runs `EnvironmentCheckService.RunAllAsync` (disabled while already running).
**"Claude Help Me" button** (`Core/ClaudeHelpLauncher.cs`) — a second button below the shared
check-list footer, enabled only when `claude-cli` is `Ok` and `claude-auth` is not `Failed` (`Unknown`
stays enabled — an indeterminate login state shouldn't block the one feature that could help
diagnose it). `BuildReportAsync` renders all check results (Id/Severity/Status/Message table,
plus the full `Detail` of any `Failed` check) and system info (OS, `dotnet --list-runtimes`,
resolved `git`/`claude` messages, planned install dir/ports) into
`%TEMP%\claudedo-setup-diagnose.md` — English and hardcoded (an AI assistant reads it, not the
user) and deliberately excludes credentials/tokens/env-var dumps. `LaunchTerminal` then opens
`wt.exe -d %TEMP% cmd.exe /k <claude> <prompt>` (or `cmd.exe /k <claude> <prompt>` if `wt.exe`
isn't resolvable) via the injectable `IProcessLauncher`, pointing the initial prompt at that
report file. Errors from either step surface as `ClaudeHelpError` on the page, never an
exception. It reads the last report via `CheckListViewModel.LastReport`, so it re-evaluates on
every check run.
`DiagnosePage` (`Pages/DiagnosePage/`) hosts the same `CheckListView` in `SettingsWindow` (Config
mode only, `ShowInSettings = true` / `ShowInWizard = false`, `Order = 5` — after UiSettings).
Nothing here blocks navigation and checks do **not** auto-run on load — only on a "Recheck" click.
Unlike the wizard, its `InstallContext` is built from the **installed** configuration
(`InstallerWorkerConfig.Load()` for `ClaudeBin`/`SignalRPort`, the shared `InstallContext` for
`InstallDirectory`/`ExternalMcpPort`), refreshed on every `LoadAsync()` — not the wizard-default
`InstallContext` the DI container hands out, which is only populated once a page's `ApplyAsync`
(i.e. Save) runs. `DiagnosePage` intentionally has **no** "Claude Help Me" button — that one is
wizard-only.
@@ -0,0 +1,90 @@
<UserControl x:Class="ClaudeDo.Installer.Checks.CheckListView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Checks"
xmlns:loc="clr-namespace:ClaudeDo.Installer.Localization"
d:DataContext="{d:DesignInstance local:CheckListViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Check list -->
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type local:CheckRowViewModel}">
<Border Margin="0,0,0,6" Padding="10,8"
Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="1" CornerRadius="4">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Ellipse Grid.Column="0" Width="10" Height="10" Margin="0,0,10,0"
VerticalAlignment="Top"
Fill="{Binding StatusBrush}"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Message}" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap" Margin="0,2,0,0"/>
<TextBlock Text="{Binding Hint}" FontSize="11"
Foreground="{StaticResource TextMutedBrush}"
TextWrapping="Wrap" Margin="0,4,0,0"
Visibility="{Binding Hint, Converter={StaticResource NullToCollapsedConverter}}"/>
<Button Content="{Binding HelpUrl}"
HorizontalAlignment="Left" Margin="-16,4,0,0"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource AccentLightBrush}"
Cursor="Hand"
Command="{Binding DataContext.OpenHelpUrlCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding HelpUrl}"
Visibility="{Binding HelpUrl, Converter={StaticResource NullToCollapsedConverter}}"/>
</StackPanel>
</Grid>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Progress + summary -->
<StackPanel Grid.Row="1" Margin="0,8,0,0">
<ProgressBar IsIndeterminate="True" Margin="0,0,0,8"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisConverter}}"/>
<TextBlock Text="{Binding Summary}" FontSize="12" TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding HasBlockingError}" Value="True">
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
<!-- Footer -->
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="{loc:Tr installer.systemCheck.recheck}"
Command="{Binding RunChecksCommand}"/>
</StackPanel>
</Grid>
</UserControl>
@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Checks;
public partial class CheckListView : UserControl
{
public CheckListView() => InitializeComponent();
}
@@ -0,0 +1,139 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using System.Windows.Media;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Localization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Installer.Checks;
public sealed class CheckRowViewModel
{
public CheckRowViewModel(CheckResult result)
{
var loc = TrExtension.Localizer;
Status = result.Status;
Severity = result.Severity;
Title = loc?[result.TitleKey] ?? result.TitleKey;
Message = result.Message;
Hint = result.HintKey is not null ? loc?[result.HintKey] ?? result.HintKey : null;
HelpUrl = result.HelpUrl;
StatusBrush = ResolveBrush(Status, Severity);
}
public CheckStatus Status { get; }
public CheckSeverity Severity { get; }
public string Title { get; }
public string Message { get; }
public string? Hint { get; }
public string? HelpUrl { get; }
public Brush StatusBrush { get; }
private static Brush ResolveBrush(CheckStatus status, CheckSeverity severity)
{
var key = status switch
{
CheckStatus.Ok => "StatusGreenBrush",
CheckStatus.Unknown => "StatusGrayBrush",
CheckStatus.Failed when severity == CheckSeverity.Error => "StatusRedBrush",
CheckStatus.Failed => "StatusOrangeBrush",
_ => "StatusGrayBrush",
};
return Application.Current?.Resources[key] as Brush ?? Brushes.Gray;
}
}
/// <summary>
/// Shared presentation logic for the environment check list: running the check pass, tracking
/// busy/summary state, and the row collection. Composed (not subclassed) by every page that
/// hosts a check list — <see cref="Pages.SystemCheckPage.SystemCheckPageViewModel"/> and
/// <see cref="Pages.DiagnosePage.DiagnosePageViewModel"/> — so the run logic exists exactly once.
/// </summary>
public partial class CheckListViewModel : ObservableObject
{
private readonly InstallContext _context;
private readonly Func<EnvironmentCheckService> _checkServiceFactory;
public ObservableCollection<CheckRowViewModel> Rows { get; } = [];
/// <summary>Report of the most recent run, null before the first one. Hosts that need the raw
/// results (the wizard's "Claude Help Me" button) read it instead of running the checks again.</summary>
public EnvironmentCheckReport? LastReport { get; private set; }
[ObservableProperty] private bool _isRunning;
[ObservableProperty] private bool _hasRun;
[ObservableProperty] private bool _hasBlockingError;
[ObservableProperty] private string _summary = string.Empty;
public CheckListViewModel(InstallContext context, Func<EnvironmentCheckService> checkServiceFactory)
{
_context = context;
_checkServiceFactory = checkServiceFactory;
}
partial void OnIsRunningChanged(bool value) => RunChecksCommand.NotifyCanExecuteChanged();
[RelayCommand(CanExecute = nameof(CanRunChecks))]
private async Task RunChecksAsync()
{
if (IsRunning) return;
IsRunning = true;
Summary = TrExtension.Localizer?["installer.systemCheck.summary.running"] ?? "Checking your system…";
try
{
var service = _checkServiceFactory();
var report = await service.RunAllAsync(_context, CancellationToken.None);
Rows.Clear();
foreach (var result in report.Results)
Rows.Add(new CheckRowViewModel(result));
LastReport = report;
OnPropertyChanged(nameof(LastReport));
HasBlockingError = report.HasBlockingError;
Summary = BuildSummary(report);
HasRun = true;
}
finally
{
IsRunning = false;
}
}
private bool CanRunChecks() => !IsRunning;
private static string BuildSummary(EnvironmentCheckReport report)
{
var loc = TrExtension.Localizer;
var blocking = report.Results
.Where(r => r.Severity == CheckSeverity.Error && r.Status == CheckStatus.Failed)
.ToList();
if (blocking.Count > 0)
{
var names = string.Join(", ", blocking.Select(r => loc?[r.TitleKey] ?? r.TitleKey));
return loc?.Get("installer.systemCheck.summary.blocking", blocking.Count, names)
?? $"{blocking.Count} problem(s) must be fixed: {names}";
}
var warnings = report.Results.Count(r => r.Severity == CheckSeverity.Warning && r.Status == CheckStatus.Failed);
if (warnings > 0)
{
return loc?.Get("installer.systemCheck.summary.warnings", warnings)
?? $"{warnings} warning(s) found.";
}
return loc?["installer.systemCheck.summary.ok"] ?? "Everything looks good.";
}
[RelayCommand]
private static void OpenHelpUrl(string? url)
{
if (string.IsNullOrEmpty(url)) return;
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
}
@@ -0,0 +1,25 @@
namespace ClaudeDo.Installer.Checks;
public enum CheckSeverity { Warning, Error }
public enum CheckStatus { Ok, Failed, Unknown }
public sealed record CheckResult(
string Id,
CheckSeverity Severity,
CheckStatus Status,
string TitleKey,
string Message,
string? HintKey,
string? HelpUrl,
string? Detail)
{
public static CheckResult Ok(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) =>
new(id, severity, CheckStatus.Ok, titleKey, message, HintKey: null, HelpUrl: null, detail);
public static CheckResult Fail(string id, CheckSeverity severity, string titleKey, string message, string? hintKey = null, string? helpUrl = null, string? detail = null) =>
new(id, severity, CheckStatus.Failed, titleKey, message, hintKey, helpUrl, detail);
public static CheckResult Unknown(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) =>
new(id, severity, CheckStatus.Unknown, titleKey, message, HintKey: null, HelpUrl: null, detail);
}

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