# ClaudeDo.Data Shared data layer: models, repositories, SQLite infrastructure, and git operations. ## Models - **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), Dependencies (n user/MCP-declared predecessors via `task_dependencies`, distinct from BlockedByTaskId), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns / PermissionMode (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId, TicketRef, Number (global immutable human-readable alias). - `TicketRef` (`ticket_ref`, nullable string) — link to an external ticket, format `:` (currently always `bandel:`). Set by the ticket import, otherwise null. → `ClaudeDo.Worker/CLAUDE.md` Tickets section. - Status / PlanningPhase / BlockedByTaskId / dependency-set semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model. - `Dependencies` (`ICollection`) + the `[NotMapped] DependsOnTaskIds` id projection. **Only populated where the query `Include`s it** — `TaskRepository.GetByIdAsync`/`GetByNumberAsync`/`GetByListIdAsync` do, and so do the `TasksIslandViewModel` row-hydration queries. The queue gate itself is raw SQL in `QueuePicker` and never reads the nav, so a forgotten Include misreports the UI/MCP `blocked` flag but can never let a blocked task run. ⚠️ `TaskRepository.UpdateAsync` marks **only the root entity** Modified (`Entry().State`, not `Update()`) — `Update()` marks the loaded graph, and an all-key `task_dependencies` row has nothing to SET. - `HandlerBaseCommit`/`HandlerHeadCommit` = the review range for a **worktree-less "list handler" host task** ("Let Claude handle it"), which commits straight into the list's working dir instead of a per-task worktree. Everything that reads a task's diff falls back to this pair whenever `Worktree` is null → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). - `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). - `Number` (INTEGER NOT NULL, unique index `idx_tasks_number`) — a global, monotonically increasing display alias for tasks. **Never reused**: deleting a task leaves a gap in numbering, ensuring that a task number that appears in a log or report always points to the same task (if it exists). Allocated by `TaskNumberAllocator.AddWithNumberAsync` on every insert via a persistent counter (`app_settings.next_task_number`). **Why not `MAX(number) + 1`:** deleting the highest-numbered task would free its number for reuse, breaking the immutability guarantee. The allocator uses an `UPDATE…RETURNING` statement to claim numbers atomically, retrying up to 5 times if a uniqueness collision occurs (the counter advances on each attempt, so retries always allocate fresh numbers). The one-time backfill for pre-existing rows lived in the `AddTaskNumbers` migration, squashed away 2026-08-26. - Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired. The backfill migration was squashed away (2026-08-26) — a database still holding those values can't be upgraded, see Schema below. - **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`), Icon, Color, FolderId - `Color` (`color`, nullable) — dot colour: a swatch key (`Swatch3`), a literal `#RRGGBB`, or null = the hash-derived identity colour. Same value format as `ListFolderEntity.Color`; resolved UI-side by `ColorBrushConverter`. - `FolderId` (`folder_id`, nullable FK → `list_folders.id`, `ON DELETE SET NULL`) — the sidebar folder the list sits in; null = top level. Written only by the UI (`ListsIslandViewModel` drag/context menu), never by MCP. - **ListFolderEntity** — Id, Name, Icon, SortOrder, IsCollapsed, ParentFolderId, CreatedAt → `list_folders`. A pure sidebar grouping: no tasks, no config, no working dir. `ParentFolderId` (self-FK, `ON DELETE SET NULL`) lets folders nest arbitrarily deep — drag-and-drop only, no UI to pick a parent otherwise. Deliberately its own table rather than a flag on `lists`, so every caller that enumerates lists (MCP `list_task_lists`, "move to list", the queue picker, repo import) needs no folder filtering. Folder CRUD lives on `ListRepository`. - **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand, PermissionMode, TicketProjectId (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md). `PermissionMode` is null = inherit the global default; resolution order task → list → global lives in `EffectiveRunConfigResolver`. `TicketProjectId` (`ticket_project_id`) links the list to a project in the external ticket system; null = not linked → `ClaudeDo.Worker/CLAUDE.md` Tickets section. - ⚠️ `ListRepository.SetConfigAsync` copies the entity **verbatim**, so every writer must carry the fields it doesn't own (`SessionSkills`, `PermissionMode`, `TicketProjectId`) or they silently reset. `UpdateListConfigDto.TicketProjectId` is tri-state (`null` = keep stored) for exactly that reason — only the list-settings modal sends an explicit value. This bit `PermissionMode` for real: its update-branch copy was missing from `SetConfigAsync` until `f0a3a186`, so a list's permission-mode override only ever stuck on the list's *first* save, never on a later one. - **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, Kind (`PrimeActionKind`, column `action_kind`, default `ping`), CreatedAt. Recurs on selected weekdays; no date range. `PromptOverride`'s role depends on `Kind`: ignored for `Ping`, appended to the daily-prep prompt for `FillMyDay`, and the entire prompt for `Custom`. - **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` - **TaskDependencyEntity** — one declared "run after" edge: TaskId + DependsOnTaskId, composite PK, table `task_dependencies`. Both FKs are `ON DELETE CASCADE`, which is what the old single `depends_on_task_id` column got from `ON DELETE SET NULL`: deleting a predecessor drops the edge so the dependent becomes pickable instead of blocked on a task that's gone. Written **only** by `TaskStateService.SetDependsOnAsync`. - **MissionControlSessionEntity** — SessionId (PK), Cwd, TaskId (no FK), Title, StartedAt, PredecessorSessionId, BriefPath → `mission_control_sessions`. Index behind Mission Control's "Recent sessions" flyout; written only by `InteractiveLaunchSpecService` (task/ad-hoc/handoff tile launches), insert-if-absent → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). - **SubtaskEntity**, **AgentInfo** — subtasks / record for scanned agent files ### AppSettingsEntity Beyond the basics it carries: | Property | Column | Default | Note | |---|---|---|---| | `DefaultMaxTurns` | `default_max_turns` | **40** | Lowered from 100; `AddMaxTurnsCeiling` backfilled the seeded row. | | `MaxTurnsCeiling` | `max_turns_ceiling` | 80 | Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run. `UpdateAsync` clamps to min 1. | | `ModelPresets` | `model_presets` | seeded | JSON array of `ModelPreset` rows. ⚠️ `AppSettingsRepository.GetAsync` **backfills shipping defaults on the first read after it's null**, so it's never null once a run has started. | | `UsageGateFiveHourPct` / `UsageGateSevenDayPct` | `usage_gate_*_pct` | 80 / 90 | Queue pause thresholds; `0` = off. | | `UsageThrottle{FiveHour,SevenDay}{Soft,Hard}Pct` | `usage_throttle_{five_hour,seven_day}_{soft,hard}_pct` | 50 / 65 per bucket | Staged parallelism below the hard gate, **per bucket**; `0` = that stage off. Edited by dragging the usage-monitor gauges. | | `DailyPrepMaxTasks` | `daily_prep_max_tasks` | 5 | Hard cap on MyDay tasks the daily prep may place. | | `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. | | `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. | | `NextTaskNumber` | `next_task_number` | 1 | Persistent counter for `TaskEntity.Number` allocation. Incremented atomically in `TaskNumberAllocator.AddWithNumberAsync` on every task insert. **Monotonic only:** the counter itself advances across all tasks (no per-list numbering), and it never resets. A deleted task leaves a gap. | | `PowerUseEnabled` | `power_use_enabled` | false | PowerUse master switch, toggled in **Settings → Execution**. ⚠️ Never sufficient alone — a handler run is in PowerUse only when `list_config.power_use_allowed` is also set. | | `ListHandlerMaxHandoffs` | `list_handler_max_handoffs` | 4 | Cap on a list-handler run's wait↔merge handoff chain (`HandoffRoundTracker`). 4 = two merge rounds. Independent of PowerUse — applies to interactive runs too. Clamped to min 1 in `WorkerHub.UpdateAppSettings` and again in `Register`, because 0 would remove the only bound on a runaway chain. | | `PowerUseWindowStart` / `PowerUseWindowEnd` | `power_use_window_*` | null | Optional local-time window (`HH:mm`) narrowing when PowerUse applies. Both null (or only one set) = round the clock, i.e. byte-identical to the pre-window behaviour; end before start wraps over midnight. Semantics live in the pure `PowerUseWindow`; checked in `InteractiveLaunchSpecService.ComposeHandlerPromptAsync` **per phase** at prompt-build time, so a chain handing off past the end time drops the addendum from there on. Unparseable input is stored as null, not rejected. | | `PowerUseHandoffPct` | `power_use_handoff_pct` | 95 | 5h-usage percentage at which a PowerUse handler session should hand off. Clamped 50..99. Typed in Settings → Execution; the consumer is the handoff prompt. | All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`. Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). ⚠️ **`AppSettingsRepository.UpdateAsync` copies the incoming entity field by field** onto the singleton row — a property missing from that block never persists, however correctly the caller filled the entity, and nothing fails. `ModelPresets`, `PowerUseEnabled` and `ListHandlerMaxHandoffs` were all silently unwritable that way until 2026-09-02. Add a new column to that block, not just to the entity + the hub. ### 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 1–200) 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 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`. ⚠️ Status-mutation primitives (`MarkRunningAsync`/`MarkDoneAsync`/`MarkFailedAsync`/`FlipAllRunningToFailedAsync`) are **`internal`** — only the Worker's `TaskStateService` may call them. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`. **Task-number allocation:** `AddAsync` (line 20) and `CreateChildAsync` (line 276) are the two sole task-creation paths; both call `TaskNumberAllocator.AddWithNumberAsync` to allocate a number atomically during the same transaction as the insert. Every other creation path (UI, MCP `add_task`, `batch_add_tasks`, Online Inbox sync, merge-helper handler tasks, planning children) routes through one of these two. - **ListRepository** — CRUD, `GetConfigAsync`/`SetConfigAsync` (upsert)/`DeleteConfigAsync` for `list_config`, plus the `list_folders` set: `GetFoldersAsync`, `AddFolderAsync` (appends), `RenameFolderAsync`, `DeleteFolderAsync` (folder only — the FK releases its lists), `SetFolderCollapsedAsync`, `SetListFolderAsync`. The folder writers are `ExecuteUpdate` patches, not entity `Update()`s, so a rename can't reset `SortOrder`/`IsCollapsed` the way `SetConfigAsync`'s verbatim copy resets forgotten columns. - ⚠️ **`lists.sort_order` and `list_folders.sort_order` share one number space** — the sidebar interleaves folders and top-level lists in a single order, so `ReorderSidebarAsync` numbers the whole visual sequence (each folder followed by its children) across both tables, and `AddAsync`/`AddFolderAsync` append via `MAX(sort_order)+1` over **both**. Never renumber one table on its own. - **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)`. Bulk delete-per-task is the `ON DELETE CASCADE` on `task_attachments`, not a repository method. - **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; WAL mode + foreign keys via `UseSqlite` options - **IDbContextFactory\** — registered in DI; used by singleton consumers (e.g. the Worker hosted service) - **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root `~/.claudeDo` - **AppSettings** — loads `~/.claudeDo/ui.config.json` (DbPath, SignalRUrl) - **AttachmentStore** — dependency-free file store, default root `~/.claudeDo/attachments//`. `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 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), 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 `), `RevertAbortAsync`, `IsMidRevertAsync` (`REVERT_HEAD`, mirrors `IsMidMergeAsync`) - `PreviewMergeAsync` (non-destructive check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo - Empty repos: `HasHeadCommitAsync` (HEAD resolves to a real commit) and `EnsureHeadCommitAsync` — a fresh `git init` repo (unborn HEAD) gets an empty bootstrap commit via plumbing only (mktree/commit-tree/update-ref), so the user's index and working tree stay untouched; identity is passed inline, so it also works with no git config, and a broken HEAD throws instead of guessing - Identity fallback: `CommitAsync` retries once as the ClaudeDo identity when the machine has none configured ⚠️ **Revert never resets or rewrites** — it always produces a new commit, because the working directory it operates on is shared with other concurrent sessions. ⚠️ **Git stdin is BOM-less UTF-8** — `Encoding.UTF8` writes a BOM preamble, which git rejects as input (`mktree` fails with "input format error", and commit messages would carry it). ## Schema Tables (one per line so parallel migrations don't collide on the same line): - `lists` - `tasks` - `worktrees` - `list_config` - `task_runs` - `subtasks` - `app_settings` - `prime_schedules` - `daily_notes` - `week_reports` - `task_attachments` - `task_dependencies` - `list_folders` - `mission_control_sessions` Managed by EF Core migrations in `Migrations/`. **The 40 incremental migrations were squashed into a single `20260826094154_InitialCreate` on 2026-08-26** — the schema history is git history now, not a migration chain. Consequences: - `ClaudeDoDbContext.MigrateAndConfigure` **baselines** an existing database onto the squashed id (pre-EF databases without a history, and databases whose history still lists the old chain ending at `20260825063230_AddPrimeActionKind`). A database that stopped *mid*-chain can no longer be upgraded and throws with a reinstall message instead of being stamped onto a schema it lacks. Covered by `MigrationBaselineTests` — one of only two tests that run a real `Migrate()` (`TaskDependencyMigrationTests` is the other; every other Data.Tests fixture uses `EnsureCreated`, which skips migrations entirely). Its fixture builds the legacy database by migrating **to** `InitialCreate` and seeding rows with raw SQL: `EnsureCreated` would build *today's* schema, so every post-squash migration would then re-add an existing column ("duplicate column name"). Assert "nothing pending", never "InitialCreate is the only history row". - Don't hand-edit the squashed migration; add new ones on top as usual. `dotnet ef migrations add --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --configuration Release` — `Data` is its own startup project (it holds the `Design` package and a design-time factory), and `--configuration Release` is needed because a running Worker locks `Debug`. `tasks` holds `status`, `planning_phase` (default `none`) and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). The user/MCP-declared predecessors live in `task_dependencies` instead — a **set**, so a task can wait on n of them; see Worker/CLAUDE.md → Status Model for why it isn't unified with `blocked_by_task_id`. `AddTaskDependencies` (2026-09-02) migrated the former single `tasks.depends_on_task_id` column into that table (each value became a one-element set) and dropped it; the `Down` direction is inherently lossy for more than one edge. ## Conventions - Enum ↔ string mapping via EF Core `ValueConverter`, configured in `IEntityTypeConfiguration` - Entity configurations live in `Configuration/` - Primary keys are `init`-only strings (GUIDs assigned at creation) - All methods are async with CancellationToken where applicable