Files
ClaudeDo/docs/superpowers/plans/2026-06-03-daily-prep.md
2026-06-04 08:42:41 +02:00

31 KiB

Daily Prep ("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: Turn the Prime Time warm-up into a daily preparation where Claude reads open tasks and moves an effort-aware, capped subset into MyDay, triggered by the Prime schedule and a manual button.

Architecture: Agentic. Two new tools on the always-on ExternalMcpService (get_daily_prep_candidates, set_my_day with a server-side cap-guard). The existing PrimeRunner is rewritten to launch a headless claude -p run with a fixed parameterized prompt and --allowedTools for those two tools, relying on the already-registered claudedo MCP (no separate --mcp-config). A new DailyPrepMaxTasks app setting drives the cap. A manual hub method reuses the same runner with a single-flight guard.

Tech Stack: .NET 8, ASP.NET Core, EF Core (SQLite), SignalR, ModelContextProtocol, Avalonia (CommunityToolkit.Mvvm), xUnit.

Spec: docs/superpowers/specs/2026-06-03-daily-prep-design.md


Deviation from spec (deliberate, to minimize churn)

The spec proposed renaming IPrimeRunner/PrimeRunner/PrimeSchedulerDailyPrep*. We keep the existing names and the FireAsync(PrimeScheduleDto, ct) signature and only rewrite the runner body. This avoids touching the scheduler, DI registration, IPrimeBroadcaster, and the existing Prime tests for a pure rename. The per-schedule PromptOverride field becomes unused by the runner (left in the DB/UI untouched).

Build & test commands (this repo)

.slnx needs .NET 9; on .NET 8 build/test individual projects. Use -c Release if a running Worker locks Debug.

dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release

Tests use real SQLite + real git (project convention). Mirror the setup already present in the test file you are extending.


File Structure

Create

  • src/ClaudeDo.Data/Migrations/<timestamp>_DailyPrepMaxTasks.cs (+ Designer, via dotnet ef)
  • src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs — pure prompt + args builder (easy to unit-test)

Modify

  • src/ClaudeDo.Data/Models/AppSettingsEntity.cs — add DailyPrepMaxTasks
  • src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs — map column
  • src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs — persist field in UpdateAsync
  • src/ClaudeDo.Worker/External/ExternalMcpService.cs — add 2 tools + DTOs
  • src/ClaudeDo.Worker/Prime/PrimeRunner.cs — rewrite body to daily prep + single-flight
  • src/ClaudeDo.Worker/Hub/WorkerHub.cs — add DailyPrepMaxTasks to AppSettings DTO + RunDailyPrepNow
  • src/ClaudeDo.Ui/Services/WorkerClient.cs — mirror DailyPrepMaxTasks in the UI AppSettings DTO + add RunDailyPrepNow call
  • src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs (+ its view) — numeric editor for DailyPrepMaxTasks
  • MyDay list header view + its ViewModel — "Tag vorbereiten" button + command

Test

  • tests/ClaudeDo.Data.Tests/...AppSettings... — new field persists / default 5
  • tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs — candidate filter + set_my_day + cap-guard
  • tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs — prompt/args content
  • tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs (if present) — single-flight + success/failure via IClaudeProcess fake

Task 1: DailyPrepMaxTasks app setting

Files:

  • Modify: src/ClaudeDo.Data/Models/AppSettingsEntity.cs

  • Modify: src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs

  • Modify: src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs

  • Create (via dotnet ef): src/ClaudeDo.Data/Migrations/<timestamp>_DailyPrepMaxTasks.cs

  • Test: tests/ClaudeDo.Data.Tests (extend existing AppSettings repository test, or add AppSettingsRepositoryTests.cs)

  • Step 1: Write the failing test

In a Data.Tests file (mirror the existing repo test harness that opens a real SQLite ClaudeDoDbContext):

[Fact]
public async Task DailyPrepMaxTasks_defaults_to_5_and_persists()
{
    await using var ctx = NewContext(); // existing helper that migrates a temp sqlite db
    var repo = new AppSettingsRepository(ctx);

    var initial = await repo.GetAsync();
    Assert.Equal(5, initial.DailyPrepMaxTasks);

    initial.DailyPrepMaxTasks = 8;
    await repo.UpdateAsync(initial);

    var reloaded = await repo.GetAsync();
    Assert.Equal(8, reloaded.DailyPrepMaxTasks);
}
  • Step 2: Run it — expect FAIL (AppSettingsEntity has no DailyPrepMaxTasks).
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter DailyPrepMaxTasks_defaults_to_5_and_persists
  • Step 3: Add the property to AppSettingsEntity.cs after StandupWeekday:
    // Max number of open tasks the daily prep ("Prime Claude") may place in MyDay.
    public int DailyPrepMaxTasks { get; set; } = 5;
  • Step 4: Map the column in AppSettingsEntityConfiguration.cs, after the StandupWeekday mapping (before builder.HasData(...)):
        builder.Property(s => s.DailyPrepMaxTasks)
            .HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5);
  • Step 5: Persist it in AppSettingsRepository.UpdateAsync, after the StandupWeekday assignment:
        row.DailyPrepMaxTasks = updated.DailyPrepMaxTasks < 1 ? 1 : updated.DailyPrepMaxTasks;
  • Step 6: Generate the migration (regenerates the model snapshot — do NOT hand-edit the snapshot):
