fix:Improve Claude Mds

This commit is contained in:
Mika Kuns
2026-08-05 16:46:02 +02:00
parent f6cb8250bb
commit d15aa27707
9 changed files with 307 additions and 272 deletions
+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`.
+10
View File
@@ -10,6 +10,16 @@ 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 |
## Rules
- **Only stable structure.** Flows, responsibilities, entry points, invariants, relative
@@ -53,6 +53,34 @@ How a task moves Queued → Running → terminal, across `src/ClaudeDo.Worker`
- **Failed** FailAsync (Running/Queued → Failed).
- **Cancelled** CancelAsync (Running/Queued/WaitingForReview/WaitingForChildren → Cancelled).
## Model, effort & max-turns resolution
*(section added and verified at commit `f6cb825`, 2026-08-05)*
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/**
+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
+69 -28
View File
@@ -4,52 +4,93 @@ 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, HandlerBaseCommit / HandlerHeadCommit (nullable; review range for a worktree-less "list handler" host task — Mission Control's "Let Claude handle it" — which commits straight into the list's working dir instead of a per-task worktree: `HandlerBaseCommit` is stamped to the list repo's HEAD when the host task is created, `HandlerHeadCommit` when it's submitted for review; the Worker's `SubmitTaskForReview`/`GetTaskDiff` and the Ui's `DetailsIslandViewModel`/`MergeSectionViewModel` fall back to this pair whenever `Worktree` is null). Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
- **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit.
- Status / PlanningPhase / BlockedByTaskId 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).
- 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, SessionSkills, VerifyCommand (all nullable). `VerifyCommand` is an optional post-merge gate (see `ClaudeDo.Worker/CLAUDE.md` → TaskMergeService): null/blank = today's behavior, no gate.
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable; SHA of the merge commit this worktree's branch produced on the target branch, stamped by `TaskMergeService` the moment a merge/continue-merge succeeds — the only thing that makes `revert_merge` possible without heuristically searching `git log`; null for any worktree merged before this field existed), State (Active|Merged|Discarded|Kept)
- **TaskRunEntity** — per-run record (session_id, turns, result, structured output, exit code, log path, nullable `Model` — the model the run actually executed with, column `model`; token fields `TokensIn`/`TokensOut`/`CacheReadTokens`/`CacheWriteTokens`, columns `tokens_in`/`tokens_out`/`cache_read_tokens`/`cache_write_tokens` — populated from the session transcript, not the stream-json event, as a per-run delta against prior runs on the same `session_id`; see `ClaudeDo.Worker/CLAUDE.md` → Execution History)
- **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) , `ModelPresets` (string?, JSON array of `ModelPreset` rows, column `model_presets``AppSettingsRepository.GetAsync` backfills shipping defaults into this column on the first read after it's null, so it's never null once a run has started), `MaxTurnsCeiling` (int, default 80, column `max_turns_ceiling` — hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run starts; `AppSettingsRepository.UpdateAsync` clamps it to a minimum of 1), `UsageGateFiveHourPct` / `UsageGateSevenDayPct` (int, defaults 80/90, columns `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` — percentage of the 5h/7d Claude usage window at which the autonomous queue pauses; `0` = gate off; `AppSettingsRepository.UpdateAsync` clamps both to 0..100), and `UsageThrottleSoftPct` / `UsageThrottleHardPct` (int, defaults 50/65, columns `usage_throttle_soft_pct` / `usage_throttle_hard_pct` — intermediate staging thresholds below the hard gate above, at which the queue's effective parallelism steps down to 2 then 1 slot; see `Worker/CLAUDE.md``UsageThrottle`; `0` = that stage off; also clamped 0..100). `DefaultMaxTurns` default was lowered from 100 to 40 (entity default + `AddMaxTurnsCeiling` migration backfill on the seeded row).
- **ModelPresets** / **ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`): one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1200) and never throw — a malformed settings row must not stop a run. `For(presets, model, fallbackMaxTurns = 30)` always returns a usable row: `model` is resolved through `ModelRegistry.TryNormalizeAlias` first, so a full CLI model id (e.g. `claude-sonnet-4-6`) still hits its alias's preset row instead of missing every lookup and falling through; only a model that normalizes to nothing recognized falls back to a synthesized row (`EffortRegistry.DefaultLevel` + `fallbackMaxTurns` — callers pass `AppSettings.DefaultMaxTurns` here so that setting has a real effect instead of a hardcoded number). Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25.
- **ModelRegistry.TryNormalizeAlias** — non-throwing counterpart to `NormalizeAlias` for the run path: exact alias match, then substring match against a full model id, else `null`. Never throws, unlike `NormalizeAlias` (which stays the strict, throwing validator for `add_task`/planning model input).
- **EffortRegistry** — the `--effort` levels (`low|medium|high|xhigh|max`) + `NormalizeLevel` (blank → null = don't pass the flag)
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
- **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. |
| `UsageThrottleSoftPct` / `UsageThrottleHardPct` | `usage_throttle_*_pct` | 50 / 65 | Staged parallelism below the hard gate; `0` = that stage off. |
| `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`. |
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`, `SetMergedAsync` (atomically sets State=Merged and stamps MergeCommit in one update — the only writer of MergeCommit)
- **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`.
- **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), revert (`RevertMergeCommitAsync``git revert --no-edit -m 1 <sha>`, reverts a merge commit against its first parent; `RevertAbortAsync`; `IsMidRevertAsync``REVERT_HEAD` presence, mirrors `IsMidMergeAsync`'s `MERGE_HEAD`), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo. Revert never resets/rewrites — it always produces a new commit, because the working directory it operates on is shared with other concurrent sessions.
**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`. Migration `AddHandlerCommitRange` added the nullable `handler_base_commit`/`handler_head_commit` columns to `tasks`. Migration `AddUsageGateAndRunModel` added `app_settings.usage_gate_five_hour_pct`/`usage_gate_seven_day_pct` (defaults 80/90) and the nullable `task_runs.model` column. Migration `AddWorktreeMergeCommit` added the nullable `worktrees.merge_commit` column. Migration `AddVerifyCommand` added the nullable `list_config.verify_command` column. Migration `AddMaxTurnsCeiling` added `app_settings.max_turns_ceiling` (default 80) and lowered `default_max_turns`'s default/seeded value from 100 to 40. Migration `AddRunCacheTokens` added the nullable `task_runs.cache_read_tokens`/`cache_write_tokens` columns. Migration `AddUsageThrottleThresholds` added `app_settings.usage_throttle_soft_pct`/`usage_throttle_hard_pct` (defaults 50/65). `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: `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`), and
`blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`).
## 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
+11 -8
View File
@@ -21,13 +21,11 @@ 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`)
@@ -82,7 +80,12 @@ Non-fatal if `claude` CLI is missing or too old (prints the manual command). Ser
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; it is dropped after a successful install, a bad download is deleted immediately, and zips of other versions are pruned. `app\`/`worker\` are stashed to `*.bak` before extraction and restored if extraction fails.
**`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
+67 -35
View File
@@ -2,17 +2,22 @@
Avalonia UI layer: views, viewmodels, converters, and the SignalR client.
Deeper detail: [review-merge](../../docs/explore-notes/review-merge.md) (diff stack + conflict
resolver) · [conpty-sessions](../../docs/explore-notes/conpty-sessions.md) (Mission Control
tiles) · [usage-monitoring](../../docs/explore-notes/usage-monitoring.md) (usage pill + modal).
## Pattern
MVVM with CommunityToolkit.Mvvm source generators:
- `[ObservableProperty]` for bindable properties
- `[RelayCommand]` for commands (supports async and CanExecute)
- `[ObservableProperty]` for bindable properties, `[RelayCommand]` for commands
- All ViewModels inherit `ViewModelBase` (extends `ObservableObject`)
- All views use compiled bindings (`x:DataType`)
## Layout: Islands
`MainWindow` hosts three "islands" (lists | tasks | details). There is no MainWindowViewModel, StatusBarView, or task/list editor modal — the root coordinator is **IslandsShellViewModel**, and task/list editing happens inline in the islands.
`MainWindow` hosts three "islands" (lists | tasks | details). There is **no**
MainWindowViewModel, StatusBarView, or task/list editor modal — the root coordinator is
`IslandsShellViewModel`, and task/list editing happens inline in the islands.
```
ViewModels/
@@ -23,52 +28,79 @@ ViewModels/
Modals/ — About, DiffViewer (+ DiffModels), ListSettings, Merge, MergeHelperSelection,
RepoImport, Settings (+ Settings/ tab VMs), UnfinishedPlanning, WeeklyReport,
WorkerConnection, WorktreesOverview, UnifiedDiffParser
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge, AgentConfigEditor
Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyles.axaml
(component styles + the filled icon geometry library)
Conflicts/ — ConflictResolverViewModel + ConflictModels
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge,
AgentConfigEditor
Design/ — Tokens.axaml (design tokens; merged before styles)
+ IslandStyles.axaml (component styles + the filled icon geometry library)
```
## ViewModels
## Core ViewModels
- **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay via `OpenLogVisualizerCommand`; `FlashFooterError` surfaces UI-action failures + the worker's Serilog Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help, LogVisualizer) plus `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
- **IslandsShellViewModel** — root coordinator. Owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay; `FlashFooterError` surfaces UI-action failures + the worker's Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, the modal openers, and `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RefineTask`, `OpenConPtySessionRequested` (embedded ConPTY terminal), `ToggleManual` (per-task manual flag) and `SyncInteractiveSessions` (mirrors Mission Control's open ConPTY panes onto the rows); MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards) — the ROADBLOCK card also has a reply field (`RoadblockReplyDraft`/`SendRoadblockReplyCommand`, gated by `CanReplyToRoadblock` on `LatestRunSessionId`) that resumes the session via the same `ContinueTaskAsync` transport as `ContinueCommand` but with the user's own text instead of the fixed re-run prompt; failures raise `ErrorReported`, wired by the shell into `FlashFooterError`, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and fire the `DiffViewed` callback; `HasReviewableDiff` reports whether anything is inspectable, feeding the review gate), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand``RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; plus `IsManual` (→ MANUAL badge; suppresses `CanSendToQueue`/`CanRefine`/`CanOpenPlanningSession`) and `HasInteractiveSession` (→ accent "Interactive" chip instead of "Parked"; tapping it jumps to that Mission Control pane); list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints, `IsManual`).
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
- **UsagePillViewModel** — one shared instance backs the `UsagePill` control hosted in both the footer and the Mission Control header; loads via `GetUsageSnapshotAsync` and updates live off `IWorkerClient.UsageUpdatedEvent`; derives display text, tooltip, and dot state (normal/warn/stale/blocked, mutually exclusive priority blocked > stale > warn > normal) from the shared `UsageSnapshotDto`. `IsThrottled` (effective slots below configured, and not gate-blocked) adds a tooltip line naming the effective/configured slot count and the decisive bucket (`ThrottleBucket` on the DTO — `"five_hour"`/`"seven_day"`).
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows). General hosts the per-model preset table (`ModelPresets``ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field, `ListSettingsModalViewModel` (name, working dir, commit type, "manual list" flag, `VerifyCommand` (optional post-merge verify gate, own field/section — not part of `AgentConfigEditorViewModel`), delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync(verifyCommand)`, since both fields land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `MergeHelperSelectionModalViewModel` ("Let Claude handle it": checkbox picker over one list's non-terminal, non-manual tasks, pre-ticks 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 first calls `IWorkerClient.CreateMergeHelperTaskAsync` to create one new ClaudeDo task per run in that list — `Idle`/`IsManual=true` (never queued), title/description localized (`missionControl.mergeHelperTaskTitle`/`mergeHelperTaskDescriptionHeader`), `TaskEntity.HandlerBaseCommit` stamped to the list repo's current HEAD — then opens a **task-based** ConPTY tile for it (deduped by `TaskId` like `OpenConPtySessionAsync`, not `CreateAdHoc`) running the five-phase handler prompt. The handler still merges the tasks it handles itself; the host task never gets a worktree of its own, so "Submit for review" stamps `HandlerHeadCommit` instead of committing a worktree, and the detail pane's `MergeSectionViewModel` falls back to `HandlerBaseCommit`/`HandlerHeadCommit` over the list's working dir for its diff — see `TaskEntity` in `ClaudeDo.Data/CLAUDE.md`), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`), `UsageMonitorModalViewModel` (opened from the usage pill; renders one gauge per row in `UsageSnapshotDto.Limits`**dynamic**, since the fixed `seven_day_opus`/`seven_day_sonnet`-style buckets the raw Anthropic API can return are plan-dependent and come back `null` on plans that don't have them, so a fixed gauge layout would break; also shows model usage (`GetModelUsageAsync`, ClaudeDo-vs-Other split per model) and top-task usage (`GetTaskUsageAsync`) tables over a 7d/30d preset or custom date range).
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModels.cs` holds shared types: `DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`, `DiffTreeNodeViewModel`, `DiffTree`. `DiffViewerViewModel` is a single unified read-only diff viewer with two modes: **Files** (dirty worktree / branch-vs-base / commit-range — loads via GitService, shows a folder file-tree on the left + per-file diff pane on the right, Merge button for live branch source) and **Planning** (per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat diff right, combined integration-branch toggle). The Merge button opens the merge form, which routes to `ConflictResolverViewModel` on conflict. `DiffLinesView` renders per-file diff content with binary/empty placeholders.
- **Conflicts** — `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (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-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **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). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, 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; a `FilesSummary` readout shows how many files still have conflicts, and 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) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions; planning session lifecycle; `RefineTask`, `OpenConPtySessionRequested`, `ToggleManual`, `SyncInteractiveSessions`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row. Raises `NotesRequested`/`PrepRequested` for the shell.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, the session-outcome/roadblock split, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` + `ChildrenNeedingAttention` drive an attention band on the Session tab), attachments, and the modes `IsNotesMode`/`IsPrepMode`/computed `IsTaskDetailVisible`. Failures raise `ErrorReported`, wired by the shell into `FlashFooterError`.
- Three concerns are extracted into section VMs exposed as properties: `AgentSettings` (`AgentConfigEditorViewModel`, scope=Task), `MergeSectionViewModel`, `PrepPanelViewModel`. Helper rows live in the same file.
- The ROADBLOCK card's reply field (`RoadblockReplyDraft`/`SendRoadblockReplyCommand`, gated by `CanReplyToRoadblock` on `LatestRunSessionId`) resumes the session over the same `ContinueTaskAsync` transport as `ContinueCommand`, but with the user's own text.
- Attachments write directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`; `ComposedPreview` includes attachment paths.
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs. Task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`, plus `IsManual` (→ MANUAL badge; suppresses `CanSendToQueue`/`CanRefine`/`CanOpenPlanningSession`) and `HasInteractiveSession` (→ accent "Interactive" chip instead of "Parked"; tapping jumps to that Mission Control pane). List row: kind Smart/Virtual/User, count, icon/dot keys, drop hints, `IsManual`.
- **NotesEditorViewModel** — day navigator + bullet CRUD via `INotesApi`.
- **UsagePillViewModel** — one shared instance backs the `UsagePill` in both the footer and the Mission Control header → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
## Modal VMs
| VM | Notes |
|---|---|
| `SettingsModalViewModel` | Four tabs: General, Worktrees, Files (prompt paths), Prime Claude. General hosts the per-model preset table (`ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field. |
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
| `UsageMonitorModalViewModel` | Opened from the usage pill; gauges are **dynamic** per `UsageSnapshotDto.Limits` row. |
Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
`UnfinishedPlanningModalViewModel`, `LogVisualizerViewModel` (last 30 min, all levels + a
warn/error filter), `WorkerConnectionModalViewModel`, `AboutModalViewModel`.
## Diff & Conflicts
`UnifiedDiffParser` (static) + `DiffModels.cs` shared types + `DiffViewerViewModel` (one unified
read-only viewer, Files and Planning modes) + `DiffLinesView`.
`ConflictResolverViewModel` is an in-app Rider-style 3-pane AvaloniaEdit merge editor for both
single-task and planning unit-merge conflicts. Full detail →
[review-merge](../../docs/explore-notes/review-merge.md).
## Services
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface tracks `WorkerHub` (see `src/ClaudeDo.Worker/CLAUDE.md` for the canonical method/event list); groups: task execution (RunNow/Cancel/Continue/Reset/SetTaskStatus), review (`ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, reject-to-queue/idle, cancel review, `PreviewMergeAsync -> MergePreviewDto`), planning sessions (start/resume/discard/finalize, queue subtasks, pending draft count, refine), embedded ConPTY launch specs (`GetInteractiveLaunchSpecAsync`/`GetAdHocLaunchSpecAsync`), planning aggregate/integration-branch diffs, unit-merge continue/abort, single-task conflict resolving (start/get-conflict-documents/write-resolution/continue/abort), worktrees (overview, set state, force remove, cleanup, reset all), agents, app settings, lists/config, weekly report, daily notes, daily prep (`RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`), prime schedules, recent worker logs (`GetRecentLogsAsync`), usage monitoring (`GetUsageSnapshotAsync -> UsageSnapshotDto?`, `GetModelUsageAsync(from, to)`, `GetTaskUsageAsync(from, to)`). Events mirror `HubBroadcaster` (task/worktree/list/run updates, prep events, planning-merge events, refine events, worker log, `UsageUpdatedEvent`). Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
- **INotesApi** / **WorkerNotesApi** — daily-note CRUD (`ListAsync(day)`, `AddAsync`, `UpdateAsync`, `DeleteAsync`); UI DTO `DailyNoteDto(Id, Date, Text, SortOrder)`.
- **IPrimeScheduleApi** — prime-schedule CRUD (`ListAsync`, `UpsertAsync`, `DeleteAsync`).
- **UpdateCheckService** — polls releases, exposes `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` (feeds the shell's update banner).
- **WorkerClient / IWorkerClient** — SignalR client on `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface **tracks `WorkerHub`** — treat `src/ClaudeDo.Worker/Hub/WorkerHub.cs` as the canonical method list rather than duplicating it here. Events mirror `HubBroadcaster`. Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
- **INotesApi / WorkerNotesApi** — daily-note CRUD; UI DTO `DailyNoteDto(Id, Date, Text, SortOrder)`.
- **IPrimeScheduleApi** — prime-schedule CRUD.
- **UpdateCheckService** — polls releases; `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` feed the shell's update banner.
- **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges.
- **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**.
## Converters
`StatusColorConverter` (+ `ConnectionColorConverter` in the same file), `WorkerLogLevelToBrushConverter`, `DotBrushConverter`, `EqStatusConverter`, `IconKeyConverter`, `CheckboxBorderConverter`, `StrikeIfTrueConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter`, `NotNullToBoolConverter`, `UpperCaseConverter`, `DateOnlyToDateTimeConverter`.
In `Converters/` — grep rather than list: status/connection colors, log-level brush, dot brush,
status equality, icon key, checkbox border, strike/italic/opacity toggles, null→bool,
uppercase, `DateOnly``DateTime`.
## Dialog Pattern
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog sets the result on save/cancel, and the caller awaits the TCS.
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog
sets the result on save/cancel, the caller awaits the TCS.
## Notes
## Gotchas
- Context menus exist on both list rows and task rows; right-click selects before opening the menu
- "Run Now" CanExecute re-evaluates when worker connection state changes
- Icon gotcha: `PathIcon` fills geometry. Line-art/stroke icons must be defined as filled geometry or rendered as a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style); a pure stroke path used with `PathIcon` is invisible.
- Window key bindings live on `MainWindow`: `Ctrl+K` focuses search, `Ctrl+N` the add-task box. Do **not** bind bare punctuation gestures — `OemQuestion` used to hold search focus and silently swallowed `#` app-wide on a German layout.
- `FocusClearing` also clears focus from a TextBox on Escape, mirroring its click-outside behavior — but the KeyDown handler is scoped to `MainWindow` specifically (`AddClassHandler<MainWindow>`, not `<TopLevel>`). Modal windows (`AboutModalView` etc.) each bind their own `Window.KeyBindings` Escape → close; since modals are separate `Window` instances, this handler never runs there, so Escape still closes them unchanged. Mission Control's ConPTY tiles (`InteractiveTerminalView`) live in `MissionControlWindow`, also unaffected — Escape always reaches the PTY there.
- `Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner: used for a starting ConPTY pane (`InteractiveTerminalViewModel.IsStarting`) and in place of the refine button while `TaskRowViewModel.IsRefining`.
- `ConPtyPaneViewModel` resolves its own launch spec (ctor takes a descriptor **factory**; the host wires handlers and then calls `Start()`), so the Mission Control tile appears immediately with its spinner while the worker is still preparing the worktree. A failed launch keeps the tile with its inline error banner instead of never appearing.
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) used for both the task `Log` and the prep `PrepLog`.
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" hover overlay. `DescriptionStepsCard` shows an Attachments list (file name, size, remove button), an "Add file…" picker, and an explicit `DropStatus` confirmation line. Keys use the `details.attachments.*` localization namespace (en + de).
- **`PathIcon` *fills* its geometry.** Line-art/stroke icons must be authored as filled geometry or rendered with a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style). A pure stroke path in a `PathIcon` is **invisible**.
- **Never bind bare punctuation gestures.** Window key bindings live on `MainWindow` (`Ctrl+K` search, `Ctrl+N` add-task). `OemQuestion` once held search focus and silently swallowed `#` app-wide on a German layout.
- **`FocusClearing`'s Escape handler is scoped to `MainWindow`** (`AddClassHandler<MainWindow>`, not `<TopLevel>`) — it clears focus from a TextBox on Escape, mirroring click-outside. Modals are separate `Window` instances that bind their own Escape → close, so it never runs there. Mission Control's ConPTY tiles are in `MissionControlWindow`, also unaffected, so **Escape always reaches the PTY**.
- **Review gate:** Approve & Merge stays disabled until the diff has been opened once, and re-locks per run → [review-merge](../../docs/explore-notes/review-merge.md).
- Context menus exist on both list and task rows; right-click selects before opening the menu.
- "Run Now" CanExecute re-evaluates when worker connection state changes.
- `Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner (starting ConPTY pane, refining task row).
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) used for both the task `Log` and the prep `PrepLog`.
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" overlay; `DescriptionStepsCard` shows the attachments list, an "Add file…" picker, and an explicit `DropStatus` line. Keys use the `details.attachments.*` locale namespace (en + de).
+108 -177
View File
@@ -2,112 +2,71 @@
ASP.NET Core hosted service that executes tasks via Claude CLI in isolated environments.
**Deeper detail lives in `docs/explore-notes/`** — read the matching note before deep-exploring:
[worker-task-pipeline](../../docs/explore-notes/worker-task-pipeline.md) ·
[usage-monitoring](../../docs/explore-notes/usage-monitoring.md) ·
[external-mcp](../../docs/explore-notes/external-mcp.md) ·
[review-merge](../../docs/explore-notes/review-merge.md) ·
[conpty-sessions](../../docs/explore-notes/conpty-sessions.md)
## Folder Layout
```
Worker/
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService, RunCancellationRegistry (taskId → running-run CTS; lets TaskStateService.CancelAsync kill the process of a cancelled task/child without a DI cycle)
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, VerifyCommandRunner (IVerifyCommandRunner — spawns a list's optional post-merge verify command via `cmd.exe /c`), ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery, AttachmentOrphanRecovery (startup sweep: deletes any `attachments/<taskId>/` dirs whose task no longer exists), PromptFileRecovery (startup sweep: `PromptFiles.ReconcileStaleDefaults()` drops any prompt override that only matched a now-superseded default and was never actually edited, `QuarantineOrphans()` moves *.md files under `prompts/` with no matching `PromptKind` into `prompts/_orphans/`)
Queue/ — IQueueWaker, IQueuePicker, QueueService, OverrideSlotService, RunCancellationRegistry
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, VerifyCommandRunner,
ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery,
AttachmentOrphanRecovery, PromptFileRecovery (last four = startup sweeps)
Worktrees/ — WorktreeMaintenanceService
Agents/ — AgentFileService, DefaultAgentSeeder
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalLauncher (ITerminalLauncher) — wt launcher for planning sessions
Refine/ — RefineRunner + RefinePrompt (hub `RefineTask`; broadcasts RefineStarted/RefineFinished)
External/ — ExternalMcpService + sibling tool classes
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/Context/TokenRegistry (in-task MCP)
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService,
PlanningMergeOrchestrator, PlanningAggregator, InteractiveLaunchSpecService,
session-context/token-auth types, WindowsTerminalLauncher (ITerminalLauncher)
Refine/ — RefineRunner + RefinePrompt (hub `RefineTask`)
External/ — ExternalMcpService + sibling tool classes (always-on MCP for general sessions)
Config/ — WorkerConfig
Hub/ — WorkerHub, HubBroadcaster
Logging/ — LogRingBuffer (30-min in-memory log window) + BroadcastLogSink (Serilog sink → footer + overlay)
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (OIDC discovery + refresh-token flow), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
Usage/ — OAuth usage monitor: UsageModels (UsageBucket/UsageLimitRow/UsageSnapshot), ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at `~/.claude/.credentials.json`, calls `GET https://api.anthropic.com/api/oauth/usage`; defensive parsing — missing/null buckets → null, missing `limits` → empty list; never logs the token), UsageState (threadsafe singleton; a failed poll never overwrites the last good snapshot, only sets `LastError`), UsageMonitorService (BackgroundService, polls on `usage_poll_interval_seconds`, one poll at startup, logs a failure at most once per distinct error message, broadcasts `HubBroadcaster.UsageUpdated` after every tick via `UsageSnapshotBuilder`), UsageSnapshotBuilder (builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings` thresholds — the one place `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` share the stale/threshold/gate logic), TranscriptUsageReader (aggregates Claude Code transcript token usage from `~/.claude/projects/**/*.jsonl` by date/model/scope (ClaudeDo vs Other), deduped by requestId, with a per-file length+mtime cache; `<synthetic>`-model lines are skipped everywhere, not real API calls; also exposes `ReadSessionTotalsAsync(sessionId)` — cumulative raw input/output/cache-read/cache-write totals for one session, located by `{sessionId}.jsonl`, used by `TaskRunner` to populate `task_runs`' per-run token fields), UsageGate (reads `UsageState` + `AppSettings.UsageGateFiveHourPct`/`UsageGateSevenDayPct`, returns a `UsageGateDecision(IsBlocked, Reason)`; `Utilization` from `UsageBucket` is already a 0100 percent, compared directly against the threshold with `>=`; threshold `0` = that bucket never gates; fail-open — no snapshot yet, a failed last poll, or a settings-read error all resolve to not-blocked), UsageThrottle (pure static `EffectiveSlots(configuredSlots, fiveHourPct, sevenDayPct, softPct, hardPct, gateFiveHourPct, gateSevenDayPct)` — stages parallelism down ahead of the hard gate: whichever bucket is more utilized decides the stage, `0` for a threshold disables that stage, `>= softPct` caps at 2 slots, `>= hardPct` caps at 1, `>= either gate threshold` returns 0 — same 0 as `UsageGate`'s hard block, kept in sync since both read the same gate thresholds); interfaces in Usage/Interfaces/ (IUsageClient, ITranscriptUsageReader, IUsageGate)
Logging/ — LogRingBuffer (30-min window) + BroadcastLogSink (Serilog → footer + overlay)
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService
Prime/ — daily prep ("Prime Claude"): PrimeScheduler, PrimeRunner, DailyPrepPrompt,
NextDueCalculator, PrimeScheduleSignal
Online/ — optional Online Inbox sync (off by default; zero network when disabled)
Usage/ — OAuth usage monitor, gate, throttle, transcript token reader
```
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
Interfaces (`IQueueWaker`, `IPrimeClock`, `ITaskStateService`, …) live in an `Interfaces/`
subfolder within their area; the namespace stays the area namespace.
## Architecture
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
- **TaskStateService** — only component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions.
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`. On each loop tick, `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 — never throttles on a broken poll); a stage change (not every tick) logs once via the standard logger. Separately, it also asks `IUsageGate.EvaluateAsync` — if blocked, the slot-fill loop is skipped entirely for that tick (already-running slots are untouched in either case; `RunNow`/`ContinueTask`/interactive/planning/daily-prep all bypass the queue and are unaffected). A blocked↔free transition is logged/broadcast (`WorkerLog`, Warn on block / Info on resume) exactly once per change, not on every tick; the 30 s backstop timer re-evaluates both the throttle and the gate on its own even with no wake signal, so the queue self-recovers once usage drops back under the threshold.
- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions.
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0` and schedule; QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." **No external tool returns bare `Task` or a nullable payload directly** — an MCP client can't tell an empty/omitted response apart from a dropped one. Write tools return a small confirmation record (`{ ok/deleted/removed/reset/started: true, <id>, ... }`, e.g. `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`, pre-existing `BatchGetTaskResult`, `TaskLogResult`) instead of returning null outright. Organized by concern:
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued` / `Cancelled` / `Done``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, since a child parked back to `Idle` mid-chain is a manual opt-out signal; `Done` goes through `TaskStateService.ForceSetStatusAsync` — same unconditional write the UI's "set status freely" affordance uses — but is refused here with an active-worktree task, since that would skip `review_task`'s merge), `GetTaskStatusValues`, `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 reported in the `ReviewTaskResult`), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`, `PreviewMerge` (non-destructive `git merge-tree --write-tree` mergeability check for one task's worktree branch against `targetBranch`, default the repo's current branch — status/conflictFiles/changedFileCount plus `behind`; throws a clear error instead of TaskMergeService.PreviewAsync's silent "unavailable" when the task has no worktree, the worktree isn't Active, or the list's working dir is missing), `PreviewMergeSet` (same preview for a batch of task ids plus a file→tasks overlap report built from each task's own diff-stat — a same-file-name hint only, blind to cross-file collisions like 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` — a new commit, never a reset/rewrite, since the target working directory is shared with other 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 ever left in the tree), `ListWorktrees`, `CleanupTaskWorktree`
- `BatchMcpTools` — best-effort batch variants that loop the `ExternalMcpService` single-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task): `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees`. 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.
- `ListMcpTools``CreateList`, `UpdateList`, `DeleteList`
- `ConfigMcpTools``GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`
- `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
- `AgentMcpTools``ListAgents`
- `LifecycleMcpTools``ResetFailedTask`
- `AppSettingsMcpTools``GetAppSettings` (read-only; includes `MaxParallelExecutions`)
- `TaskWaitMcpTools``WaitForTaskChange(taskIds, timeoutSeconds = 60)`: blocks until any given task leaves `Queued`/`Running`, or times out; returns immediately for a task already outside `Queued`/`Running` (unknown ids reported as status `"NotFound"`, also immediate). Implemented as an async DB poll (short-lived `DbContext` per check, 500ms delay between checks, no held connection or busy loop) rather than hooking `HubBroadcaster` — kept deliberately isolated so it can't regress the existing broadcast callers. `timeoutSeconds` is clamped server-side to `TaskWaitMcpTools.MaxTimeoutSeconds` (170s), comfortably under the list handler's `MCP_TOOL_TIMEOUT` (200s, see `InteractiveLaunchSpecService`) so the tool reports `timedOut: true` instead of racing the client's own abort. Replaces the list handler's old "sleep + poll get_task in a loop" Phase 3 instruction (`PromptFiles.MergeHelperDefault`).
- `AttachmentMcpTools``AddTaskAttachment(taskId, fileName, textContent?|base64Content?)`, `ListTaskAttachments`, `RemoveTaskAttachment`. Re-attaching the same fileName overwrites; add/remove refuse on a Running task.
- `ExternalMcpService` also exposes two daily-prep tools:
- `GetDailyPrepCandidates` — returns 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 (same file).
- `SetMyDay` — sets a task's `IsMyDay` (+ optional `SortOrder`); server-side cap-guard rejects turning on MyDay beyond `DailyPrepMaxTasks` open (Idle) MyDay tasks.
## Daily Prep (Prime Claude)
- **PrimeScheduler** (hosted `BackgroundService`) computes the next due time from the `prime_schedules` table and at that time calls `IPrimeRunner.FireAsync`. A manual run arrives via `WorkerHub.RunDailyPrepNow`. A `SemaphoreSlim` single-flight gate **in `PrimeRunner`** prevents overlapping runs (returns "already running"); both scheduled and manual runs go through it.
- **PrimeRunner** builds a fixed prompt via `DailyPrepPrompt.BuildPrompt`, parameterized by `AppSettings.DailyPrepMaxTasks` and today's date, then invokes:
```
claude -p --output-format stream-json --verbose --permission-mode acceptEdits --max-turns 30
--allowedTools mcp__claudedo__get_daily_prep_candidates mcp__claudedo__set_my_day
```
It relies on the globally-registered `claudedo` MCP (installer's `RegisterMcpStep`) — no separate `--mcp-config`. This replaced the old warm-up "ping".
- Each stdout line is streamed to the UI via `IPrimeBroadcaster.PrepLineAsync` AND written to `DailyPrepPrompt.LogPath()` = `<appdata>/logs/daily-prep.log` (truncated at the start of each run → last run only). `PrepStarted`/`PrepFinished` events bracket the run.
- Agentic behaviour: Claude calls `get_daily_prep_candidates`, picks an effort-aware subset capped at `DailyPrepMaxTasks`, and marks them via `set_my_day` (which broadcasts `TaskUpdated` so the UI updates live).
## Usage Monitor & Gate
Source: `GET https://api.anthropic.com/api/oauth/usage`, an **undocumented** Anthropic API,
authenticated with the Bearer access token Claude Code itself keeps fresh at
`~/.claude/.credentials.json` — ClaudeDo reads that token but never refreshes it and never
logs it. `UsageMonitorService` polls on `usage_poll_interval_seconds` (default 60s, one poll
at startup too) and broadcasts `HubBroadcaster.UsageUpdated` after every tick.
The gate (`IUsageGate`, thresholds `usage_gate_five_hour_pct`/`usage_gate_seven_day_pct`)
pauses **only** the queue's slot-fill loop (new tasks don't start) once `five_hour >=
usage_gate_five_hour_pct` or `seven_day >= usage_gate_seven_day_pct`; already-running runs,
and `RunNow`/`ContinueTask`/interactive ConPTY/planning sessions/daily-prep all bypass the
queue and are unaffected. **Fail-open**: no snapshot yet, a failed last poll, or an
app-settings read error all resolve to not-blocked, so an outage of the (undocumented, can
change without notice) usage endpoint never blocks work. There's no persistent pause state —
recovery is just the queue's 30s backstop timer re-evaluating the gate on its own once usage
drops back under the threshold. See `Usage/` in the folder layout above for the component
breakdown.
Ahead of that hard gate, `UsageThrottle` steps the queue's effective parallelism down in two
stages (thresholds `usage_throttle_soft_pct`/`usage_throttle_hard_pct`, defaults 50/65):
whichever of 5h/7d is more utilized decides the stage — below soft = full configured
`max_parallel_executions`, at/above soft = capped to 2 slots, at/above hard = capped to 1,
at/above either gate threshold = 0 (the pre-existing hard pause, unchanged). Only *new* slot
fills are affected; a run already occupying a slot when the stage tightens keeps running to
completion. Same fail-open policy as the gate — no snapshot yet means no throttling, full
configured parallelism. The effective stage (configured vs. effective slots, decisive bucket)
rides along on `UsageSnapshotDto`/`GetUsageSnapshot` for UI display (`UsagePillViewModel`
tooltip, `UsageMonitorModalViewModel`'s throttle band) — it does not change what the gate
itself gates on.
- **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Two hard conventions** (both test-enforced): every optional parameter needs a C# default value, and no tool returns bare `Task`/a nullable payload. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md).
## Status Model
`TaskEntity` carries three orthogonal fields. Lifecycle, planning hierarchy, and chain blocking are no longer conflated.
`TaskEntity` carries orthogonal fields — lifecycle, planning hierarchy, and chain blocking are
not conflated.
| Field | Values | Meaning |
|---|---|---|
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work is done, waiting on its children. |
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work done, waiting on children. |
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with `BlockedByTaskId != NULL` is skipped by the picker. |
| `IsManual` | bool | Reminder only the user can do. `TaskStateService.EnqueueAsync`/`StartRunningAsync` refuse it, the queue picker skips it, and `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. |
| `ReviewFeedback` | nullable string | Reviewer's rejection comment. Set by `RejectToQueueAsync`; consumed and cleared by `QueueService` on the next re-run (resumes the Claude session with it as the next-turn prompt). |
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. |
| `IsManual` | bool | Reminder only the user can do. `EnqueueAsync`/`StartRunningAsync` refuse it, the picker skips it, `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. |
| `ReviewFeedback` | nullable string | Reviewer's rejection comment; consumed and cleared by `QueueService` on the next re-run. |
Allowed transitions (enforced by `TaskStateService`):
```
Idle → Queued | Running (RunNow) | Cancelled (external update_task_status only, allowFromIdle: true)
Queued → Running | Cancelled | Idle | Failed (OverrideSlotService preflight gap: RunAsync can fail before StartRunningAsync is called)
Queued → Running | Cancelled | Idle | Failed (OverrideSlotService preflight gap)
Running → WaitingForReview (standalone success, no children)
| WaitingForChildren (parent with pending children)
| Done (planning/improvement child success) | Failed | Cancelled
@@ -119,134 +78,106 @@ Cancelled → Idle | Queued
```
**Unified parent model.** Every parent — planning *or* improvement — flows
`… → WaitingForChildren → WaitingForReview → Done`, advanced by the single
`TaskStateService.TryAdvanceParentAsync` (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** still go straight to `Done` (no individual
review) — only the parent is reviewed.
`… → WaitingForChildren → WaitingForReview → Done` via the single `TryAdvanceParentAsync`.
Planning/improvement **children** go straight to `Done`; only the parent is reviewed.
**Approve = merge the whole unit.** `ApproveReview`/`review_task` approve, for a task
that has children, drives `PlanningMergeOrchestrator` (merges the parent worktree if
Active + each `Done` child in order, sets the parent `Done`, and on a mid-merge
conflict pauses for `ContinuePlanningMerge`/`AbortPlanningMerge`). Childless tasks use
`TaskMergeService.ApproveAndMergeAsync`. There is no separate "Merge all" entry —
approve is the single review+merge action.
**Post-merge verify gate.** A list can set `ListConfigEntity.VerifyCommand` (List Settings
modal → Verification). Null/blank (the default) = no gate, behavior is bit-identical to
before this 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`. Exit 0 → unchanged flow (worktree marked
`Merged`, task `Done` if it was `WaitingForReview`). Non-zero exit or a 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` and `MergeResult.Status` comes back
`TaskMergeService.StatusVerifyFailed` (`"verify_failed"`) with an output excerpt in
`ErrorMessage` — this flows through `MergeResultDto` (hub) and `ReviewTaskResult`
(`review_task` MCP tool) unchanged, since both already treat any non-`blocked`/`conflict`
status generically. 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. Review transitions live in `TaskStateService`
(`SubmitForReviewAsync`, `SubmitForChildrenAsync`, `ApproveReviewAsync`,
`RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync`).
**Approve = merge the whole unit.** `ApproveReview` / `review_task` approve is the single
review+merge action — there is no separate "Merge all". Details, plus the post-merge verify
gate and the conflict resolver → [review-merge](../../docs/explore-notes/review-merge.md).
## Planning Flow
`PlanningSessionManager.FinalizeAsync` is the single path:
1. `_state.FinalizePlanningAsync(parent)` flips parent `PlanningPhase` to `Finalized` and sets `Status` to `WaitingForChildren` (or `WaitingForReview` if the parent has no children).
2. `PlanningChainCoordinator.SetupChainAsync(parent, enqueue: false)` establishes the blocked-by chain (`BlockOn`s child[i] → child[i-1]) but **leaves children `Idle`** — finalize never auto-queues. Queueing is a deliberate user action: `QueuePlanAsync` (hub `QueuePlanningSubtasksAsync`, the "Queue plan" button) calls `SetupChainAsync(parent, enqueue: true)`, which sets every non-terminal child `Queued` and re-applies the chain.
1. `_state.FinalizePlanningAsync(parent)` flips `PlanningPhase` to `Finalized` and `Status` to `WaitingForChildren` (or `WaitingForReview` if childless).
2. `PlanningChainCoordinator.SetupChainAsync(parent, enqueue: false)` establishes the blocked-by chain (child[i] → child[i-1]) but **leaves children `Idle`** — finalize never auto-queues. Queueing is a deliberate user action: `QueuePlanAsync` (hub `QueuePlanningSubtasksAsync`, the "Queue plan" button) calls `SetupChainAsync(parent, enqueue: true)`.
3. Once queued, the first child is woken automatically; successors unblock as their predecessor reaches a terminal state via `OnChildFinishedAsync`.
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 child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED`) does **not** advance the
parent — it stays in `WaitingForChildren` until every child is terminal, with blocked children
surfaced on the parent's Session tab instead.
`TaskRepository.FinalizePlanningAsync` no longer exists. The `Mark*Async` repository helpers are `internal` — only `TaskStateService` calls them.
`TaskRepository.FinalizePlanningAsync` no longer exists; the `Mark*Async` repository helpers are
`internal` — only `TaskStateService` calls them.
## Task Execution Pipeline
`TaskRunner` orchestrates:
1. Load task + list metadata from DB; resolve config from `list_config` + task-level overrides (model, system_prompt, agent_path)
2. Create worktree (if `WorkingDir` set) or sandbox directory
3. Mark task "running", broadcast `TaskStarted`
4. Resolve the effective model (task → list → `AppSettings.DefaultModel`), then take its `ModelPresets` row via `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; only a truly unrecognized model falls back to a synthesized row using `AppSettings.DefaultMaxTurns` (never a hardcoded number, and never throws — an unknown model must not block a run). The preset supplies `--effort` and the **global** max-turns default (task/list `MaxTurns` overrides still win). The task/list/global-resolved value is then hard-clamped to `AppSettings.MaxTurnsCeiling` (default 80) via `TaskRunner.ResolveMaxTurns` — a task or list override above the ceiling still starts, just capped, and a Warn is logged with the task id, requested, and effective value. Build CLI args via `ClaudeArgsBuilder`; inject attachment absolute paths via `TaskPromptComposer.Compose` (appends a read-only "## Reference files" section); invoke `ClaudeProcess` with task prompt
5. Stream NDJSON output through `StreamAnalyzer`; lines forwarded to log file and SignalR (`TaskMessage`)
6. On success: auto-commit changes (worktree only), store run record, mark "done"
7. On failure: retry once if session ID available (`--resume`), then mark "failed"
`TaskRunner` orchestrates: load task + config → worktree or sandbox → mark running → build args →
spawn `ClaudeProcess` → stream NDJSON through `StreamAnalyzer` → on success auto-commit + store
run record, on failure retry once via `--resume` then fail.
Full flow, invariants, and model/effort/max-turns resolution (including the low-preset turn trap)
→ [worker-task-pipeline](../../docs/explore-notes/worker-task-pipeline.md).
## Key Components
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever permission mode the app settings specify). Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree).
- **ClaudeArgsBuilder** — dynamically constructs CLI args; supports `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
- **StreamAnalyzer** — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser.
- **TaskResetService** — discards a failed task's worktree and resets the task row to Idle; preserves run history.
- **WorktreeManager** — creates worktrees at `claudedo/{taskId[:8]}` branches, commits changes with semantic messages, updates DB with head commit and diff stats
- **CommitMessageBuilder** — formats `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`
- **AgentFileService** — manages `~/.todo-app/agents/*.md` agent definition files; exposes list/refresh via SignalR
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree.
- **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
- **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser.
- **WorktreeManager** — worktrees on `claudedo/{taskId[:8]}` branches; commits with semantic messages, updates DB with head commit + diff stats
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`
- **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history
- **AgentFileService** — manages `~/.todo-app/agents/*.md`; list/refresh via SignalR
- **LogWriter** — async StreamWriter wrapper, auto-creates parent dirs
## Execution History
Each CLI invocation is recorded in `task_runs` via `TaskRunRepository`. ⚠️ Token fields come from
the **session transcript**, not the stream-json result event, as a per-run delta →
[usage-monitoring](../../docs/explore-notes/usage-monitoring.md). `TaskRunner.ContinueAsync` sends
a follow-up prompt to an existing session via `--resume <session_id>`.
Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`:
- Fields: `session_id`, turn count, `result` text, structured output JSON, and the four raw token
fields (`tokens_in`/`tokens_out`/`cache_read_tokens`/`cache_write_tokens`) — **not** read from the
stream-json "result" event's `usage.input_tokens` (that's only the uncached remainder of one API
call and undercounts the real prompt size by orders of magnitude once caching kicks in). Instead
`TaskRunner.ApplyUsageAsync` reads `ITranscriptUsageReader.ReadSessionTotalsAsync(sessionId)` —
the session transcript's cumulative totals across every assistant message — and stores the
**delta** against prior `task_runs` rows sharing the same `session_id`, so a `--resume`'d run
doesn't double-count the turns already billed to an earlier run. A missing/unreadable transcript
leaves all four fields `null`; it never fails the run.
- Enables auto-retry on failure (resume last session) and multi-turn follow-up via `ContinueAsync`
## Daily Prep (Prime Claude)
## Multi-Turn / Continue
**PrimeScheduler** (`BackgroundService`) computes the next due time from `prime_schedules` and
calls `IPrimeRunner.FireAsync`; manual runs arrive via `WorkerHub.RunDailyPrepNow`. The
`SemaphoreSlim` single-flight gate lives **in `PrimeRunner`**, not the scheduler, so both paths go
through it (returns "already running").
`TaskRunner.ContinueAsync` sends a follow-up prompt to an existing Claude session using `--resume <session_id>` with the stored session ID from the last run.
**PrimeRunner** builds a fixed prompt via `DailyPrepPrompt.BuildPrompt` (parameterized by
`AppSettings.DailyPrepMaxTasks` + today's date), then runs `claude -p` with
`--permission-mode acceptEdits --max-turns 30` and `--allowedTools
mcp__claudedo__get_daily_prep_candidates mcp__claudedo__set_my_day`. It relies on the
globally-registered `claudedo` MCP (installer's `RegisterMcpStep`) — no separate `--mcp-config`.
Claude then calls `get_daily_prep_candidates`, picks an effort-aware subset capped at
`DailyPrepMaxTasks`, and marks them via `set_my_day`. Each stdout line goes to the UI via
`IPrimeBroadcaster.PrepLineAsync` **and** to `DailyPrepPrompt.LogPath()` =
`<appdata>/logs/daily-prep.log`, which is **truncated at the start of each run** (last run only).
`PrepStarted`/`PrepFinished` bracket the run.
## SignalR Hub
**WorkerHub** methods, grouped:
`WorkerHub` is the canonical method list — grep it rather than trusting a doc inventory.
Groups: execution · review/merge · conflict resolver · planning sessions · interactive ConPTY
launch specs · worktrees · agents/settings/lists · reports/notes/prep · diagnostics · usage.
`IWorkerClient` in `ClaudeDo.Ui` mirrors it.
- Execution: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `SetTaskStatus`, `RefineTask`
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets`
- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names)
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
- Interactive sessions (embedded ConPTY, UI process): `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`, `GetMergeHelperLaunchSpec`, `CreateMergeHelperTask` (creates the ClaudeDo task that owns a list-handler run — `Idle`/`IsManual=true`, `HandlerBaseCommit` stamped to the list repo's current HEAD via `InteractiveLaunchSpecService.CreateMergeHelperTaskAsync` — called by the UI before it opens the task-based ConPTY tile), `SubmitTaskForReview` (branches on whether the task has a `WorktreeEntity`: with one, commits it and moves on; without one, it's a worktree-less list-handler host task and it just stamps `HandlerHeadCommit` to the list repo's current HEAD — both paths then flip the task Idle/Failed → WaitingForReview). Every ConPTY spec that `InteractiveLaunchSpecService` builds passes `--effort <level>` from the relevant model's preset (task/list model for a task session, `PlanningAlias` for planning, list config for the list handler, global default for ad-hoc) — 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 (see below). `--model` is deliberately NOT forced on an interactive session — the user can still switch models in the TUI. The list-handler spec (`BuildForMergeHelperAsync`) uses `--permission-mode auto` so it runs unattended; the `--allowedTools` allowlist (`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`) remains the security boundary. **No ConPTY path ever passes task free-text (title/description/brief) as a CLI argument** — every one of them (task session, planning start/resume, list handler) writes it to a file first and hands claude a single-line kickoff pointing at that file, exposed via `--add-dir`. Reason: the ConPTY host flattens `Args` into one command line to spawn the process, and claude re-splits that line on whitespace, so any token starting with `-` (e.g. `->`, `--abort`) in real task text would be misread as an unknown option — and 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.
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
- Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
- Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`
- Diagnostics: `GetRecentLogs` (last 30 min of buffered log records, all levels, for the Log Visualizer overlay)
- Usage: `GetUsageSnapshot() -> UsageSnapshotDto` (built by `UsageSnapshotBuilder` from `UsageState` + `IUsageGate` + `AppSettings` gate thresholds; percentages/limits/`FetchedAtUtc` 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) -> IReadOnlyList<ModelUsageRowDto>` (thin wrapper over `ITranscriptUsageReader.ReadAsync`), `GetTaskUsage(from, to) -> IReadOnlyList<TaskUsageRowDto>` (top consumers from `task_runs` joined to task/list, grouped per task — `Runs`/summed `TokensIn`/`TokensOut` (null token columns count as 0, never dropped), `Model` from that task's most recent run — sorted by total tokens descending, capped at 100)
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`,
`TaskUpdated`, `RunCreated`, `ListUpdated`, `WorkerLog`, `PrimeFired`, `PrepStarted`, `PrepLine`,
`PrepFinished`, `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`,
`PlanningMergeAborted`, `PlanningCompleted`, `RefineStarted`, `RefineFinished`, `UsageUpdated`.
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`, `WorkerLog`, `PrimeFired`, `PrepStarted`, `PrepLine`, `PrepFinished`, `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`, `PlanningMergeAborted`, `PlanningCompleted`, `RefineStarted`, `RefineFinished`, `UsageUpdated` (carries the same `UsageSnapshotDto` as `GetUsageSnapshot`; `UsageMonitorService` fires it after every poll cycle, success or failure, via the shared `UsageSnapshotBuilder`)
`WorkerLog` carries two sources: the hand-curated business events (`_broadcaster.WorkerLog(...)` in TaskRunner/TaskMergeService/TaskResetService) **and** every Serilog **Warn/Error** event, re-broadcast by `BroadcastLogSink` (deduped within a 120 s per-message window; SignalR plumbing source-contexts filtered to avoid feedback loops). The sink also buffers **all** levels into `LogRingBuffer` for `GetRecentLogs`.
`WorkerLog` carries two sources: hand-curated business events (`_broadcaster.WorkerLog(...)` in
TaskRunner/TaskMergeService/TaskResetService) **and** every Serilog Warn/Error, re-broadcast by
`BroadcastLogSink` (deduped within a 120 s per-message window; SignalR plumbing source-contexts
filtered to avoid feedback loops). The sink also buffers **all** levels into `LogRingBuffer` for
`GetRecentLogs`.
## Config
Loaded from `~/.todo-app/worker.config.json`:
- `db_path`, `sandbox_root`, `log_root`
- `worktree_root_strategy` ("sibling" | "central"), `central_worktree_root`
- `queue_backstop_interval_ms` (default 30000)
- `signalr_port` (default 47821)
- `claude_bin` (path to claude CLI)
- `online_inbox` — Online Inbox config (default: `enabled=false`, zero network when disabled):
- `enabled` (bool, default false) — when false the entire `Online/` stack is not registered
- `api_base_url` (string) — must be HTTPS or loopback; validated at startup when enabled
- `poll_interval_seconds` (int, default 60)
- `zitadel.authority`, `zitadel.client_id`, `zitadel.scopes` — used by `ZitadelAuthProvider` (OIDC discovery + refresh-token flow)
- The refresh token is NOT in this file — stored encrypted via DPAPI at `~/.todo-app/online-inbox.token`
- `usage_poll_interval_seconds` (default 60, clamped to a minimum of 15 on load) — poll interval for `UsageMonitorService`
`~/.todo-app/worker.config.json`:
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = 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`/`AddTask` do not expose it.
- `db_path`, `sandbox_root`, `log_root`
- `worktree_root_strategy` (`sibling` | `central`), `central_worktree_root`
- `queue_backstop_interval_ms` (30000) — also the gate/throttle recovery timer
- `signalr_port` (47821), `claude_bin`
- `usage_poll_interval_seconds` (60, clamped to min 15 on load)
- `online_inbox``enabled` (false by default; when false the entire `Online/` stack is not registered), `api_base_url` (must be HTTPS or loopback, validated at startup), `poll_interval_seconds` (60), `zitadel.authority`/`client_id`/`scopes`. The refresh token is **not** in this file — DPAPI-encrypted at `~/.todo-app/online-inbox.token`.
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`,
`agent_path`, `max_turns`, `session_skills`, `verify_command`; tasks override each individually.
## Notes
- The worker runs standalone — start it separately from the UI
- Only listens on loopback (127.0.0.1)
- ClaudeProcess uses `--permission-mode auto` by default; legacy "bypassPermissions" settings are mapped to `auto` at dispatch time. `acceptEdits`, `plan`, and `default` pass through unchanged.
- Worktree branches follow `claudedo/{id}` naming convention
- The worker runs standalone — start it separately from the UI. Loopback only (127.0.0.1).
- `--permission-mode auto` by default; legacy `bypassPermissions` settings map to `auto` at dispatch time. `acceptEdits`, `plan`, `default` pass through unchanged.
- Worktree branches follow `claudedo/{id}`.
+4 -11
View File
@@ -17,17 +17,10 @@ xUnit integration tests for the Worker and Data layers. One of six test projects
## Test Areas
Tests are organized by Worker area (mirroring the source folders); 30+ test files in total. Highlights per area:
| Area | Covers |
|------|--------|
| Repositories | `ListRepositoryTests`/`TaskRepositoryTests` + config, delete-config, agent-settings, planning, orphan-guard, and roadblock variants |
| Runner | `WorktreeManagerTests`, `CommitMessageBuilderTests`, `StreamAnalyzerTests`, standalone-children routing |
| Queue / Services | `QueueServiceTests` (FIFO, override slot contention, cancellation, active tracking), `QueueServiceSlotGuardTests`, `StaleTaskRecoveryTests`, `TaskResetServiceTests`, `TaskMergeServiceTests`, `WorktreeMaintenanceServiceTests`, `AgentFileServiceTests`, `DefaultAgentSeederTests` |
| State | `TaskStateService` transition coverage |
| Planning | session manager, chain coordinator, merge orchestrator, end-to-end planning flow |
| Prime / Report / External / Lifecycle / Hub | daily prep, weekly report, external MCP tools, recovery services, hub methods |
| UiSchema / UiVm | UI-facing DTO schemas and viewmodel behavior driven from Worker fakes |
Test folders mirror the Worker source folders (`Repositories`, `Runner`, `Queue`, `State`,
`Planning`, `Prime`, `Report`, `External`, `Lifecycle`, `Hub`, `UiSchema`, `UiVm`) — `ls` the
folder rather than trusting an inventory here. `UiSchema`/`UiVm` cover UI-facing DTO schemas and
viewmodel behaviour driven from Worker fakes.
## Conventions