Files
ClaudeDo/src/ClaudeDo.Data/CLAUDE.md
T

20 KiB
Raw Blame History

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 <provider>:<id> (currently always bandel:<ticketId>). 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<TaskDependencyEntity>) + the [NotMapped] DependsOnTaskIds id projection. Only populated where the query Includes 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.
    • 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.
    • 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. 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.
  • 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.
  • 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.

⚠️ 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.
  • 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<ClaudeDoDbContext> — 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/<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 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 <sha>), 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 <Name> --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<T>
  • Entity configurations live in Configuration/
  • Primary keys are init-only strings (GUIDs assigned at creation)
  • All methods are async with CancellationToken where applicable