dotnet ef migrations add DailyPrepMaxTasks \
  -p src/ClaudeDo.Data/ClaudeDo.Data.csproj \
  -s src/ClaudeDo.Worker/ClaudeDo.Worker.csproj

Verify the generated Up contains an AddColumn<int>("daily_prep_max_tasks", ... defaultValue: 5) and an UpdateData setting the singleton row's daily_prep_max_tasks to 5. If dotnet ef is unavailable, hand-write the migration mirroring 20260603072822_WeeklyReport.cs and add the matching Property<int>("DailyPrepMaxTasks").HasColumnName("daily_prep_max_tasks") line to ClaudeDoDbContextModelSnapshot.cs under the AppSettingsEntity builder.

  • Step 7: Run the test — expect PASS.

  • Step 8: Commit.

git add src/ClaudeDo.Data tests/ClaudeDo.Data.Tests
git commit -m "feat(daily-prep): add DailyPrepMaxTasks app setting"

Task 2: get_daily_prep_candidates MCP tool

Files:

  • Modify: src/ClaudeDo.Worker/External/ExternalMcpService.cs
  • Test: tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs

Read ExternalMcpServiceTests.cs first and reuse its existing harness (how it builds an ExternalMcpService with a real SQLite context, ListRepository, TaskRepository, fake HubBroadcaster, etc.). The new tool reads all lists/tasks itself via the injected _dbFactory, so it needs no new constructor args.

  • Step 1: Write the failing test. Seed: a list with WorkingDir = @"D:\work\repo" holding two Idle tasks (one blocked, one not) and one Done task; a second list with WorkingDir = @"C:\Private\secret" holding one Idle task; a third list with WorkingDir = null holding one Idle task; and one Idle task with IsMyDay = true in the first list. Set AppSettings.ReportExcludedPaths = "[\"C:\\\\Private\"]".
[Fact]
public async Task GetDailyPrepCandidates_filters_by_status_block_and_excluded_repo()
{
    // ... seed as described, using the file's existing seed helpers ...
    var svc = NewService();

    var result = await svc.GetDailyPrepCandidates(CancellationToken.None);

    // Only the non-blocked, Idle, non-MyDay task in the non-excluded repo is a candidate.
    Assert.Single(result.Candidates);
    Assert.Equal("idle-unblocked", result.Candidates[0].Id);
    // The Idle MyDay task is reported separately, not as a candidate.
    Assert.Single(result.CurrentMyDay);
    Assert.Equal(1, result.MaxTasks > 0 ? 1 : 1); // MaxTasks comes from AppSettings (default 5)
    Assert.Equal(5, result.MaxTasks);
}
  • Step 2: Run it — expect FAIL (method missing).

  • Step 3: Add the DTOs near the other record declarations at the top of ExternalMcpService.cs:

public sealed record DailyPrepCandidateDto(
    string Id, string ListId, string ListName, string Title, string? Description,
    bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt);

