# Tabbed Settings + Prime Claude — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Restructure the Settings modal into a tabbed layout, move About to the Help menu, and add a "Prime Claude" tab that schedules a daily one-shot `claude -p "ping"` call to start the Claude usage window early. **Architecture:** New `PrimeScheduleEntity` + repository in `ClaudeDo.Data`. Event-driven `PrimeScheduler : BackgroundService` in `ClaudeDo.Worker` that waits on `Task.Delay(timeUntilNextDue, ct)` and is signaled by hub mutations. Hub adds list/upsert/delete + a `PrimeFired` broadcast event. `SettingsModalViewModel` is split into per-tab VMs. `MainWindow` Help menu gains an "About…" entry that opens a new modal. **Tech Stack:** .NET 8, EF Core (Sqlite) + migrations, Avalonia 12 (Fluent), CommunityToolkit.Mvvm, SignalR, xUnit. **Spec:** `docs/superpowers/specs/2026-04-28-tabbed-settings-prime-claude-design.md` **Build / test reminder (project-specific):** `dotnet build ClaudeDo.slnx` fails on .NET 8 — always build individual csproj files. Run all tests via `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` and `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`. --- ## Task 1: Add `PrimeScheduleEntity` + EF configuration **Files:** - Create: `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs` - Create: `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs` - Modify: `src/ClaudeDo.Data/ClaudeDoDbContext.cs` (add `DbSet`) - [ ] **Step 1: Create the entity** ```csharp // src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs namespace ClaudeDo.Data.Models; public sealed class PrimeScheduleEntity { public Guid Id { get; set; } = Guid.NewGuid(); public DateOnly StartDate { get; set; } public DateOnly EndDate { get; set; } public TimeSpan TimeOfDay { get; set; } public bool WorkdaysOnly { get; set; } = true; public bool Enabled { get; set; } = true; public DateTimeOffset? LastRunAt { get; set; } public string? PromptOverride { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; } ``` - [ ] **Step 2: Create the EF configuration** ```csharp // src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs using ClaudeDo.Data.Models; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace ClaudeDo.Data.Configuration; public class PrimeScheduleEntityConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.ToTable("prime_schedules"); builder.HasKey(s => s.Id); builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever(); builder.Property(s => s.StartDate).HasColumnName("start_date").IsRequired(); builder.Property(s => s.EndDate).HasColumnName("end_date").IsRequired(); builder.Property(s => s.TimeOfDay).HasColumnName("time_of_day").IsRequired(); builder.Property(s => s.WorkdaysOnly).HasColumnName("workdays_only").IsRequired().HasDefaultValue(true); builder.Property(s => s.Enabled).HasColumnName("enabled").IsRequired().HasDefaultValue(true); builder.Property(s => s.LastRunAt).HasColumnName("last_run_at"); builder.Property(s => s.PromptOverride).HasColumnName("prompt_override"); builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired(); } } ``` - [ ] **Step 3: Register the DbSet** In `src/ClaudeDo.Data/ClaudeDoDbContext.cs`, add next to the other `DbSet` properties (after `AppSettings`): ```csharp public DbSet PrimeSchedules => Set(); ``` - [ ] **Step 4: Build to verify the model compiles** Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj` Expected: build succeeds. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs src/ClaudeDo.Data/ClaudeDoDbContext.cs git commit -m "feat(data): add PrimeScheduleEntity + configuration" ``` --- ## Task 2: Add EF migration `AddPrimeSchedules` **Files:** - Create: `src/ClaudeDo.Data/Migrations/_AddPrimeSchedules.cs` (auto-generated) - Create: `src/ClaudeDo.Data/Migrations/_AddPrimeSchedules.Designer.cs` (auto-generated) - Modify: `src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs` (auto-updated) - [ ] **Step 1: Generate the migration** Run from repo root: ```bash dotnet ef migrations add AddPrimeSchedules \ --project src/ClaudeDo.Data/ClaudeDo.Data.csproj \ --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj ``` Expected: three files added/modified under `src/ClaudeDo.Data/Migrations/`. The `Up()` method should `CreateTable("prime_schedules", ...)` with the columns from Task 1. - [ ] **Step 2: Eyeball the generated `Up()` to verify columns** Open the generated `_AddPrimeSchedules.cs` and confirm columns: `id` (TEXT PK), `start_date`, `end_date`, `time_of_day`, `workdays_only`, `enabled`, `last_run_at` (nullable), `prompt_override` (nullable), `created_at`. - [ ] **Step 3: Build worker to confirm migration applies** Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` Expected: build succeeds. - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Data/Migrations/ git commit -m "feat(data): add AddPrimeSchedules migration" ``` --- ## Task 3: Add `PrimeScheduleRepository` (TDD) **Files:** - Create: `src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs` - Create: `tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs` - [ ] **Step 1: Write failing repository test** ```csharp // tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Tests.Infrastructure; namespace ClaudeDo.Worker.Tests.Repositories; public class PrimeScheduleRepositoryTests : IDisposable { private readonly DbFixture _db = new(); public void Dispose() => _db.Dispose(); [Fact] public async Task Upsert_Then_List_RoundTrips() { var id = Guid.NewGuid(); using (var ctx = _db.CreateContext()) { await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity { Id = id, StartDate = new DateOnly(2026, 5, 1), EndDate = new DateOnly(2026, 6, 30), TimeOfDay = new TimeSpan(7, 0, 0), WorkdaysOnly = true, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, }); } using var read = _db.CreateContext(); var rows = await new PrimeScheduleRepository(read).ListAsync(); Assert.Single(rows); Assert.Equal(id, rows[0].Id); Assert.Equal(new TimeSpan(7, 0, 0), rows[0].TimeOfDay); } [Fact] public async Task UpdateLastRunAt_Persists() { var id = Guid.NewGuid(); var when = new DateTimeOffset(2026, 5, 5, 7, 1, 0, TimeSpan.FromHours(2)); using (var ctx = _db.CreateContext()) { await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity { Id = id, StartDate = new DateOnly(2026, 5, 1), EndDate = new DateOnly(2026, 5, 31), TimeOfDay = new TimeSpan(7, 0, 0), Enabled = true, CreatedAt = DateTimeOffset.UtcNow, }); } using (var ctx = _db.CreateContext()) await new PrimeScheduleRepository(ctx).UpdateLastRunAsync(id, when); using var read = _db.CreateContext(); var row = await new PrimeScheduleRepository(read).GetAsync(id); Assert.NotNull(row); Assert.Equal(when, row!.LastRunAt); } [Fact] public async Task Delete_Removes_Row() { var id = Guid.NewGuid(); using (var ctx = _db.CreateContext()) await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity { Id = id, StartDate = new DateOnly(2026, 5, 1), EndDate = new DateOnly(2026, 5, 1), TimeOfDay = TimeSpan.Zero, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, }); using (var ctx = _db.CreateContext()) await new PrimeScheduleRepository(ctx).DeleteAsync(id); using var read = _db.CreateContext(); Assert.Empty(await new PrimeScheduleRepository(read).ListAsync()); } } ``` - [ ] **Step 2: Run test, confirm failure** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~PrimeScheduleRepositoryTests"` Expected: compile error — `PrimeScheduleRepository` does not exist. - [ ] **Step 3: Implement the repository** ```csharp // src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs using ClaudeDo.Data.Models; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Data.Repositories; public sealed class PrimeScheduleRepository { private readonly ClaudeDoDbContext _context; public PrimeScheduleRepository(ClaudeDoDbContext context) => _context = context; public async Task> ListAsync(CancellationToken ct = default) => await _context.PrimeSchedules.AsNoTracking() .OrderBy(s => s.StartDate).ThenBy(s => s.TimeOfDay) .ToListAsync(ct); public async Task GetAsync(Guid id, CancellationToken ct = default) => await _context.PrimeSchedules.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct); public async Task UpsertAsync(PrimeScheduleEntity entity, CancellationToken ct = default) { var existing = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == entity.Id, ct); if (existing is null) { _context.PrimeSchedules.Add(entity); } else { existing.StartDate = entity.StartDate; existing.EndDate = entity.EndDate; existing.TimeOfDay = entity.TimeOfDay; existing.WorkdaysOnly = entity.WorkdaysOnly; existing.Enabled = entity.Enabled; existing.PromptOverride = entity.PromptOverride; // CreatedAt + LastRunAt are not overwritten by upsert } await _context.SaveChangesAsync(ct); } public async Task DeleteAsync(Guid id, CancellationToken ct = default) { var row = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == id, ct); if (row is null) return; _context.PrimeSchedules.Remove(row); await _context.SaveChangesAsync(ct); } public async Task UpdateLastRunAsync(Guid id, DateTimeOffset when, CancellationToken ct = default) { var row = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == id, ct); if (row is null) return; row.LastRunAt = when; await _context.SaveChangesAsync(ct); } } ``` - [ ] **Step 4: Run tests, confirm pass** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~PrimeScheduleRepositoryTests"` Expected: 3 passed. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs git commit -m "feat(data): add PrimeScheduleRepository" ``` --- ## Task 4: Add `PrimeScheduleDto` **Files:** - Create: `src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs` - [ ] **Step 1: Create the folder and DTO** ```csharp // src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs namespace ClaudeDo.Worker.Prime; public sealed record PrimeScheduleDto( Guid Id, DateOnly StartDate, DateOnly EndDate, TimeSpan TimeOfDay, bool WorkdaysOnly, bool Enabled, DateTimeOffset? LastRunAt, string? PromptOverride); ``` - [ ] **Step 2: Build to confirm** Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` Expected: build succeeds. - [ ] **Step 3: Commit** ```bash git add src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs git commit -m "feat(worker): add PrimeScheduleDto" ``` --- ## Task 5: Add scheduler abstractions (`IPrimeClock`, `IPrimeRunner`, `IPrimeScheduleSignal`) **Files:** - Create: `src/ClaudeDo.Worker/Prime/IPrimeClock.cs` - Create: `src/ClaudeDo.Worker/Prime/IPrimeRunner.cs` - Create: `src/ClaudeDo.Worker/Prime/IPrimeScheduleSignal.cs` - Create: `src/ClaudeDo.Worker/Prime/PrimeClock.cs` - Create: `src/ClaudeDo.Worker/Prime/PrimeScheduleSignal.cs` - Create: `src/ClaudeDo.Worker/Prime/PrimeRunner.cs` - Create: `src/ClaudeDo.Worker/Prime/PrimeSchedulerOptions.cs` - [ ] **Step 1: Clock** ```csharp // src/ClaudeDo.Worker/Prime/IPrimeClock.cs namespace ClaudeDo.Worker.Prime; public interface IPrimeClock { DateTimeOffset Now { get; } } ``` ```csharp // src/ClaudeDo.Worker/Prime/PrimeClock.cs namespace ClaudeDo.Worker.Prime; public sealed class PrimeClock : IPrimeClock { public DateTimeOffset Now => DateTimeOffset.Now; } ``` - [ ] **Step 2: Signal (waker for the scheduler)** ```csharp // src/ClaudeDo.Worker/Prime/IPrimeScheduleSignal.cs namespace ClaudeDo.Worker.Prime; public interface IPrimeScheduleSignal { void Signal(); CancellationToken CurrentToken { get; } } ``` ```csharp // src/ClaudeDo.Worker/Prime/PrimeScheduleSignal.cs namespace ClaudeDo.Worker.Prime; public sealed class PrimeScheduleSignal : IPrimeScheduleSignal, IDisposable { private CancellationTokenSource _cts = new(); private readonly object _lock = new(); public CancellationToken CurrentToken { get { lock (_lock) return _cts.Token; } } public void Signal() { CancellationTokenSource old; lock (_lock) { old = _cts; _cts = new CancellationTokenSource(); } try { old.Cancel(); } catch { /* already cancelled */ } old.Dispose(); } public void Dispose() { lock (_lock) _cts.Dispose(); } } ``` - [ ] **Step 3: Runner contract** ```csharp // src/ClaudeDo.Worker/Prime/IPrimeRunner.cs namespace ClaudeDo.Worker.Prime; public interface IPrimeRunner { Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct); } public sealed record PrimeRunOutcome(bool Success, string Message); ``` - [ ] **Step 4: Runner implementation** ```csharp // src/ClaudeDo.Worker/Prime/PrimeRunner.cs using ClaudeDo.Data; using ClaudeDo.Worker.Runner; namespace ClaudeDo.Worker.Prime; public sealed class PrimeRunner : IPrimeRunner { private static readonly TimeSpan FireTimeout = TimeSpan.FromSeconds(60); private readonly IClaudeProcess _claude; private readonly ILogger _logger; public PrimeRunner(IClaudeProcess claude, ILogger logger) { _claude = claude; _logger = logger; } public async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct) { var cwd = Paths.AppDataRoot(); Directory.CreateDirectory(cwd); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(FireTimeout); try { var prompt = schedule.PromptOverride ?? "ping"; var result = await _claude.RunAsync( arguments: "-p --max-turns 1", prompt: prompt, workingDirectory: cwd, onStdoutLine: _ => Task.CompletedTask, ct: timeoutCts.Token); if (result.ExitCode == 0) return new PrimeRunOutcome(true, "Primed Claude"); return new PrimeRunOutcome(false, $"exit {result.ExitCode}"); } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested) { return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalSeconds:0}s"); } catch (Exception ex) { _logger.LogWarning(ex, "Prime fire failed"); return new PrimeRunOutcome(false, ex.Message); } } } ``` (Note: `RunResult.ExitCode` is the exit code surfaced by `ClaudeProcess`. If the existing `RunResult` shape differs, expose the int via the same property name used internally — this is the only field consumed here.) - [ ] **Step 5: Options record** ```csharp // src/ClaudeDo.Worker/Prime/PrimeSchedulerOptions.cs namespace ClaudeDo.Worker.Prime; public sealed record PrimeSchedulerOptions(TimeSpan CatchUpWindow) { public static PrimeSchedulerOptions Default { get; } = new(TimeSpan.FromMinutes(30)); } ``` - [ ] **Step 6: Build to confirm** Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` Expected: build succeeds. If `RunResult` doesn't expose `ExitCode` directly, open `src/ClaudeDo.Worker/Runner/RunResult.cs` and use the equivalent field (e.g. `Status`, `IsSuccess`). The contract is: success when the process exited cleanly. Adjust the `if` branch accordingly and re-build. - [ ] **Step 7: Commit** ```bash git add src/ClaudeDo.Worker/Prime/ git commit -m "feat(worker): add Prime scheduler abstractions + runner" ``` --- ## Task 6: Add `NextDueCalculator` (pure function, TDD) **Files:** - Create: `src/ClaudeDo.Worker/Prime/NextDueCalculator.cs` - Create: `tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs` This pure function is the heart of the scheduler. Isolating it makes the BackgroundService thin and testable. - [ ] **Step 1: Write failing tests** ```csharp // tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs using ClaudeDo.Worker.Prime; namespace ClaudeDo.Worker.Tests.Prime; public class NextDueCalculatorTests { private static PrimeScheduleDto Schedule( DateOnly start, DateOnly end, TimeSpan time, bool workdaysOnly = true, bool enabled = true, DateTimeOffset? lastRun = null) => new(Guid.NewGuid(), start, end, time, workdaysOnly, enabled, lastRun, null); [Fact] public void Disabled_Schedule_Returns_Null() { var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), enabled: false); Assert.Null(NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30))); } [Fact] public void Future_Same_Day_Returns_Today_At_Target() { // Tuesday 06:00 local, target 07:00 same day var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); Assert.NotNull(r); Assert.Equal(new DateTimeOffset(2026,5,5,7,0,0, now.Offset), r!.At); Assert.False(r.FireImmediately); } [Fact] public void Within_CatchUp_Window_Fires_Immediately() { // Tuesday 07:15, target was 07:00, catch-up 30min var now = new DateTimeOffset(2026, 5, 5, 7, 15, 0, TimeSpan.FromHours(2)); var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); Assert.NotNull(r); Assert.True(r!.FireImmediately); } [Fact] public void Past_CatchUp_Window_Skips_To_Next_Eligible_Day() { // Tuesday 09:00, target was 07:00, catch-up 30min → next is Wednesday 07:00 var now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)); var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); Assert.NotNull(r); Assert.Equal(new DateOnly(2026,5,6), DateOnly.FromDateTime(r!.At.LocalDateTime)); } [Fact] public void WorkdaysOnly_Skips_Weekend() { // Friday 08:00, target was 07:00 (past catch-up) → next eligible is Monday var now = new DateTimeOffset(2026, 5, 8, 8, 0, 0, TimeSpan.FromHours(2)); var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), workdaysOnly: true); var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); Assert.NotNull(r); Assert.Equal(DayOfWeek.Monday, r!.At.LocalDateTime.DayOfWeek); Assert.Equal(new DateOnly(2026,5,11), DateOnly.FromDateTime(r.At.LocalDateTime)); } [Fact] public void Already_Fired_Today_Skips_To_Tomorrow() { // Today 06:00, lastRun was today 07:01 → next is tomorrow var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); var lastRun = new DateTimeOffset(2026, 5, 5, 7, 1, 0, TimeSpan.FromHours(2)); var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), lastRun: lastRun); var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); Assert.NotNull(r); Assert.Equal(new DateOnly(2026,5,6), DateOnly.FromDateTime(r!.At.LocalDateTime)); } [Fact] public void Past_EndDate_Returns_Null() { var now = new DateTimeOffset(2026, 6, 1, 6, 0, 0, TimeSpan.FromHours(2)); var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); Assert.Null(NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30))); } [Fact] public void Multiple_Schedules_Returns_Earliest() { var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); var early = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); var late = Schedule(new(2026,5,1), new(2026,5,31), new(9,0,0)); var r = NextDueCalculator.Compute(new[]{late, early}, now, TimeSpan.FromMinutes(30)); Assert.NotNull(r); Assert.Equal(early.Id, r!.Schedule.Id); } } ``` - [ ] **Step 2: Run tests, confirm failure** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~NextDueCalculatorTests"` Expected: compile error — `NextDueCalculator` does not exist. - [ ] **Step 3: Implement `NextDueCalculator`** ```csharp // src/ClaudeDo.Worker/Prime/NextDueCalculator.cs namespace ClaudeDo.Worker.Prime; public sealed record NextDue(PrimeScheduleDto Schedule, DateTimeOffset At, bool FireImmediately); public static class NextDueCalculator { public static NextDue? Compute( IEnumerable schedules, DateTimeOffset now, TimeSpan catchUp) { NextDue? best = null; foreach (var s in schedules) { if (!s.Enabled) continue; var due = ComputeFor(s, now, catchUp); if (due is null) continue; if (best is null || due.At < best.At) best = due; } return best; } private static NextDue? ComputeFor(PrimeScheduleDto s, DateTimeOffset now, TimeSpan catchUp) { if (s.EndDate < DateOnly.FromDateTime(now.LocalDateTime)) return null; var todayLocal = DateOnly.FromDateTime(now.LocalDateTime); var alreadyFiredToday = s.LastRunAt is { } last && DateOnly.FromDateTime(last.LocalDateTime) == todayLocal; // Try today first if (!alreadyFiredToday) { var startOrToday = s.StartDate > todayLocal ? s.StartDate : todayLocal; if (startOrToday == todayLocal && IsEligibleDay(s, todayLocal)) { var todayTarget = ToOffset(todayLocal, s.TimeOfDay, now.Offset); if (todayTarget >= now) return new NextDue(s, todayTarget, false); if (now <= todayTarget + catchUp) return new NextDue(s, now, true); // else fall through to tomorrow } } // Find the next eligible day strictly after today var d = todayLocal.AddDays(1); if (s.StartDate > d) d = s.StartDate; for (int i = 0; i < 8; i++) // at most a week ahead to find a workday { if (d > s.EndDate) return null; if (IsEligibleDay(s, d)) return new NextDue(s, ToOffset(d, s.TimeOfDay, now.Offset), false); d = d.AddDays(1); } return null; } private static bool IsEligibleDay(PrimeScheduleDto s, DateOnly d) { if (d < s.StartDate || d > s.EndDate) return false; if (!s.WorkdaysOnly) return true; var dow = d.ToDateTime(TimeOnly.MinValue).DayOfWeek; return dow != DayOfWeek.Saturday && dow != DayOfWeek.Sunday; } private static DateTimeOffset ToOffset(DateOnly day, TimeSpan time, TimeSpan offset) => new(day.Year, day.Month, day.Day, time.Hours, time.Minutes, time.Seconds, offset); } ``` - [ ] **Step 4: Run tests, confirm pass** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~NextDueCalculatorTests"` Expected: 8 passed. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Prime/NextDueCalculator.cs tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs git commit -m "feat(worker): add NextDueCalculator with workday + catch-up logic" ``` --- ## Task 7: Add `PrimeScheduler : BackgroundService` + integration tests **Files:** - Create: `src/ClaudeDo.Worker/Prime/PrimeScheduler.cs` - Create: `tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs` This BackgroundService is thin: it loops, computes next due, awaits, and fires. Logic lives in `NextDueCalculator`; concrete IO lives in `IPrimeRunner`. - [ ] **Step 1: Write failing scheduler tests** ```csharp // tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Prime; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; namespace ClaudeDo.Worker.Tests.Prime; public class PrimeSchedulerTests : IDisposable { private readonly DbFixture _db = new(); public void Dispose() => _db.Dispose(); private sealed class FakeClock : IPrimeClock { public DateTimeOffset Now { get; set; } } private sealed class FakeRunner : IPrimeRunner { public List FiredIds { get; } = new(); public Task FireAsync(PrimeScheduleDto s, CancellationToken ct) { FiredIds.Add(s.Id); return Task.FromResult(new PrimeRunOutcome(true, "ok")); } } private sealed class FakeBroadcaster : IPrimeBroadcaster { public List<(Guid id, bool ok, string msg)> Calls { get; } = new(); public Task PrimeFiredAsync(Guid id, bool ok, string msg, DateTimeOffset firedAt) { Calls.Add((id, ok, msg)); return Task.CompletedTask; } } [Fact] public async Task Fires_Schedule_When_Within_CatchUp_On_Startup() { var id = Guid.NewGuid(); using (var ctx = _db.CreateContext()) await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity { Id = id, StartDate = new DateOnly(2026, 5, 5), EndDate = new DateOnly(2026, 5, 5), TimeOfDay = new TimeSpan(7, 0, 0), WorkdaysOnly = false, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, }); var clock = new FakeClock { Now = new DateTimeOffset(2026, 5, 5, 7, 10, 0, TimeSpan.FromHours(2)) }; var runner = new FakeRunner(); var broadcaster = new FakeBroadcaster(); var signal = new PrimeScheduleSignal(); var scheduler = new PrimeScheduler( _db.CreateFactory(), runner, clock, signal, broadcaster, PrimeSchedulerOptions.Default, NullLogger.Instance); using var cts = new CancellationTokenSource(); var run = scheduler.StartAsync(cts.Token); // Allow scheduler to fire await WaitFor(() => runner.FiredIds.Count >= 1, TimeSpan.FromSeconds(3)); cts.Cancel(); await scheduler.StopAsync(CancellationToken.None); Assert.Single(runner.FiredIds); Assert.Equal(id, runner.FiredIds[0]); Assert.Single(broadcaster.Calls); Assert.True(broadcaster.Calls[0].ok); using var read = _db.CreateContext(); var row = await new PrimeScheduleRepository(read).GetAsync(id); Assert.NotNull(row!.LastRunAt); } [Fact] public async Task Does_Not_Fire_Past_CatchUp_Window() { var id = Guid.NewGuid(); using (var ctx = _db.CreateContext()) await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity { Id = id, StartDate = new DateOnly(2026, 5, 5), EndDate = new DateOnly(2026, 5, 5), TimeOfDay = new TimeSpan(7, 0, 0), WorkdaysOnly = false, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, }); // 09:00 — 2h after target, well past 30min catch-up var clock = new FakeClock { Now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)) }; var runner = new FakeRunner(); var signal = new PrimeScheduleSignal(); var scheduler = new PrimeScheduler( _db.CreateFactory(), runner, clock, signal, new FakeBroadcaster(), PrimeSchedulerOptions.Default, NullLogger.Instance); using var cts = new CancellationTokenSource(); await scheduler.StartAsync(cts.Token); await Task.Delay(200); cts.Cancel(); await scheduler.StopAsync(CancellationToken.None); Assert.Empty(runner.FiredIds); } [Fact] public async Task Signal_Recomputes_Mid_Wait() { // Empty DB, scheduler waits on signal. Insert a due schedule and signal — fires. var clock = new FakeClock { Now = new DateTimeOffset(2026, 5, 5, 7, 10, 0, TimeSpan.FromHours(2)) }; var runner = new FakeRunner(); var signal = new PrimeScheduleSignal(); var scheduler = new PrimeScheduler( _db.CreateFactory(), runner, clock, signal, new FakeBroadcaster(), PrimeSchedulerOptions.Default, NullLogger.Instance); using var cts = new CancellationTokenSource(); await scheduler.StartAsync(cts.Token); var id = Guid.NewGuid(); using (var ctx = _db.CreateContext()) await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity { Id = id, StartDate = new DateOnly(2026, 5, 5), EndDate = new DateOnly(2026, 5, 5), TimeOfDay = new TimeSpan(7, 0, 0), WorkdaysOnly = false, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, }); signal.Signal(); await WaitFor(() => runner.FiredIds.Count >= 1, TimeSpan.FromSeconds(3)); cts.Cancel(); await scheduler.StopAsync(CancellationToken.None); Assert.Single(runner.FiredIds); } private static async Task WaitFor(Func cond, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (!cond() && DateTime.UtcNow < deadline) await Task.Delay(20); } } ``` - [ ] **Step 2: Run tests, confirm failure** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~PrimeSchedulerTests"` Expected: compile error — `PrimeScheduler` and `IPrimeBroadcaster` do not exist. - [ ] **Step 3: Add `IPrimeBroadcaster`** ```csharp // src/ClaudeDo.Worker/Prime/IPrimeBroadcaster.cs namespace ClaudeDo.Worker.Prime; public interface IPrimeBroadcaster { Task PrimeFiredAsync(Guid scheduleId, bool success, string message, DateTimeOffset firedAt); } ``` - [ ] **Step 4: Implement `PrimeScheduler`** ```csharp // src/ClaudeDo.Worker/Prime/PrimeScheduler.cs using ClaudeDo.Data; using ClaudeDo.Data.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Hosting; namespace ClaudeDo.Worker.Prime; public sealed class PrimeScheduler : BackgroundService { private readonly IDbContextFactory _dbFactory; private readonly IPrimeRunner _runner; private readonly IPrimeClock _clock; private readonly IPrimeScheduleSignal _signal; private readonly IPrimeBroadcaster _broadcaster; private readonly PrimeSchedulerOptions _options; private readonly ILogger _logger; public PrimeScheduler( IDbContextFactory dbFactory, IPrimeRunner runner, IPrimeClock clock, IPrimeScheduleSignal signal, IPrimeBroadcaster broadcaster, PrimeSchedulerOptions options, ILogger logger) { _dbFactory = dbFactory; _runner = runner; _clock = clock; _signal = signal; _broadcaster = broadcaster; _options = options; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { await TickAsync(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } catch (Exception ex) { _logger.LogWarning(ex, "PrimeScheduler tick failed; backing off"); await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); } } } private async Task TickAsync(CancellationToken stoppingToken) { var schedules = await LoadAsync(stoppingToken); var now = _clock.Now; var due = NextDueCalculator.Compute(schedules, now, _options.CatchUpWindow); var signalToken = _signal.CurrentToken; using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, signalToken); if (due is null) { try { await Task.Delay(TimeSpan.FromHours(1), linked.Token); } catch (OperationCanceledException) { /* signal or shutdown */ } return; } var delay = due.FireImmediately ? TimeSpan.Zero : due.At - now; if (delay > TimeSpan.Zero) { try { await Task.Delay(delay, linked.Token); } catch (OperationCanceledException) { if (signalToken.IsCancellationRequested) return; // recompute throw; } } await FireAsync(due.Schedule, stoppingToken); } private async Task> LoadAsync(CancellationToken ct) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var rows = await new PrimeScheduleRepository(ctx).ListAsync(ct); return rows.Select(ToDto).ToList(); } private static PrimeScheduleDto ToDto(Data.Models.PrimeScheduleEntity e) => new(e.Id, e.StartDate, e.EndDate, e.TimeOfDay, e.WorkdaysOnly, e.Enabled, e.LastRunAt, e.PromptOverride); private async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct) { var firedAt = _clock.Now; var outcome = await _runner.FireAsync(schedule, ct); await using (var ctx = await _dbFactory.CreateDbContextAsync(ct)) await new PrimeScheduleRepository(ctx).UpdateLastRunAsync(schedule.Id, firedAt, ct); await _broadcaster.PrimeFiredAsync(schedule.Id, outcome.Success, outcome.Message, firedAt); if (outcome.Success) _logger.LogInformation("Prime fired {Id} at {When}", schedule.Id, firedAt); else _logger.LogWarning("Prime failed {Id}: {Msg}", schedule.Id, outcome.Message); } } ``` - [ ] **Step 5: Run tests, confirm pass** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~PrimeSchedulerTests"` Expected: 3 passed. (If `Signal_Recomputes_Mid_Wait` is flaky on slow CI, increase the `WaitFor` timeout.) - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Worker/Prime/IPrimeBroadcaster.cs src/ClaudeDo.Worker/Prime/PrimeScheduler.cs tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs git commit -m "feat(worker): add PrimeScheduler hosted service" ``` --- ## Task 8: Wire `HubBroadcaster` to publish `PrimeFired` **Files:** - Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` - [ ] **Step 1: Add the broadcast method + implement `IPrimeBroadcaster`** In `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`, add this method alongside the existing ones, and make the class implement `IPrimeBroadcaster`: ```csharp // add to using's: using ClaudeDo.Worker.Prime; ``` Change the class declaration: ```csharp public sealed class HubBroadcaster : IPrimeBroadcaster ``` Append: ```csharp public Task PrimeFired(Guid scheduleId, bool success, string message, DateTimeOffset firedAt) => _hub.Clients.All.SendAsync("PrimeFired", scheduleId, success, message, firedAt); Task IPrimeBroadcaster.PrimeFiredAsync(Guid scheduleId, bool success, string message, DateTimeOffset firedAt) => PrimeFired(scheduleId, success, message, firedAt); ``` - [ ] **Step 2: Build to confirm** Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` Expected: build succeeds. - [ ] **Step 3: Commit** ```bash git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs git commit -m "feat(worker): broadcast PrimeFired SignalR event" ``` --- ## Task 9: Add hub methods (`ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`) **Files:** - Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs` - [ ] **Step 1: Add `using` and inject `IPrimeScheduleSignal`** Add to imports: ```csharp using ClaudeDo.Worker.Prime; ``` Add a private field and constructor parameter for `IPrimeScheduleSignal _primeSignal`. Pattern matches the existing constructor — append the parameter at the end and assign it. - [ ] **Step 2: Add the three hub methods** Append below `UpdateAppSettings`: ```csharp public async Task> ListPrimeSchedules() { using var ctx = _dbFactory.CreateDbContext(); var rows = await new PrimeScheduleRepository(ctx).ListAsync(); return rows.Select(e => new PrimeScheduleDto( e.Id, e.StartDate, e.EndDate, e.TimeOfDay, e.WorkdaysOnly, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList(); } public async Task UpsertPrimeSchedule(PrimeScheduleDto dto) { using var ctx = _dbFactory.CreateDbContext(); var repo = new PrimeScheduleRepository(ctx); var existing = await repo.GetAsync(dto.Id); var entity = new ClaudeDo.Data.Models.PrimeScheduleEntity { Id = dto.Id == Guid.Empty ? Guid.NewGuid() : dto.Id, StartDate = dto.StartDate, EndDate = dto.EndDate, TimeOfDay = dto.TimeOfDay, WorkdaysOnly = dto.WorkdaysOnly, Enabled = dto.Enabled, PromptOverride = dto.PromptOverride, CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow, LastRunAt = existing?.LastRunAt, }; await repo.UpsertAsync(entity); _primeSignal.Signal(); return new PrimeScheduleDto(entity.Id, entity.StartDate, entity.EndDate, entity.TimeOfDay, entity.WorkdaysOnly, entity.Enabled, entity.LastRunAt, entity.PromptOverride); } public async Task DeletePrimeSchedule(Guid id) { using var ctx = _dbFactory.CreateDbContext(); await new PrimeScheduleRepository(ctx).DeleteAsync(id); _primeSignal.Signal(); } ``` - [ ] **Step 3: Add a using for the repository** ```csharp using ClaudeDo.Data.Repositories; ``` (Already imported earlier in the file — verify.) - [ ] **Step 4: Build worker** Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` Expected: build succeeds. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Hub/WorkerHub.cs git commit -m "feat(worker): add Prime schedule hub methods" ``` --- ## Task 10: Register Prime services in `Program.cs` **Files:** - Modify: `src/ClaudeDo.Worker/Program.cs` - [ ] **Step 1: Add the registrations** Just before `builder.Services.AddHostedService(sp => sp.GetRequiredService());` (around line 79), add: ```csharp // Prime Claude builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(PrimeSchedulerOptions.Default); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(); ``` Add `using ClaudeDo.Worker.Prime;` to the imports if not already present. - [ ] **Step 2: Build to confirm wiring** Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` Expected: build succeeds, no DI resolution warnings. - [ ] **Step 3: Commit** ```bash git add src/ClaudeDo.Worker/Program.cs git commit -m "feat(worker): register Prime services in DI" ``` --- ## Task 11: Mirror `PrimeScheduleDto` + client methods in UI `WorkerClient` **Files:** - Create: `src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs` - Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs` The UI cannot reference `ClaudeDo.Worker`. Mirror the DTO shape used over the wire. - [ ] **Step 1: Create the UI-side DTO** ```csharp // src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs namespace ClaudeDo.Ui.Services; public sealed record PrimeScheduleDto( Guid Id, DateOnly StartDate, DateOnly EndDate, TimeSpan TimeOfDay, bool WorkdaysOnly, bool Enabled, DateTimeOffset? LastRunAt, string? PromptOverride); public sealed record PrimeFiredEvent( Guid ScheduleId, bool Success, string Message, DateTimeOffset FiredAt); ``` - [ ] **Step 2: Add invoke methods + event handler in `WorkerClient`** In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, declare a public event near the existing event declarations: ```csharp public event Action? PrimeFired; ``` In the constructor, after the existing `_hub.On<...>` registrations, add: ```csharp _hub.On("PrimeFired", (id, ok, msg, when) => { Dispatcher.UIThread.Post(() => PrimeFired?.Invoke(new PrimeFiredEvent(id, ok, msg, when))); }); ``` Append public methods alongside the existing ones (e.g. after `UpdateAppSettingsAsync`): ```csharp public async Task> GetPrimeSchedulesAsync() { try { return await _hub.InvokeAsync>("ListPrimeSchedules"); } catch { return new List(); } } public async Task UpsertPrimeScheduleAsync(PrimeScheduleDto dto) { try { return await _hub.InvokeAsync("UpsertPrimeSchedule", dto); } catch { return null; } } public async Task DeletePrimeScheduleAsync(Guid id) { try { await _hub.InvokeAsync("DeletePrimeSchedule", id); } catch { /* offline */ } } ``` - [ ] **Step 3: Build UI** Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` Expected: build succeeds. - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs src/ClaudeDo.Ui/Services/WorkerClient.cs git commit -m "feat(ui): add Prime schedule client + PrimeFired event" ``` --- ## Task 12: Extract per-tab settings VMs (preparatory refactor) **Files:** - Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs` - Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/WorktreesSettingsTabViewModel.cs` - Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs` - Modify: `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` This task moves logic only — no behavior changes. The View still binds to the same property paths via `Binding General.DefaultModel` once the View is updated in Task 14. - [ ] **Step 1: Create `GeneralSettingsTabViewModel`** ```csharp // src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs using CommunityToolkit.Mvvm.ComponentModel; namespace ClaudeDo.Ui.ViewModels.Modals.Settings; public sealed partial class GeneralSettingsTabViewModel : ViewModelBase { [ObservableProperty] private string _defaultClaudeInstructions = ""; [ObservableProperty] private string _defaultModel = "sonnet"; [ObservableProperty] private int _defaultMaxTurns = 100; [ObservableProperty] private string _defaultPermissionMode = "auto"; public IReadOnlyList Models { get; } = new[] { "opus", "sonnet", "haiku" }; public IReadOnlyList PermissionModes { get; } = new[] { "auto", "bypassPermissions", "acceptEdits", "plan", "default" }; public string? Validate() { if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200) return "Max turns must be between 1 and 200."; return null; } } ``` - [ ] **Step 2: Create `WorktreesSettingsTabViewModel`** ```csharp // src/ClaudeDo.Ui/ViewModels/Modals/Settings/WorktreesSettingsTabViewModel.cs using System.IO; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ClaudeDo.Ui.ViewModels.Modals.Settings; public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase { private readonly WorkerClient _worker; [ObservableProperty] private string _worktreeStrategy = "sibling"; [ObservableProperty] private string? _centralWorktreeRoot; [ObservableProperty] private bool _worktreeAutoCleanupEnabled; [ObservableProperty] private int _worktreeAutoCleanupDays = 7; [ObservableProperty] private bool _showResetConfirm; [ObservableProperty] private string _statusMessage = ""; [ObservableProperty] private bool _isBusy; public IReadOnlyList WorktreeStrategies { get; } = new[] { "sibling", "central" }; public WorktreesSettingsTabViewModel(WorkerClient worker) => _worker = worker; public string? Validate() { if (WorktreeAutoCleanupEnabled && (WorktreeAutoCleanupDays < 1 || WorktreeAutoCleanupDays > 365)) return "Cleanup days must be between 1 and 365."; if (WorktreeStrategy == "central") { if (string.IsNullOrWhiteSpace(CentralWorktreeRoot)) return "Central worktree root is required for Central strategy."; if (!Directory.Exists(CentralWorktreeRoot)) return $"Directory not found: {CentralWorktreeRoot}"; } return null; } [RelayCommand] private async Task CleanupWorktrees() { IsBusy = true; StatusMessage = ""; try { var r = await _worker.CleanupFinishedWorktreesAsync(); StatusMessage = r is null ? "Worker offline." : $"Removed {r.Removed} worktree(s)."; } finally { IsBusy = false; } } [RelayCommand] private void RequestResetConfirm() => ShowResetConfirm = true; [RelayCommand] private void CancelResetConfirm() => ShowResetConfirm = false; [RelayCommand] private async Task ConfirmResetAll() { ShowResetConfirm = false; IsBusy = true; StatusMessage = ""; try { var r = await _worker.ResetAllWorktreesAsync(); if (r is null) StatusMessage = "Worker offline."; else if (r.Blocked) StatusMessage = $"Cannot force-remove: {r.RunningTasks} task(s) still running. Cancel them first."; else StatusMessage = $"Removed {r.Removed} worktree(s) from {r.TasksAffected} task(s)."; } finally { IsBusy = false; } } } ``` - [ ] **Step 3: Create `FilesSettingsTabViewModel`** ```csharp // src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs using System.Diagnostics; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ClaudeDo.Ui.ViewModels.Modals.Settings; public sealed partial class FilesSettingsTabViewModel : ViewModelBase { private readonly WorkerClient _worker; [ObservableProperty] private string _statusMessage = ""; [ObservableProperty] private bool _isBusy; public string SystemPromptPath { get; } = PromptFiles.PathFor(PromptKind.System); public string PlanningPromptPath { get; } = PromptFiles.PathFor(PromptKind.Planning); public string AgentPromptPath { get; } = PromptFiles.PathFor(PromptKind.Agent); public FilesSettingsTabViewModel(WorkerClient worker) => _worker = worker; [RelayCommand] private async Task RestoreDefaultAgents() { IsBusy = true; StatusMessage = ""; try { var r = await _worker.RestoreDefaultAgentsAsync(); if (r is null) StatusMessage = "Worker offline."; else if (r.Copied == 0 && r.Skipped == 0) StatusMessage = "No default agents bundled."; else if (r.Copied == 0) StatusMessage = "All default agents already present."; else StatusMessage = $"Restored {r.Copied} default agent(s)."; await _worker.RefreshAgentsAsync(); } catch (Exception ex) { StatusMessage = $"Restore failed: {ex.Message}"; } finally { IsBusy = false; } } [RelayCommand] private void OpenPrompt(string? kindName) { if (!Enum.TryParse(kindName, ignoreCase: true, out var kind)) return; try { PromptFiles.EnsureExists(kind); var path = PromptFiles.PathFor(kind); Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); } catch (Exception ex) { StatusMessage = $"Open failed: {ex.Message}"; } } } ``` - [ ] **Step 4: Slim down `SettingsModalViewModel` to a coordinator** Replace the body of `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`: ```csharp using System.Diagnostics; using System.IO; using System.Reflection; using ClaudeDo.Data; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Modals.Settings; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ClaudeDo.Ui.ViewModels.Modals; public sealed partial class SettingsModalViewModel : ViewModelBase { private readonly WorkerClient _worker; public GeneralSettingsTabViewModel General { get; } public WorktreesSettingsTabViewModel Worktrees { get; } public FilesSettingsTabViewModel Files { get; } public PrimeClaudeTabViewModel Prime { get; } // added in Task 13 [ObservableProperty] private string _validationError = ""; [ObservableProperty] private bool _isBusy; [ObservableProperty] private string _statusMessage = ""; public Action? CloseAction { get; set; } public SettingsModalViewModel(WorkerClient worker, PrimeClaudeTabViewModel prime) { _worker = worker; General = new GeneralSettingsTabViewModel(); Worktrees = new WorktreesSettingsTabViewModel(worker); Files = new FilesSettingsTabViewModel(worker); Prime = prime; } public async Task LoadAsync() { IsBusy = true; try { var dto = await _worker.GetAppSettingsAsync(); if (dto is not null) { General.DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? ""; General.DefaultModel = dto.DefaultModel ?? "sonnet"; General.DefaultMaxTurns = dto.DefaultMaxTurns; General.DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto"; Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling"; Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot; Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled; Worktrees.WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays; } else StatusMessage = "Worker offline — settings read-only."; await Prime.LoadAsync(); } finally { IsBusy = false; } } [RelayCommand] private async Task Save() { var err = General.Validate() ?? Worktrees.Validate() ?? Prime.Validate(); if (err is not null) { ValidationError = err; return; } ValidationError = ""; IsBusy = true; try { var dto = new AppSettingsDto( General.DefaultClaudeInstructions ?? "", General.DefaultModel ?? "sonnet", General.DefaultMaxTurns, General.DefaultPermissionMode ?? "auto", Worktrees.WorktreeStrategy ?? "sibling", string.IsNullOrWhiteSpace(Worktrees.CentralWorktreeRoot) ? null : Worktrees.CentralWorktreeRoot, Worktrees.WorktreeAutoCleanupEnabled, Worktrees.WorktreeAutoCleanupDays); await _worker.UpdateAppSettingsAsync(dto); await Prime.SaveAsync(); CloseAction?.Invoke(); } catch (Exception ex) { StatusMessage = $"Save failed: {ex.Message}"; } finally { IsBusy = false; } } [RelayCommand] private void Cancel() => CloseAction?.Invoke(); } ``` - [ ] **Step 5: Build UI to verify shape (it will fail until Task 13 supplies `PrimeClaudeTabViewModel`)** Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` Expected: compile error pointing at `PrimeClaudeTabViewModel`. This is intentional — Task 13 fills it in. - [ ] **Step 6: Do not commit yet** Wait for Task 13 to make the UI compile, then commit Tasks 12 + 13 together. --- ## Task 13: Add `PrimeClaudeTabViewModel` + row VM (TDD) **Files:** - Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs` - Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs` - Create: `tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs` - [ ] **Step 1: Write failing tests** ```csharp // tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Modals.Settings; namespace ClaudeDo.Ui.Tests.ViewModels; public class PrimeClaudeTabViewModelTests { private sealed class FakeApi : IPrimeScheduleApi { public List Stored { get; } = new(); public List Upserts { get; } = new(); public List Deletes { get; } = new(); public Task> ListAsync() => Task.FromResult(Stored.ToList()); public Task UpsertAsync(PrimeScheduleDto dto) { Upserts.Add(dto); return Task.FromResult(dto); } public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; } } [Fact] public async Task Load_Populates_Rows() { var api = new FakeApi(); api.Stored.Add(new PrimeScheduleDto( Guid.NewGuid(), new DateOnly(2026,5,1), new DateOnly(2026,5,31), new TimeSpan(7,0,0), true, true, null, null)); var vm = new PrimeClaudeTabViewModel(api); await vm.LoadAsync(); Assert.Single(vm.Rows); } [Fact] public void AddSchedule_Appends_Row_With_Defaults() { var vm = new PrimeClaudeTabViewModel(new FakeApi()); vm.AddScheduleCommand.Execute(null); Assert.Single(vm.Rows); Assert.True(vm.Rows[0].Enabled); Assert.True(vm.Rows[0].WorkdaysOnly); Assert.Equal(new TimeSpan(7,0,0), vm.Rows[0].TimeOfDay); } [Fact] public async Task Save_Diffs_New_And_Removed_Rows() { var api = new FakeApi(); var keptId = Guid.NewGuid(); var deletedId = Guid.NewGuid(); api.Stored.Add(new PrimeScheduleDto(keptId, new(2026,5,1), new(2026,5,31), new(7,0,0), true, true, null, null)); api.Stored.Add(new PrimeScheduleDto(deletedId, new(2026,5,1), new(2026,5,31), new(8,0,0), true, true, null, null)); var vm = new PrimeClaudeTabViewModel(api); await vm.LoadAsync(); vm.RemoveScheduleCommand.Execute(vm.Rows.Single(r => r.Id == deletedId)); vm.AddScheduleCommand.Execute(null); await vm.SaveAsync(); Assert.Contains(deletedId, api.Deletes); // 2 upserts: the kept (existing) row + the newly added row Assert.Equal(2, api.Upserts.Count); } [Fact] public void Validate_Reports_StartAfterEnd() { var vm = new PrimeClaudeTabViewModel(new FakeApi()); vm.AddScheduleCommand.Execute(null); vm.Rows[0].StartDate = new DateOnly(2026, 6, 1); vm.Rows[0].EndDate = new DateOnly(2026, 5, 1); Assert.NotNull(vm.Validate()); } } ``` - [ ] **Step 2: Run tests, confirm failure (compile error)** Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter "FullyQualifiedName~PrimeClaudeTabViewModelTests"` Expected: compile error. - [ ] **Step 3: Implement the row VM** ```csharp // src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; namespace ClaudeDo.Ui.ViewModels.Modals.Settings; public sealed partial class PrimeScheduleRowViewModel : ViewModelBase { public Guid Id { get; } public bool IsExisting { get; } [ObservableProperty] private bool _enabled; [ObservableProperty] private DateOnly _startDate; [ObservableProperty] private DateOnly _endDate; [ObservableProperty] private TimeSpan _timeOfDay; [ObservableProperty] private bool _workdaysOnly; [ObservableProperty] private DateTimeOffset? _lastRunAt; public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—"; partial void OnLastRunAtChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastRunLabel)); public PrimeScheduleRowViewModel(PrimeScheduleDto dto, bool isExisting) { Id = dto.Id; IsExisting = isExisting; Enabled = dto.Enabled; StartDate = dto.StartDate; EndDate = dto.EndDate; TimeOfDay = dto.TimeOfDay; WorkdaysOnly = dto.WorkdaysOnly; LastRunAt = dto.LastRunAt; } public PrimeScheduleDto ToDto() => new(Id, StartDate, EndDate, TimeOfDay, WorkdaysOnly, Enabled, LastRunAt, null); } ``` - [ ] **Step 4: Add `IPrimeScheduleApi` and adapter on `WorkerClient`** Add abstraction (avoids coupling tests to SignalR): ```csharp // src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs namespace ClaudeDo.Ui.Services; public interface IPrimeScheduleApi { Task> ListAsync(); Task UpsertAsync(PrimeScheduleDto dto); Task DeleteAsync(Guid id); } public sealed class WorkerPrimeScheduleApi : IPrimeScheduleApi { private readonly WorkerClient _client; public WorkerPrimeScheduleApi(WorkerClient client) => _client = client; public Task> ListAsync() => _client.GetPrimeSchedulesAsync(); public Task UpsertAsync(PrimeScheduleDto dto) => _client.UpsertPrimeScheduleAsync(dto); public Task DeleteAsync(Guid id) => _client.DeletePrimeScheduleAsync(id); } ``` - [ ] **Step 5: Implement the tab VM** ```csharp // src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs using System.Collections.ObjectModel; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ClaudeDo.Ui.ViewModels.Modals.Settings; public sealed partial class PrimeClaudeTabViewModel : ViewModelBase { private readonly IPrimeScheduleApi _api; private readonly HashSet _initialIds = new(); public ObservableCollection Rows { get; } = new(); public PrimeClaudeTabViewModel(IPrimeScheduleApi api) => _api = api; public async Task LoadAsync() { Rows.Clear(); _initialIds.Clear(); var list = await _api.ListAsync(); foreach (var dto in list) { Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: true)); _initialIds.Add(dto.Id); } } public string? Validate() { foreach (var r in Rows) { if (r.StartDate > r.EndDate) return $"Schedule {r.TimeOfDay:hh\\:mm}: start date is after end date."; if (r.TimeOfDay < TimeSpan.Zero || r.TimeOfDay >= TimeSpan.FromDays(1)) return "Time must be between 00:00 and 23:59."; } return null; } public async Task SaveAsync() { var keepIds = Rows.Select(r => r.Id).ToHashSet(); foreach (var removed in _initialIds.Where(id => !keepIds.Contains(id)).ToList()) await _api.DeleteAsync(removed); foreach (var r in Rows) await _api.UpsertAsync(r.ToDto()); _initialIds.Clear(); foreach (var id in keepIds) _initialIds.Add(id); } [RelayCommand] private void AddSchedule() { var today = DateOnly.FromDateTime(DateTime.Today); var dto = new PrimeScheduleDto( Id: Guid.NewGuid(), StartDate: today, EndDate: today.AddDays(30), TimeOfDay: new TimeSpan(7, 0, 0), WorkdaysOnly: true, Enabled: true, LastRunAt: null, PromptOverride: null); Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: false)); } [RelayCommand] private void RemoveSchedule(PrimeScheduleRowViewModel? row) { if (row is null) return; Rows.Remove(row); } public void ApplyFiredEvent(PrimeFiredEvent evt) { var row = Rows.FirstOrDefault(r => r.Id == evt.ScheduleId); if (row is null) return; if (evt.Success) row.LastRunAt = evt.FiredAt; } } ``` - [ ] **Step 6: Run tests, confirm pass** Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter "FullyQualifiedName~PrimeClaudeTabViewModelTests"` Expected: 4 passed. - [ ] **Step 7: Build UI** Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` Expected: build succeeds (this also clears the Task 12 break). - [ ] **Step 8: Commit Tasks 12 + 13 together** ```bash git add src/ClaudeDo.Ui/ViewModels/Modals/Settings/ src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs git commit -m "feat(ui): split SettingsModalViewModel into per-tab VMs + add PrimeClaudeTabViewModel" ``` --- ## Task 14: Refactor `SettingsModalView.axaml` to TabControl + add Prime Claude tab **Files:** - Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` This is a substantial XAML rewrite. The body changes from a `ScrollViewer` of `StackPanels` to a `TabControl` with four `TabItem`s. Bindings change from `{Binding DefaultModel}` to `{Binding General.DefaultModel}`, etc. - [ ] **Step 1: Replace the body `ScrollViewer` (lines 83–283 in current file) with a `TabControl`** Replace the entire ` ... ` block with the structure below. Keep the Title bar (Grid.Row=0) and Footer (Grid.Row=2) unchanged. ```xml