51 KiB
Prime Action Kind 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: Give every Prime Claude schedule a selectable action — Ping (open the usage window and nothing else, the new default), FillMyDay (today's daily prep), or Custom (the schedule's own prompt with the ClaudeDo MCP tools).
Architecture: A new PrimeActionKind enum on PrimeScheduleEntity (column action_kind, TEXT, default "ping") flows through PrimeScheduleDto into PrimeRunner.FireAsync, which branches to one of three prompt/args/logging combinations. The existing prompt_override column is reused with a kind-dependent role. The Settings UI gets a per-row radio group.
Tech Stack: .NET 8, EF Core (SQLite), SignalR, Avalonia 12 + CommunityToolkit.Mvvm, xUnit.
Spec: docs/superpowers/specs/2026-08-24-prime-action-kind-design.md
File Structure
Create:
src/ClaudeDo.Data/Models/PrimeActionKind.cs— the enumsrc/ClaudeDo.Data/Migrations/<timestamp>_AddPrimeActionKind.cs— scaffoldedsrc/ClaudeDo.Worker/Prime/PrimePrompts.cs— Ping/Custom prompt + args builders (DailyPrepPromptstays as-is for FillMyDay)src/ClaudeDo.Worker/Prime/PrimeScheduleValidation.cs— pure validation, callable from the hub and unit-testable without constructingWorkerHubtests/ClaudeDo.Worker.Tests/Prime/PrimeScheduleValidationTests.cstests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs
Modify:
src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs—Kindpropertysrc/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs— column + convertersrc/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs—UpsertAsynccopiesKindsrc/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs—Kindmembersrc/ClaudeDo.Worker/Prime/PrimeScheduler.cs—ToDtomapsKindsrc/ClaudeDo.Worker/Prime/PrimeRunner.cs— three-way branchsrc/ClaudeDo.Worker/Hub/WorkerHub.cs—ListPrimeSchedules,UpsertPrimeSchedule,RunDailyPrepNowsrc/ClaudeDo.Ui/Services/PrimeScheduleDto.cs—Kindmembersrc/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs—Kind+ radio booleans + prompt label/placeholdersrc/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs— defaultPing,AnyFillMyDay, validationsrc/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml— radio group, conditional prompt box, gated max-tasks fieldsrc/ClaudeDo.Localization/locales/en.json+de.json— newsettings.prime.*keystests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs— existing DTO construction + new per-kind teststests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs— existing DTO construction + new testssrc/ClaudeDo.Data/CLAUDE.md,src/ClaudeDo.Worker/CLAUDE.md— doc updates
Staging rule for every commit in this plan: stage the listed files by path. Never git add -A — parallel sessions leave unrelated WIP in this tree (git status at plan time already showed modified UsageMonitorModalViewModel.cs, TaskStateService.cs and others that are not ours).
Task 1: The PrimeActionKind enum and entity column
Files:
-
Create:
src/ClaudeDo.Data/Models/PrimeActionKind.cs -
Modify:
src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs -
Modify:
src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs -
Modify:
src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs:24-38 -
Test:
tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs -
Step 1: Write the failing test
Create 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();
private static PrimeScheduleEntity Row(PrimeActionKind kind) => new()
{
Id = Guid.NewGuid(),
Days = PrimeDays.Weekdays,
TimeOfDay = new TimeSpan(7, 0, 0),
Enabled = true,
Kind = kind,
};
[Fact]
public void Kind_defaults_to_ping_on_a_new_entity()
{
Assert.Equal(PrimeActionKind.Ping, new PrimeScheduleEntity().Kind);
}
[Fact]
public async Task Kind_round_trips_through_the_database()
{
await using var ctx = await _db.CreateFactory().CreateDbContextAsync();
var repo = new PrimeScheduleRepository(ctx);
var row = Row(PrimeActionKind.Custom);
await repo.UpsertAsync(row);
var reloaded = await repo.GetAsync(row.Id);
Assert.NotNull(reloaded);
Assert.Equal(PrimeActionKind.Custom, reloaded!.Kind);
}
[Fact]
public async Task UpsertAsync_updates_kind_on_an_existing_row()
{
await using var ctx = await _db.CreateFactory().CreateDbContextAsync();
var repo = new PrimeScheduleRepository(ctx);
var row = Row(PrimeActionKind.Ping);
await repo.UpsertAsync(row);
await repo.UpsertAsync(new PrimeScheduleEntity
{
Id = row.Id,
Days = row.Days,
TimeOfDay = row.TimeOfDay,
Enabled = row.Enabled,
Kind = PrimeActionKind.FillMyDay,
});
var reloaded = await repo.GetAsync(row.Id);
Assert.Equal(PrimeActionKind.FillMyDay, reloaded!.Kind);
}
}
- Step 2: Run the test to verify it fails
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~PrimeScheduleRepositoryTests
Expected: compile error — PrimeScheduleEntity has no Kind, PrimeActionKind does not exist.
- Step 3: Add the enum
Create src/ClaudeDo.Data/Models/PrimeActionKind.cs:
namespace ClaudeDo.Data.Models;
/// <summary>What a Prime schedule actually does when it fires.</summary>
public enum PrimeActionKind
{
/// <summary>One throwaway turn that only opens the usage window. Default for new schedules.</summary>
Ping,
/// <summary>Run the daily prep prompt and let Claude fill MyDay.</summary>
FillMyDay,
/// <summary>Run the schedule's own prompt with the ClaudeDo MCP tools available.</summary>
Custom
}
- Step 4: Add the entity property
In src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs, add after Days:
public PrimeActionKind Kind { get; set; } = PrimeActionKind.Ping;
- Step 5: Map the column
In src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs, add the converter above Configure (this file currently has no converters — add the using for Microsoft.EntityFrameworkCore.Storage.ValueConversion, matching WorktreeEntityConfiguration):
private static string KindToString(PrimeActionKind v)
=> v == PrimeActionKind.Ping ? "ping"
: v == PrimeActionKind.FillMyDay ? "fill_my_day"
: v == PrimeActionKind.Custom ? "custom"
: throw new ArgumentOutOfRangeException(nameof(v));
private static PrimeActionKind KindFromString(string v)
=> v == "ping" ? PrimeActionKind.Ping
: v == "fill_my_day" ? PrimeActionKind.FillMyDay
: v == "custom" ? PrimeActionKind.Custom
: throw new ArgumentOutOfRangeException(nameof(v));
private static readonly ValueConverter<PrimeActionKind, string> KindConverter =
new(v => KindToString(v), v => KindFromString(v));
And inside Configure, after the days_of_week property:
builder.Property(s => s.Kind).HasColumnName("action_kind")
.IsRequired().HasDefaultValue(PrimeActionKind.Ping)
.HasConversion(KindConverter);
- Step 6: Copy
Kindin the repository upsert
In src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs, inside the else branch of UpsertAsync, add:
existing.Kind = entity.Kind;
This branch lists every field explicitly — a missing one is silent, the update just keeps the old value.
- Step 7: Run the tests to verify they pass
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~PrimeScheduleRepositoryTests
Expected: 3 passed. (DbFixture uses EnsureCreated, so the column comes from the model, not the migration — Task 2 covers the migration.)
- Step 8: Commit
git commit -m "feat(prime): add PrimeActionKind to the schedule entity" -- \
src/ClaudeDo.Data/Models/PrimeActionKind.cs \
src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs \
src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs \
src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs \
tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs
Task 2: The EF migration
Files:
-
Create:
src/ClaudeDo.Data/Migrations/<timestamp>_AddPrimeActionKind.cs(+.Designer.cs) -
Modify:
src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs -
Step 1: Confirm the current migration head
ls src/ClaudeDo.Data/Migrations/ | grep -v Designer | tail -3
Expected at plan time: 20260821155619_AddUsageLimitAutoContinue.cs is the newest. If a newer one appeared, that is fine — but do not scaffold if another uncommitted migration is sitting in the tree from a parallel session: two migrations off the same parent silently drop each other's columns when SQLite rebuilds the table. If you see one, stop and report it.
- Step 2: Scaffold the migration
dotnet ef migrations add AddPrimeActionKind --project src/ClaudeDo.Data --startup-project src/ClaudeDo.Worker
- Step 3: Verify the generated
Up
Open the new <timestamp>_AddPrimeActionKind.cs. It must be exactly one AddColumn:
migrationBuilder.AddColumn<string>(
name: "action_kind",
table: "prime_schedules",
type: "TEXT",
nullable: false,
defaultValue: "ping");
Expected: existing rows get "ping". This is intentional — the user asked for existing schedules to become Ping. Do not add an UPDATE that backfills them to fill_my_day.
If the scaffold produced anything else (a table rebuild, extra columns), delete the migration files and re-scaffold — extra columns mean a sibling migration collided.
- Step 4: Build to confirm the snapshot compiles
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
Expected: Build succeeded.
- Step 5: Commit
git commit -m "feat(prime): migrate prime_schedules with action_kind defaulting to ping" -- \
src/ClaudeDo.Data/Migrations/
Task 3: PrimeRunner branches on the kind
Files:
-
Create:
src/ClaudeDo.Worker/Prime/PrimePrompts.cs -
Modify:
src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs -
Modify:
src/ClaudeDo.Worker/Prime/PrimeScheduler.cs:104-105 -
Modify:
src/ClaudeDo.Worker/Prime/PrimeRunner.cs:34-95 -
Test:
tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs -
Step 1: Extend the DTO and the mapper
PrimeActionKind is added as a 7th positional member with no default on purpose — every construction site must state its kind rather than silently inheriting one.
Replace the whole of src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs:
using ClaudeDo.Data.Models;
namespace ClaudeDo.Worker.Prime;
public sealed record PrimeScheduleDto(
Guid Id,
int Days,
TimeSpan TimeOfDay,
bool Enabled,
DateTimeOffset? LastRunAt,
string? PromptOverride,
PrimeActionKind Kind);
In src/ClaudeDo.Worker/Prime/PrimeScheduler.cs, ToDto becomes:
private static PrimeScheduleDto ToDto(Data.Models.PrimeScheduleEntity e) =>
new(e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride, e.Kind);
- Step 2: Update the existing tests to compile, and add the new failing tests
In tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs:
Replace DefaultSchedule() (line 74-75) — the existing tests all assert daily-prep behaviour, so they become explicitly FillMyDay:
private static PrimeScheduleDto DefaultSchedule() =>
new(Guid.Empty, 0, TimeSpan.Zero, true, null, null, PrimeActionKind.FillMyDay);
private static PrimeScheduleDto Schedule(PrimeActionKind kind, string? promptOverride = null) =>
new(Guid.Empty, 0, TimeSpan.Zero, true, null, promptOverride, kind);
Add using ClaudeDo.Data.Models; at the top of the file, and replace every inline
new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null) (lines 117, 136, 145)
with DefaultSchedule().
Extend FakeClaudeProcess so tests can inspect what the runner asked for — add these members to the class and record them at the top of RunAsync:
public IReadOnlyList<string>? LastArguments { get; private set; }
public string? LastPrompt { get; private set; }
LastArguments = arguments;
LastPrompt = prompt;
Now append the new tests:
[Fact]
public async Task Ping_runs_a_single_turn_with_no_mcp_servers()
{
var claude = new FakeClaudeProcess(exitCode: 0, result: "ok");
var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
var outcome = await runner.FireAsync(Schedule(PrimeActionKind.Ping), CancellationToken.None);
Assert.True(outcome.Success);
Assert.Contains("--strict-mcp-config", claude.LastArguments!);
var turnsIndex = claude.LastArguments!.ToList().IndexOf("--max-turns");
Assert.Equal("1", claude.LastArguments[turnsIndex + 1]);
Assert.DoesNotContain(DailyPrepPrompt.SetMyDayTool, claude.LastArguments);
}
[Fact]
public async Task Ping_writes_no_prep_log_and_raises_no_prep_events()
{
var path = DailyPrepPrompt.LogPath();
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, "previous fill-my-day run");
var broadcaster = new RecordingPrimeBroadcaster();
var claude = new FakeClaudeProcess(emitLines: ["ping-line"], exitCode: 0, result: "ok");
var runner = NewRunner(claude, broadcaster);
await runner.FireAsync(Schedule(PrimeActionKind.Ping), CancellationToken.None);
Assert.Equal("previous fill-my-day run", await File.ReadAllTextAsync(path));
Assert.Equal(0, broadcaster.StartedCount);
Assert.Empty(broadcaster.Lines);
Assert.Empty(broadcaster.FinishedResults);
}
[Fact]
public async Task Custom_sends_the_prompt_override_verbatim_with_claudedo_mcp_only()
{
var claude = new FakeClaudeProcess(exitCode: 0, result: "ok");
var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
var outcome = await runner.FireAsync(
Schedule(PrimeActionKind.Custom, "Queue the three oldest idle tasks."),
CancellationToken.None);
Assert.True(outcome.Success);
Assert.Equal("Queue the three oldest idle tasks.", claude.LastPrompt);
Assert.Contains("--allowedTools", claude.LastArguments!);
Assert.Contains("mcp__claudedo", claude.LastArguments!);
Assert.DoesNotContain("--strict-mcp-config", claude.LastArguments!);
}
[Fact]
public async Task Custom_writes_no_prep_log_and_raises_no_prep_events()
{
var path = DailyPrepPrompt.LogPath();
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, "previous fill-my-day run");
var broadcaster = new RecordingPrimeBroadcaster();
var claude = new FakeClaudeProcess(emitLines: ["custom-line"], exitCode: 0, result: "ok");
var runner = NewRunner(claude, broadcaster);
await runner.FireAsync(Schedule(PrimeActionKind.Custom, "do a thing"), CancellationToken.None);
Assert.Equal("previous fill-my-day run", await File.ReadAllTextAsync(path));
Assert.Equal(0, broadcaster.StartedCount);
Assert.Empty(broadcaster.FinishedResults);
}
[Fact]
public async Task FillMyDay_still_appends_the_prompt_override()
{
var claude = new FakeClaudeProcess(exitCode: 0, result: "ok");
var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
await runner.FireAsync(
Schedule(PrimeActionKind.FillMyDay, "Prefer the LogX tasks."),
CancellationToken.None);
Assert.Contains("Prefer the LogX tasks.", claude.LastPrompt);
Assert.Contains(DailyPrepPrompt.SetMyDayTool, claude.LastArguments!);
}
[Fact]
public async Task Gate_is_shared_across_kinds()
{
var runner = NewRunner(claudeDelay: TimeSpan.FromSeconds(2));
var first = runner.FireAsync(Schedule(PrimeActionKind.Ping), CancellationToken.None);
var second = await runner.FireAsync(Schedule(PrimeActionKind.FillMyDay), CancellationToken.None);
Assert.False(second.Success);
Assert.Contains("already running", second.Message, StringComparison.OrdinalIgnoreCase);
await first;
}
- Step 3: Run the tests to verify they fail
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~PrimeRunnerTests
Expected: the four new kind-specific tests fail — the runner ignores Kind and always does daily prep (log file overwritten, PrepStarted raised, daily-prep args).
- Step 4: Add the Ping/Custom prompt builders
Create src/ClaudeDo.Worker/Prime/PrimePrompts.cs:
namespace ClaudeDo.Worker.Prime;
/// <summary>Prompts and CLI args for the non-daily-prep Prime actions.
/// The daily-prep ones live in <see cref="DailyPrepPrompt"/>.</summary>
public static class PrimePrompts
{
/// <summary>The whole point of a ping is to open the usage window, so it asks for the
/// cheapest possible answer. Not user-editable — a schedule's prompt override is ignored
/// for this kind.</summary>
public const string PingPrompt = "Reply with the single word: ok";
/// <summary>No tools at all: --strict-mcp-config without a --mcp-config argument loads no
/// MCP server, and one turn leaves no room to act on a tool result anyway.</summary>
public static IReadOnlyList<string> BuildPingArgs() =>
[
"-p", "--output-format", "stream-json", "--verbose",
"--permission-mode", "default",
"--max-turns", "1",
"--strict-mcp-config",
];
/// <summary>ClaudeDo's MCP server only — no Read/Write/Edit/Bash. An unattended 07:00 run
/// may create and queue tasks; it may not touch the filesystem.</summary>
public static IReadOnlyList<string> BuildCustomArgs(int maxTurns) =>
[
"-p", "--output-format", "stream-json", "--verbose",
"--permission-mode", "acceptEdits",
"--max-turns", maxTurns.ToString(),
"--allowedTools", "mcp__claudedo",
];
}
- Step 5: Branch in
PrimeRunner
Replace the body of FireAsync in src/ClaudeDo.Worker/Prime/PrimeRunner.cs (keep the class fields, constructor, FireTimeout and MaxTurns as they are; add using ClaudeDo.Data.Models;):
public async Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
{
// One gate for all kinds — two schedules must never run at the same time.
if (!await _gate.WaitAsync(0, ct))
return new PrimeRunOutcome(false, "Prime run already in progress");
try
{
return schedule.Kind switch
{
PrimeActionKind.FillMyDay => await RunFillMyDayAsync(schedule, ct),
PrimeActionKind.Custom => await RunCustomAsync(schedule, ct),
_ => await RunPingAsync(ct),
};
}
finally
{
_gate.Release();
}
}
private async Task<PrimeRunOutcome> RunFillMyDayAsync(PrimeScheduleDto schedule, CancellationToken ct)
{
var success = false;
try
{
var logPath = DailyPrepPrompt.LogPath();
try { if (File.Exists(logPath)) File.Delete(logPath); } catch { /* best effort */ }
await using var logWriter = new LogWriter(logPath);
await _broadcaster.PrepStartedAsync();
var cwd = 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, schedule.PromptOverride);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(FireTimeout);
var result = await _claude.RunAsync(
arguments: DailyPrepPrompt.BuildArgs(MaxTurns),
prompt: prompt,
workingDirectory: cwd,
onStdoutLine: async line =>
{
await logWriter.WriteLineAsync(line);
await _broadcaster.PrepLineAsync(line);
},
ct: timeoutCts.Token);
success = result.IsSuccess;
return success
? 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
{
await _broadcaster.PrepFinishedAsync(success);
}
}
private Task<PrimeRunOutcome> RunPingAsync(CancellationToken ct) =>
RunQuietAsync(PrimePrompts.BuildPingArgs(), PrimePrompts.PingPrompt, "Ping complete", "Ping", ct);
private Task<PrimeRunOutcome> RunCustomAsync(PrimeScheduleDto schedule, CancellationToken ct)
{
var prompt = schedule.PromptOverride;
if (string.IsNullOrWhiteSpace(prompt))
return Task.FromResult(new PrimeRunOutcome(false, "Custom schedule has no prompt"));
return RunQuietAsync(PrimePrompts.BuildCustomArgs(MaxTurns), prompt, "Custom run complete", "Custom", ct);
}
/// <summary>Ping and Custom deliberately write no log file and raise no Prep* events — the
/// prep log belongs to the MyDay selection and would otherwise be overwritten with noise.</summary>
private async Task<PrimeRunOutcome> RunQuietAsync(
IReadOnlyList<string> args, string prompt, string successMessage, string label, CancellationToken ct)
{
try
{
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, successMessage)
: 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, "Prime {Label} run failed", label);
return new PrimeRunOutcome(false, ex.Message);
}
}
private static string Cwd()
{
var cwd = Paths.AppDataRoot();
Directory.CreateDirectory(cwd);
return cwd;
}
Note the gate message changed from "Daily prep already running" to "Prime run already in progress" — FireAsync_returns_already_running_when_gate_held asserts on the substring "already running", which still matches.
- Step 6: Run the tests to verify they pass
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~PrimeRunnerTests
Expected: all pass, including the five pre-existing ones.
- Step 7: Commit
git commit -m "feat(prime): branch PrimeRunner on the schedule action kind" -- \
src/ClaudeDo.Worker/Prime/PrimePrompts.cs \
src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs \
src/ClaudeDo.Worker/Prime/PrimeScheduler.cs \
src/ClaudeDo.Worker/Prime/PrimeRunner.cs \
tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs
Task 4: Hub wiring and upsert validation
Files:
- Create:
src/ClaudeDo.Worker/Prime/PrimeScheduleValidation.cs - Modify:
src/ClaudeDo.Worker/Hub/WorkerHub.cs:1029-1057(ListPrimeSchedules,UpsertPrimeSchedule) and:1071-1078(RunDailyPrepNow) - Test:
tests/ClaudeDo.Worker.Tests/Prime/PrimeScheduleValidationTests.cs
The validation lives in its own static class rather than inline in the hub because constructing WorkerHub in a test needs a dozen dependencies; a pure function needs none.
- Step 1: Write the failing test
Create tests/ClaudeDo.Worker.Tests/Prime/PrimeScheduleValidationTests.cs:
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Prime;
namespace ClaudeDo.Worker.Tests.Prime;
public class PrimeScheduleValidationTests
{
private static PrimeScheduleDto Dto(PrimeActionKind kind, string? prompt) =>
new(Guid.NewGuid(), 31, new TimeSpan(7, 0, 0), true, null, prompt, kind);
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Custom_without_a_prompt_is_rejected(string? prompt)
{
var error = PrimeScheduleValidation.Validate(Dto(PrimeActionKind.Custom, prompt));
Assert.NotNull(error);
}
[Fact]
public void Custom_with_a_prompt_is_accepted()
{
Assert.Null(PrimeScheduleValidation.Validate(Dto(PrimeActionKind.Custom, "do the thing")));
}
[Fact]
public void Ping_without_a_prompt_is_accepted()
{
Assert.Null(PrimeScheduleValidation.Validate(Dto(PrimeActionKind.Ping, null)));
}
[Fact]
public void FillMyDay_without_a_prompt_is_accepted()
{
Assert.Null(PrimeScheduleValidation.Validate(Dto(PrimeActionKind.FillMyDay, null)));
}
}
- Step 2: Run the test to verify it fails
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~PrimeScheduleValidationTests
Expected: compile error — PrimeScheduleValidation does not exist.
- Step 3: Write the validator
Create src/ClaudeDo.Worker/Prime/PrimeScheduleValidation.cs:
using ClaudeDo.Data.Models;
namespace ClaudeDo.Worker.Prime;
public static class PrimeScheduleValidation
{
/// <summary>Returns an error message, or null when the schedule is safe to persist.</summary>
public static string? Validate(PrimeScheduleDto dto)
{
if (dto.Kind == PrimeActionKind.Custom && string.IsNullOrWhiteSpace(dto.PromptOverride))
return "A custom Prime schedule needs a prompt.";
return null;
}
}
- Step 4: Run the test to verify it passes
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~PrimeScheduleValidationTests
Expected: 6 passed.
- Step 5: Wire the hub
In src/ClaudeDo.Worker/Hub/WorkerHub.cs:
ListPrimeSchedules — add e.Kind to the projection:
return rows.Select(e => new PrimeScheduleDto(
e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride, e.Kind)).ToList();
UpsertPrimeSchedule — validate first, set Kind on the entity, and return it:
public async Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto)
{
var error = PrimeScheduleValidation.Validate(dto);
if (error is not null) throw new HubException(error);
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,
Days = (ClaudeDo.Data.Models.PrimeDays)dto.Days,
TimeOfDay = dto.TimeOfDay,
Enabled = dto.Enabled,
PromptOverride = dto.PromptOverride,
Kind = dto.Kind,
CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow,
LastRunAt = existing?.LastRunAt,
};
await repo.UpsertAsync(entity);
_primeSignal.Signal();
return new PrimeScheduleDto(entity.Id, (int)entity.Days, entity.TimeOfDay,
entity.Enabled, entity.LastRunAt, entity.PromptOverride, entity.Kind);
}
HubException comes from Microsoft.AspNetCore.SignalR; check the file's usings and add it if missing.
RunDailyPrepNow — pin the synthetic schedule to FillMyDay. The "Plan day" button means "fill my day" regardless of how the schedules are configured:
var schedule = new PrimeScheduleDto(
Guid.Empty, 0, TimeSpan.Zero, true, null, null,
ClaudeDo.Data.Models.PrimeActionKind.FillMyDay);
- Step 6: Build and run the whole Worker suite
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
Expected: build succeeds, all tests pass. tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs:141-143 uses the Ui DTO in an IWorkerClient fake and only ever constructs empty lists, so it needs no change until Task 5 changes the Ui DTO — recheck it after that task.
- Step 7: Commit
git commit -m "feat(prime): carry the action kind through the hub and validate custom prompts" -- \
src/ClaudeDo.Worker/Prime/PrimeScheduleValidation.cs \
src/ClaudeDo.Worker/Hub/WorkerHub.cs \
tests/ClaudeDo.Worker.Tests/Prime/PrimeScheduleValidationTests.cs
Task 5: UI viewmodels
Files:
-
Modify:
src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs -
Modify:
src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs -
Modify:
src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs -
Test:
tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs -
Step 1: Extend the Ui DTO
ClaudeDo.Ui already references ClaudeDo.Data, so it uses the same enum — no duplicate. In src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs:
using ClaudeDo.Data.Models;
namespace ClaudeDo.Ui.Services;
public sealed record PrimeScheduleDto(
Guid Id,
int Days,
TimeSpan TimeOfDay,
bool Enabled,
DateTimeOffset? LastRunAt,
string? PromptOverride,
PrimeActionKind Kind);
Leave PrimeFiredEvent in the file unchanged.
- Step 2: Write the failing tests
In tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs, add using ClaudeDo.Data.Models; and replace the Dto helper (line 30-31):
private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time,
PrimeActionKind kind = PrimeActionKind.FillMyDay, string? prompt = null) =>
new(id, days, time, true, null, prompt, kind);
Append these tests to the class:
[Fact]
public void AddSchedule_Defaults_To_Ping()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
Assert.Equal(PrimeActionKind.Ping, vm.Rows[0].Kind);
Assert.True(vm.Rows[0].IsPing);
Assert.False(vm.Rows[0].IsFillMyDay);
Assert.False(vm.Rows[0].IsCustom);
}
[Fact]
public void Setting_IsCustom_Flips_Kind_And_Clears_The_Others()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
var row = vm.Rows[0];
row.IsCustom = true;
Assert.Equal(PrimeActionKind.Custom, row.Kind);
Assert.False(row.IsPing);
Assert.False(row.IsFillMyDay);
}
[Fact]
public void Row_RadioGroup_Is_Unique_Per_Row()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
vm.AddScheduleCommand.Execute(null);
Assert.NotEqual(vm.Rows[0].RadioGroup, vm.Rows[1].RadioGroup);
}
[Fact]
public void Validate_Rejects_Custom_Without_A_Prompt()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
vm.Rows[0].IsCustom = true;
vm.Rows[0].PromptOverride = " ";
Assert.NotNull(vm.Validate());
}
[Fact]
public void Validate_Accepts_Custom_With_A_Prompt()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
vm.Rows[0].IsCustom = true;
vm.Rows[0].PromptOverride = "queue the oldest task";
Assert.Null(vm.Validate());
}
[Fact]
public void AnyFillMyDay_Tracks_Kind_And_Row_Changes()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
Assert.False(vm.AnyFillMyDay);
vm.AddScheduleCommand.Execute(null);
Assert.False(vm.AnyFillMyDay);
vm.Rows[0].IsFillMyDay = true;
Assert.True(vm.AnyFillMyDay);
vm.RemoveScheduleCommand.Execute(vm.Rows[0]);
Assert.False(vm.AnyFillMyDay);
}
[Fact]
public async Task Save_Round_Trips_The_Kind()
{
var api = new FakeApi();
var vm = new PrimeClaudeTabViewModel(api);
vm.AddScheduleCommand.Execute(null);
vm.Rows[0].IsCustom = true;
vm.Rows[0].PromptOverride = "do the thing";
await vm.SaveAsync();
Assert.Equal(PrimeActionKind.Custom, api.Upserts[0].Kind);
}
- Step 3: Run the tests to verify they fail
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~PrimeClaudeTabViewModelTests
Expected: compile errors — Kind, IsPing, RadioGroup, AnyFillMyDay do not exist.
- Step 4: Extend the row viewmodel
In src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs, add using ClaudeDo.Data.Models; and using ClaudeDo.Ui.Localization;, then:
Add the observable property next to the others:
[ObservableProperty] private PrimeActionKind _kind;
Add the radio-binding members after TimeText:
/// <summary>Per-row group name. A shared GroupName would make every schedule in the
/// ItemsControl share one selection.</summary>
public string RadioGroup => $"prime-kind-{Id}";
// Three booleans rather than an enum-to-bool converter: RadioButton.IsChecked binds
// TwoWay to a bool, and compiled bindings stay simple.
public bool IsPing
{
get => Kind == PrimeActionKind.Ping;
set { if (value) Kind = PrimeActionKind.Ping; }
}
public bool IsFillMyDay
{
get => Kind == PrimeActionKind.FillMyDay;
set { if (value) Kind = PrimeActionKind.FillMyDay; }
}
public bool IsCustom
{
get => Kind == PrimeActionKind.Custom;
set { if (value) Kind = PrimeActionKind.Custom; }
}
public bool ShowPrompt => Kind != PrimeActionKind.Ping;
public string PromptLabel => Kind == PrimeActionKind.Custom
? Loc.T("settings.prime.customPromptLabel")
: Loc.T("settings.prime.promptOverrideLabel");
public string PromptPlaceholder => Kind == PrimeActionKind.Custom
? Loc.T("settings.prime.customPromptPlaceholder")
: Loc.T("settings.prime.promptOverridePlaceholder");
partial void OnKindChanged(PrimeActionKind value)
{
OnPropertyChanged(nameof(IsPing));
OnPropertyChanged(nameof(IsFillMyDay));
OnPropertyChanged(nameof(IsCustom));
OnPropertyChanged(nameof(ShowPrompt));
OnPropertyChanged(nameof(PromptLabel));
OnPropertyChanged(nameof(PromptPlaceholder));
}
In the constructor, after PromptOverride = dto.PromptOverride;:
Kind = dto.Kind;
And ToDto:
public PrimeScheduleDto ToDto() =>
new(Id, DaysMask(), TimeOfDay, Enabled, LastRunAt,
string.IsNullOrWhiteSpace(PromptOverride) ? null : PromptOverride, Kind);
- Step 5: Extend the tab viewmodel
In src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs, add
using System.Collections.Specialized;, using System.ComponentModel; and
using ClaudeDo.Data.Models;, then:
[ObservableProperty] private bool _anyFillMyDay;
public PrimeClaudeTabViewModel(IPrimeScheduleApi api)
{
_api = api;
Rows.CollectionChanged += OnRowsChanged;
}
private void OnRowsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.OldItems is not null)
foreach (PrimeScheduleRowViewModel r in e.OldItems) r.PropertyChanged -= OnRowChanged;
if (e.NewItems is not null)
foreach (PrimeScheduleRowViewModel r in e.NewItems) r.PropertyChanged += OnRowChanged;
UpdateAnyFillMyDay();
}
private void OnRowChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(PrimeScheduleRowViewModel.Kind)) UpdateAnyFillMyDay();
}
private void UpdateAnyFillMyDay() =>
AnyFillMyDay = Rows.Any(r => r.Kind == PrimeActionKind.FillMyDay);
Replace the existing expression-bodied constructor (line 17) with the block above.
In LoadAsync, unsubscribe before clearing — Clear() raises a Reset with no OldItems, so the handler above cannot detach them:
public async Task LoadAsync()
{
foreach (var r in Rows) r.PropertyChanged -= OnRowChanged;
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);
}
}
In Validate, add the custom-prompt rule inside the loop, after the time check:
if (r.Kind == PrimeActionKind.Custom && string.IsNullOrWhiteSpace(r.PromptOverride))
return $"Schedule {r.TimeOfDay:hh\\:mm}: a custom action needs a prompt.";
In AddSchedule, the new DTO gains the kind:
var dto = new PrimeScheduleDto(
Id: Guid.NewGuid(),
Days: 31, // Mon–Fri
TimeOfDay: new TimeSpan(7, 0, 0),
Enabled: true,
LastRunAt: null,
PromptOverride: null,
Kind: PrimeActionKind.Ping);
- Step 6: Run the tests to verify they pass
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
Expected: build succeeds, all Ui tests pass. If tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs:155-157 or tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs:141-143 fail to compile, they construct a PrimeScheduleDto positionally — add PrimeActionKind.Ping as the last argument. (At plan time both only build empty lists and need no change.)
Note on flakiness: Ui.Tests has a known order-dependent failure mode. If a test outside PrimeClaudeTabViewModelTests fails, re-run that test alone before blaming this change.
- Step 7: Commit
git commit -m "feat(prime): add the action kind to the settings viewmodels" -- \
src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs \
src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs \
src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs \
tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs
Task 6: Locale keys
Files:
-
Modify:
src/ClaudeDo.Localization/locales/en.json:90-104(thesettings.primeblock) -
Modify:
src/ClaudeDo.Localization/locales/de.json:90-104 -
Step 1: Add the English keys
In en.json, inside settings.prime, after "dailyPrepMaxTasks":
"actionLabel": "Action",
"actionPing": "Ping",
"actionFillMyDay": "Fill My Day",
"actionCustom": "Custom prompt",
"actionHint": "Ping only opens the usage window. Fill My Day runs the daily prep. Custom runs your own prompt with the ClaudeDo tools.",
"customPromptLabel": "Prompt",
"customPromptPlaceholder": "The full prompt to run for this schedule (required)",
"maxTasksDisabledHint": "Only applies to Fill My Day schedules.",
Also update the tab description, which currently claims every schedule is a ping:
"description": "Run a single non-interactive Claude action on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, it fires immediately.",
- Step 2: Add the German keys
In de.json, inside settings.prime, same positions:
"actionLabel": "Aktion",
"actionPing": "Ping",
"actionFillMyDay": "Mein Tag füllen",
"actionCustom": "Eigener Prompt",
"actionHint": "Ping öffnet nur das Nutzungsfenster. „Mein Tag füllen“ startet die Tagesplanung. „Eigener Prompt“ führt deinen eigenen Prompt mit den ClaudeDo-Tools aus.",
"customPromptLabel": "Prompt",
"customPromptPlaceholder": "Der vollständige Prompt für diesen Zeitplan (Pflichtfeld)",
"maxTasksDisabledHint": "Gilt nur für Zeitpläne mit „Mein Tag füllen“.",
"description": "Führt an den von dir gewählten Tagen zu einer bestimmten Zeit eine einzelne nicht-interaktive Claude-Aktion aus. Läuft nur, solange ClaudeDo geöffnet ist. Wenn die App innerhalb von 30 Minuten vor der Zielzeit startet, wird sofort ausgelöst.",
- Step 3: Run the parity test
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
Expected: all pass. A failure here means an en/de key mismatch — fix the missing side.
- Step 4: Commit
git commit -m "feat(i18n): add Prime action-kind strings in en and de" -- \
src/ClaudeDo.Localization/locales/en.json \
src/ClaudeDo.Localization/locales/de.json
Task 7: Settings view — radio group and conditional fields
Files:
- Modify:
src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml:300-316
There is no headless test for this — the verification is a build plus the user's visual pass.
- Step 1: Add the radio group and make the prompt box conditional
Replace lines 301-305 (the field-label TextBlock and the prompt TextBox inside the schedule DataTemplate) with:
<TextBlock Classes="field-label" Text="{loc:Tr settings.prime.actionLabel}"/>
<StackPanel Orientation="Horizontal" Spacing="16">
<RadioButton GroupName="{Binding RadioGroup}"
Content="{loc:Tr settings.prime.actionPing}"
IsChecked="{Binding IsPing, Mode=TwoWay}"/>
<RadioButton GroupName="{Binding RadioGroup}"
Content="{loc:Tr settings.prime.actionFillMyDay}"
IsChecked="{Binding IsFillMyDay, Mode=TwoWay}"/>
<RadioButton GroupName="{Binding RadioGroup}"
Content="{loc:Tr settings.prime.actionCustom}"
IsChecked="{Binding IsCustom, Mode=TwoWay}"/>
</StackPanel>
<TextBlock Classes="field-label" Text="{Binding PromptLabel}"
IsVisible="{Binding ShowPrompt}"/>
<TextBox Text="{Binding PromptOverride, Mode=TwoWay}"
PlaceholderText="{Binding PromptPlaceholder}"
IsVisible="{Binding ShowPrompt}"
AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="48" MaxHeight="120"/>
PlaceholderText stays — it is the same property the file already uses on this TextBox; only
the value changes from the {loc:Tr} markup extension to a binding, because the string now
depends on the selected kind.
- Step 2: Add the action hint under the description
After the existing description TextBlock (line 270-271):
<TextBlock Classes="meta" TextWrapping="Wrap"
Text="{loc:Tr settings.prime.actionHint}"/>
- Step 3: Gate the max-tasks field
Replace lines 312-316 with:
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Classes="field-label" Text="{loc:Tr settings.prime.dailyPrepMaxTasks}" VerticalAlignment="Center"/>
<NumericUpDown Minimum="1" Maximum="50" Increment="1" Width="100" FormatString="0"
IsEnabled="{Binding Prime.AnyFillMyDay}"
Value="{Binding Prime.DailyPrepMaxTasks, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"/>
<TextBlock Classes="meta" VerticalAlignment="Center"
Text="{loc:Tr settings.prime.maxTasksDisabledHint}"
IsVisible="{Binding !Prime.AnyFillMyDay}"/>
</StackPanel>
- Step 4: Build
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
Expected: Build succeeded. A compiled-binding error here names the property it could not resolve — check it against Task 5.
- Step 5: Commit
git commit -m "feat(ui): add the Prime action radio group to settings" -- \
src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
Task 8: Documentation
Files:
-
Modify:
src/ClaudeDo.Data/CLAUDE.md:17(thePrimeScheduleEntityline) -
Modify:
src/ClaudeDo.Worker/CLAUDE.md(the "Daily Prep (Prime Claude)" section and thePrime/folder line) -
Modify:
src/ClaudeDo.Ui/CLAUDE.md(theSettingsModalViewModelrow) -
Step 1: Update
src/ClaudeDo.Data/CLAUDE.md
Replace the PrimeScheduleEntity bullet:
- **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`.
- Step 2: Update
src/ClaudeDo.Worker/CLAUDE.md
In the folder layout, the Prime/ line becomes:
Prime/ — "Prime Claude" schedules: PrimeScheduler, PrimeRunner, PrimePrompts,
DailyPrepPrompt, PrimeScheduleValidation, NextDueCalculator, PrimeScheduleSignal
Rename the "## Daily Prep (Prime Claude)" section to "## Prime Claude" and put this in front of the existing PrimeScheduler/PrimeRunner paragraphs:
Every schedule carries a `PrimeActionKind` (`Ping` — the default for new schedules — `FillMyDay`,
or `Custom`). `PrimeRunner.FireAsync` holds one `SemaphoreSlim` gate for **all** kinds, then
branches:
| Kind | Prompt | Args | Log + `Prep*` events |
|---|---|---|---|
| `Ping` | `PrimePrompts.PingPrompt`, fixed | `--max-turns 1 --strict-mcp-config` (no MCP server loads at all) | none |
| `FillMyDay` | `DailyPrepPrompt.BuildPrompt` (+ the schedule's prompt as an *addition*) | the two MyDay MCP tools | `daily-prep.log`, truncated per run, + `PrepStarted`/`PrepLine`/`PrepFinished` |
| `Custom` | the schedule's prompt *verbatim* | `--allowedTools mcp__claudedo` — no Read/Write/Edit/Bash | none |
Ping and Custom deliberately write **no** log and raise **no** `Prep*` events: the prep log
belongs to the MyDay selection and would otherwise be overwritten with ping noise. All three
kinds still broadcast `PrimeFired` and update `last_run_at`.
`WorkerHub.RunDailyPrepNow` (the "Plan day" button) pins its synthetic schedule to `FillMyDay` —
the button means "fill my day" no matter how the schedules are configured.
`WorkerHub.UpsertPrimeSchedule` runs `PrimeScheduleValidation.Validate` first, so a `Custom`
schedule can never be saved without a prompt.
- Step 3: Update
src/ClaudeDo.Ui/CLAUDE.md
Append to the SettingsModalViewModel table row:
The Prime Claude tab gives each schedule row a three-way action radio group (Ping / Fill My Day / Custom) — `RadioButton.GroupName` is bound to a **per-row** `RadioGroup` string, since a shared group name would make every schedule share one selection. `DailyPrepMaxTasks` is disabled unless some row is `FillMyDay`.
- Step 4: Commit
git commit -m "docs(prime): document the schedule action kinds" -- \
src/ClaudeDo.Data/CLAUDE.md \
src/ClaudeDo.Worker/CLAUDE.md \
src/ClaudeDo.Ui/CLAUDE.md
Task 9: Full verification
- Step 1: Build both entry points
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
Expected: both succeed.
- Step 2: Run every affected suite
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
Expected: all green. Report the actual pass counts — do not claim success without the output.
- Step 3: Report the visual-verification gap
State explicitly that the following were not verified and need the user's eyes:
- the radio group's layout inside the schedule card
- the prompt box appearing/disappearing and its label switching when the kind changes
- the greyed-out
DailyPrepMaxTasksfield with its hint - that existing schedules now show Ping selected after the migration
Notes for the implementer
- Never
git add -A. Every commit in this plan stages explicit paths. The working tree contains unrelated WIP from parallel sessions. .slnxneeds .NET 9. Build individual.csprojfiles with-c Release; a running Worker locks theDebugoutput.- Do not push. Committing is fine; pushing needs the user's explicit go-ahead.