public sealed record DailyPrepDataDto(
    int MaxTasks,
    IReadOnlyList<DailyPrepCandidateDto> Candidates,
    IReadOnlyList<DailyPrepCandidateDto> CurrentMyDay);
  • Step 4: Add the tool method to the ExternalMcpService class body:
[McpServerTool, Description(
    "Daily prep: returns the open tasks eligible for today's MyDay selection. " +
    "candidates = Idle, not blocked, in a git repo not excluded from the weekly report, and not already in MyDay. " +
    "currentMyDay = Idle tasks already flagged IsMyDay (count them toward the cap). " +
    "maxTasks = the hard cap on total open MyDay tasks. Use set_my_day to add tasks (never exceed maxTasks).")]
public async Task<DailyPrepDataDto> GetDailyPrepCandidates(CancellationToken cancellationToken)
{
    await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);

    var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
    var excludes = DailyPrepFilter.ParseExcludes(settings.ReportExcludedPaths);
    var maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;

    var idle = await ctx.Tasks
        .AsNoTracking()
        .Include(t => t.List)
        .Where(t => t.Status == TaskStatus.Idle)
        .ToListAsync(cancellationToken);

    var currentMyDay = idle
        .Where(t => t.IsMyDay)
        .OrderBy(t => t.SortOrder)
        .Select(ToCandidate)
        .ToList();

    var candidates = idle
        .Where(t => !t.IsMyDay
                    && t.BlockedByTaskId == null
                    && DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes))
        .OrderBy(t => t.CreatedAt)
        .Select(ToCandidate)
        .ToList();

    return new DailyPrepDataDto(maxTasks, candidates, currentMyDay);
}

private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new(
    t.Id, t.ListId, t.List?.Name ?? "", t.Title, t.Description,
    t.IsStarred, t.ScheduledFor, t.CreatedAt);
  • Step 5: Add the filter helper as a small static class at the bottom of ExternalMcpService.cs (single-consumer helper lives beside its consumer, per repo convention):
internal static class DailyPrepFilter
{
    public static string[] ParseExcludes(string? json)
    {
        if (string.IsNullOrWhiteSpace(json)) return [];
        try
        {
            var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
            return list is null ? [] : list.Select(Normalize).Where(p => p.Length > 0).ToArray();
        }
        catch (System.Text.Json.JsonException) { return []; }
    }

    public static bool IsIncludedRepo(string? workingDir, string[] excludes)
    {
        if (string.IsNullOrWhiteSpace(workingDir)) return false; // not a repo → excluded
        var norm = Normalize(workingDir);
        return !excludes.Any(p => norm.StartsWith(p, StringComparison.OrdinalIgnoreCase));
    }

    private static string Normalize(string path) =>
        path.Trim().Replace('/', '\\').TrimEnd('\\');
}

Add using ClaudeDo.Data.Repositories; if not already present (it is, via existing usings).

  • Step 6: Run the test — expect PASS.

  • Step 7: Commit.

git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "feat(daily-prep): add get_daily_prep_candidates MCP tool"

Task 3: set_my_day MCP tool with cap-guard

Files:

  • Modify: src/ClaudeDo.Worker/External/ExternalMcpService.cs

  • Test: tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs

  • Step 1: Write the failing tests.

[Fact]
public async Task SetMyDay_sets_flag_and_sort_order()
{
    var svc = NewService();
    var id = await SeedIdleTask("My task"); // existing/added helper returning task id

    var dto = await svc.SetMyDay(id, isMyDay: true, sortOrder: 3, CancellationToken.None);

    Assert.True(dto.IsMyDay);
    Assert.Equal(3, dto.SortOrder);
}

[Fact]
public async Task SetMyDay_rejects_when_cap_reached()
{
    // AppSettings.DailyPrepMaxTasks = 1 (set in seed)
    var svc = NewService();
    var first = await SeedIdleTask("a");
    var second = await SeedIdleTask("b");
    await svc.SetMyDay(first, true, null, CancellationToken.None);

    var ex = await Assert.ThrowsAsync<InvalidOperationException>(
        () => svc.SetMyDay(second, true, null, CancellationToken.None));
    Assert.Contains("limit", ex.Message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task SetMyDay_unset_is_always_allowed()
{
    var svc = NewService();
    var id = await SeedIdleTask("a");
    await svc.SetMyDay(id, true, null, CancellationToken.None);

    var dto = await svc.SetMyDay(id, false, null, CancellationToken.None);
    Assert.False(dto.IsMyDay);
}

SetMyDay returns the existing TaskDto. Add a SortOrder field to TaskDto — see Step 3a. (SeedIdleTask / the DailyPrepMaxTasks=1 seed reuse the file's existing seeding helpers.)

  • Step 2: Run — expect FAIL.

  • Step 3a: Add SortOrder to TaskDto (record + ToDto) so the result reflects ordering:

In the TaskDto record add int SortOrder as the last positional member, and in ToDto(TaskEntity t) add t.SortOrder as the last argument. (Update any test that constructs TaskDto positionally — search the test project.)

  • Step 3b: Add the tool method:
[McpServerTool, Description(
    "Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " +
    "(use consecutive sortOrder values to keep related tasks together). " +
    "Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " +
    "clearing (isMyDay=false) is always allowed.")]
public async Task<TaskDto> SetMyDay(
    string taskId,
    bool isMyDay,
    int? sortOrder,
    CancellationToken cancellationToken)
{
    await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);

    var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken)
        ?? throw new InvalidOperationException($"Task {taskId} not found.");

    if (isMyDay && !task.IsMyDay)
    {
        var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
        var max = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
        var openMyDay = await ctx.Tasks.CountAsync(
            t => t.IsMyDay && t.Status == TaskStatus.Idle, cancellationToken);
        if (openMyDay >= max)
            throw new InvalidOperationException(
                $"MyDay limit {max} reached. Clear a task before adding another.");
    }

    task.IsMyDay = isMyDay;
    if (sortOrder is not null) task.SortOrder = sortOrder.Value;
    await ctx.SaveChangesAsync(cancellationToken);

    await _broadcaster.TaskUpdated(taskId);
    return ToDto(task);
}
  • Step 4: Run — expect PASS.

  • Step 5: Commit.

git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests
git commit -m "feat(daily-prep): add set_my_day MCP tool with cap-guard"

Task 4: Rewrite PrimeRunner to run the daily prep

Files:

  • Create: src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs
  • Modify: src/ClaudeDo.Worker/Prime/PrimeRunner.cs
  • Test: tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs, and extend PrimeRunnerTests.cs if it exists

The runner needs the cap X (read from AppSettings) and today's date. Inject IDbContextFactory<ClaudeDoDbContext> into PrimeRunner (it is resolvable in the main app DI) and an IPrimeClock for the date (already registered).

  • Step 1: Write failing prompt/args tests.
public class DailyPrepPromptTests
{
    [Fact]
    public void Build_prompt_contains_cap_and_date()
    {
        var prompt = DailyPrepPrompt.BuildPrompt(maxTasks: 5, today: new DateOnly(2026, 6, 3));
        Assert.Contains("5", prompt);
        Assert.Contains("2026-06-03", prompt);
        Assert.Contains("get_daily_prep_candidates", prompt);
        Assert.Contains("set_my_day", prompt);
    }

    [Fact]
    public void Build_args_allows_only_the_two_tools()
    {
        var args = DailyPrepPrompt.BuildArgs(maxTurns: 30);
        Assert.Contains("--output-format stream-json", args);
        Assert.Contains("--max-turns 30", args);
        Assert.Contains("--allowedTools", args);
        Assert.Contains("mcp__claudedo__get_daily_prep_candidates", args);
        Assert.Contains("mcp__claudedo__set_my_day", args);
    }
}
  • Step 2: Run — expect FAIL.

  • Step 3: Create DailyPrepPrompt.cs:

namespace ClaudeDo.Worker.Prime;

public static class DailyPrepPrompt
{
    public const string CandidatesTool = "mcp__claudedo__get_daily_prep_candidates";
    public const string SetMyDayTool = "mcp__claudedo__set_my_day";

    public static string BuildArgs(int maxTurns) =>
        "-p --output-format stream-json --verbose --permission-mode acceptEdits " +
        $"--max-turns {maxTurns} " +
        $"--allowedTools {CandidatesTool} {SetMyDayTool}";

    public static string BuildPrompt(int maxTasks, DateOnly today) =>
        $"""
        Du bereitest meinen Arbeitstag fuer {today:yyyy-MM-dd} vor.

        1. Rufe {CandidatesTool} auf.
        2. Behalte bereits als MyDay markierte offene Tasks (currentMyDay) — entferne sie nicht.
        3. Fuelle bis maximal {maxTasks} offene Tasks GESAMT in MyDay auf (currentMyDay zaehlt mit). Niemals mehr.
        4. Schaetze pro Kandidat grob den Aufwand und waehle eine machbare Mischung (nicht nur Grossbrocken).
           Priorisiere isStarred, faellige (scheduledFor) und aeltere Tasks.
        5. Lege thematisch verwandte Tasks durch aufeinanderfolgende sortOrder-Werte nebeneinander.
        6. Setze die Auswahl via {SetMyDayTool}(taskId, true, sortOrder). Markiere nichts ausserhalb der Kandidatenliste.

        Wenn es keine Kandidaten gibt, tue nichts.
        """;
}
  • Step 4: Run prompt tests — expect PASS.

  • Step 5: Rewrite PrimeRunner.cs:

using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;

namespace ClaudeDo.Worker.Prime;

public sealed class PrimeRunner : IPrimeRunner
{
    private static readonly TimeSpan FireTimeout = TimeSpan.FromMinutes(5);
    private const int MaxTurns = 30;

    private readonly IClaudeProcess _claude;
    private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
    private readonly IPrimeClock _clock;
    private readonly ILogger<PrimeRunner> _logger;
    private readonly SemaphoreSlim _gate = new(1, 1);

    public PrimeRunner(
        IClaudeProcess claude,
        IDbContextFactory<ClaudeDoDbContext> dbFactory,
        IPrimeClock clock,
        ILogger<PrimeRunner> logger)
    {
        _claude = claude;
        _dbFactory = dbFactory;
        _clock = clock;
        _logger = logger;
    }

    public async Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
    {
        if (!await _gate.WaitAsync(0, ct))
            return new PrimeRunOutcome(false, "Daily prep already running");

        try
        {
            var cwd = Paths.AppDataRoot();
            Directory.CreateDirectory(cwd);

            int maxTasks;
            await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
            {
                var settings = await new AppSettingsRepository(dbCtx).GetAsync(ct);
                maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
            }

            var today = DateOnly.FromDateTime(_clock.Now.LocalDateTime);
            var prompt = DailyPrepPrompt.BuildPrompt(maxTasks, today);
            var args = DailyPrepPrompt.BuildArgs(MaxTurns);

            using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
            timeoutCts.CancelAfter(FireTimeout);

            var result = await _claude.RunAsync(
                arguments: args,
                prompt: prompt,
                workingDirectory: cwd,
                onStdoutLine: _ => Task.CompletedTask,
                ct: timeoutCts.Token);

            return result.IsSuccess
                ? new PrimeRunOutcome(true, "Daily prep complete")
                : new PrimeRunOutcome(false, $"exit code {result.ExitCode}");
        }
        catch (OperationCanceledException) when (!ct.IsCancellationRequested)
        {
            return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min");
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Daily prep run failed");
            return new PrimeRunOutcome(false, ex.Message);
        }
        finally
        {
            _gate.Release();
        }
    }
}
  • Step 6: Fix the DI registration is unchanged (AddSingleton<IPrimeRunner, PrimeRunner>() already works — the new ctor deps IDbContextFactory and IPrimeClock are registered). Build the Worker.
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
  • Step 7: Update/extend PrimeRunnerTests.cs (if present) to match the new ctor: construct PrimeRunner with a fake IClaudeProcess, a real temp-SQLite IDbContextFactory, a fake IPrimeClock, and a logger. Add:
[Fact]
public async Task FireAsync_returns_already_running_when_gate_held()
{
    var runner = NewRunner(claudeDelay: TimeSpan.FromSeconds(2));
    var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);

    var first = runner.FireAsync(schedule, CancellationToken.None);
    var second = await runner.FireAsync(schedule, CancellationToken.None);

    Assert.False(second.Success);
    Assert.Contains("already running", second.Message, StringComparison.OrdinalIgnoreCase);
    await first;
}

If no PrimeRunnerTests.cs exists, create one. The fake IClaudeProcess should optionally delay (to keep the gate held) and return a successful RunResult { ExitCode = 0, ResultMarkdown = "ok" }.

  • Step 8: Run — expect PASS.
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "DailyPrepPrompt|PrimeRunner"
  • Step 9: Commit.
git add src/ClaudeDo.Worker/Prime tests/ClaudeDo.Worker.Tests/Prime
git commit -m "feat(daily-prep): run daily prep from PrimeRunner via allowed MCP tools"

Task 5: Hub — RunDailyPrepNow + expose DailyPrepMaxTasks

Files:

  • Modify: src/ClaudeDo.Worker/Hub/WorkerHub.cs
  • Modify: src/ClaudeDo.Ui/Services/WorkerClient.cs

Read WorkerHub.cs first. It already exposes a GetAppSettings/UpdateAppSettings pair backed by a DTO record (the one carrying ReportExcludedPaths, StandupWeekday).

  • Step 1: Add DailyPrepMaxTasks to the hub AppSettings DTO record (the record near the top of WorkerHub.cs that lists ReportExcludedPaths). Add int DailyPrepMaxTasks as a member. In the read mapping (GetAppSettings, where row.ReportExcludedPaths is read) add row.DailyPrepMaxTasks; in the write mapping (UpdateAppSettings, where ReportExcludedPaths = dto.ReportExcludedPaths) add DailyPrepMaxTasks = dto.DailyPrepMaxTasks.

  • Step 2: Add the hub method. Inject IPrimeRunner and HubBroadcaster if the hub does not already have them (the hub is constructed by SignalR via DI; both are registered singletons). Then:

public async Task<bool> RunDailyPrepNow()
{
    var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
    var firedAt = DateTimeOffset.Now;
    var outcome = await _primeRunner.FireAsync(schedule, Context.ConnectionAborted);
    await _broadcaster.PrimeFired(Guid.Empty, outcome.Success, outcome.Message, firedAt);
    return outcome.Success;
}

Add using ClaudeDo.Worker.Prime; to WorkerHub.cs if missing.

Caution (memory): changing the WorkerHub constructor breaks hand-rolled hub-test fakes in ClaudeDo.Worker.Tests and possibly ClaudeDo.Ui.Tests. After editing, build the test projects and fix every new WorkerHub(...) / fake IWorkerClient construction the compiler flags.

  • Step 3: Mirror the DTO in the UI (WorkerClient.cs, the AppSettings DTO around line 498): add int DailyPrepMaxTasks to the record (same position as in the hub DTO). Add a RunDailyPrepNow client call:
public Task<bool> RunDailyPrepNowAsync() =>
    _connection.InvokeAsync<bool>("RunDailyPrepNow");

(Match the exact connection field/name and the async-wrapper style used by neighbouring calls like GenerateWeekReport.)

  • Step 4: Build Worker + App + test projects; fix any broken fakes.
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
  • Step 5: Commit.
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/WorkerClient.cs tests
git commit -m "feat(daily-prep): add RunDailyPrepNow hub method and expose DailyPrepMaxTasks"

Task 6: Settings UI — edit DailyPrepMaxTasks

Files:

  • Modify: src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs
  • Modify: the Prime Claude tab markup in src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
  • Modify: src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs (load/save wiring, where other AppSettings fields are mapped)

Read these three files first; mirror how an existing numeric AppSetting (e.g. MaxParallelExecutions or WorktreeAutoCleanupDays) is loaded from the hub DTO, bound, and saved back.

  • Step 1: Add an observable property to PrimeClaudeTabViewModel.cs:
[ObservableProperty] private int _dailyPrepMaxTasks = 5;
  • Step 2: Wire load/save in SettingsModalViewModel.cs: where the AppSettings DTO is read into the tabs, set PrimeClaude.DailyPrepMaxTasks = dto.DailyPrepMaxTasks;. Where the DTO is written, include DailyPrepMaxTasks = PrimeClaude.DailyPrepMaxTasks. (Use the exact tab property name for the Prime Claude tab in that VM.)

  • Step 3: Add the editor in the Prime Claude tab of SettingsModalView.axaml, near the schedule list:

<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
    <TextBlock Text="{x:Static loc:L.Settings_DailyPrepMaxTasks}" VerticalAlignment="Center"/>
    <NumericUpDown Minimum="1" Maximum="50" Increment="1" Width="100"
                   Value="{Binding PrimeClaude.DailyPrepMaxTasks}"/>
</StackPanel>

Add the Settings_DailyPrepMaxTasks key to both locales/en.json and locales/de.json (en: "Max tasks per day", de: "Max. Aufgaben pro Tag"). If the tab does not use localized labels yet, use a plain Text="Max tasks per day" string to match its current style.

  • Step 4: Build the App; smoke-build the UI.
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
  • Step 5: Commit.
git add src/ClaudeDo.Ui src/ClaudeDo.Localization
git commit -m "feat(daily-prep): add DailyPrepMaxTasks editor to Prime Claude settings"

Task 7: MyDay header — "Tag vorbereiten" button

Files:

  • Modify: the ViewModel backing the MyDay list view (the one that exposes the smart-list header/toolbar; find it under src/ClaudeDo.Ui/ViewModels/Islands/ — likely the tasks/list island VM that has access to IWorkerClient)
  • Modify: the corresponding view (.axaml) that renders the list header

Read the island VM + view first. Find where the active list is known to be smart:my-day so the button can be shown only there (mirror any existing conditional header content). The VM already holds a worker-client reference used by other commands (e.g. RunNow) — reuse it.

  • Step 1: Add the command to the island VM:
[RelayCommand]
private async Task PrepareDayAsync()
{
    await _workerClient.RunDailyPrepNowAsync();
}

(Use the VM's existing worker-client field name. The MyDay list refreshes automatically via the TaskUpdated broadcast the tools emit, so no manual reload is needed.)

  • Step 2: Add an IsMyDayList (or reuse existing selected-list) guard so the button only appears on the MyDay smart list. If the VM already exposes the selected list id, add:
public bool IsMyDayList => SelectedListId == "smart:my-day";

and raise its change notification wherever SelectedListId changes (mirror existing patterns; if a [NotifyPropertyChangedFor] or manual OnPropertyChanged is already used for the selection, add this property to it).

  • Step 3: Add the button to the list header in the view, visible only on MyDay:
<Button Content="{x:Static loc:L.MyDay_PrepareDay}"
        Command="{Binding PrepareDayCommand}"
        IsVisible="{Binding IsMyDayList}"/>

Add MyDay_PrepareDay to locales/en.json ("Prepare day") and locales/de.json ("Tag vorbereiten"), or a plain string if the view is not localized.

  • Step 4: Build the App.
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
  • Step 5: Manual smoke (cannot be unit-tested): start the Worker and App, open MyDay, click "Tag vorbereiten", confirm tasks appear (capped) and the button is hidden on other lists. Report results explicitly — do not claim UI success without running it.

  • Step 6: Commit.

git add src/ClaudeDo.Ui src/ClaudeDo.Localization
git commit -m "feat(daily-prep): add Prepare-day button to MyDay header"

Final verification

  • dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
  • dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
  • dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
  • dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
  • dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
  • End-to-end manual run: schedule fires (or button) → Claude calls the two tools → MyDay gets a capped subset; re-run keeps existing MyDay and tops up without exceeding the cap.

Notes / risks

  • Relies on the globally registered claudedo MCP (installer RegisterMcpStep). If absent, the prep run produces 0 changes — acceptable for v1.
  • --permission-mode acceptEdits + explicit --allowedTools pre-approves exactly the two tools so the headless run never blocks on a permission prompt.
  • The cap-guard counts Idle && IsMyDay tasks; it is the source of truth for the "never move everything in" invariant regardless of Claude's behavior.
  • Future phase (out of scope): external ticket sources (Jira) feed into get_daily_prep_candidates behind a task-source abstraction.