Files
ClaudeDo/docs/superpowers/plans/2026-08-24-prime-action-kind.md
T

1356 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 enum
- `src/ClaudeDo.Data/Migrations/<timestamp>_AddPrimeActionKind.cs` — scaffolded
- `src/ClaudeDo.Worker/Prime/PrimePrompts.cs` — Ping/Custom prompt + args builders (`DailyPrepPrompt` stays as-is for FillMyDay)
- `src/ClaudeDo.Worker/Prime/PrimeScheduleValidation.cs` — pure validation, callable from the hub and unit-testable without constructing `WorkerHub`
- `tests/ClaudeDo.Worker.Tests/Prime/PrimeScheduleValidationTests.cs`
- `tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs`
**Modify:**
- `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs``Kind` property
- `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs` — column + converter
- `src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs``UpsertAsync` copies `Kind`
- `src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs``Kind` member
- `src/ClaudeDo.Worker/Prime/PrimeScheduler.cs``ToDto` maps `Kind`
- `src/ClaudeDo.Worker/Prime/PrimeRunner.cs` — three-way branch
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs``ListPrimeSchedules`, `UpsertPrimeSchedule`, `RunDailyPrepNow`
- `src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs``Kind` member
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs``Kind` + radio booleans + prompt label/placeholder
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs` — default `Ping`, `AnyFillMyDay`, validation
- `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` — radio group, conditional prompt box, gated max-tasks field
- `src/ClaudeDo.Localization/locales/en.json` + `de.json` — new `settings.prime.*` keys
- `tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs` — existing DTO construction + new per-kind tests
- `tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs` — existing DTO construction + new tests
- `src/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`:
```csharp
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**
```bash
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`:
```csharp
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`:
```csharp
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`):
```csharp
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:
```csharp
builder.Property(s => s.Kind).HasColumnName("action_kind")
.IsRequired().HasDefaultValue(PrimeActionKind.Ping)
.HasConversion(KindConverter);
```
- [ ] **Step 6: Copy `Kind` in the repository upsert**
In `src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs`, inside the `else` branch of `UpsertAsync`, add:
```csharp
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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`:
```csharp
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**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
```
Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
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`:
```csharp
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:
```csharp
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`:
```csharp
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`:
```csharp
public IReadOnlyList<string>? LastArguments { get; private set; }
public string? LastPrompt { get; private set; }
```
```csharp
LastArguments = arguments;
LastPrompt = prompt;
```
Now append the new tests:
```csharp
[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**
```bash
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`:
```csharp
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;`):
```csharp
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**
```bash
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**
```bash
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`:
```csharp
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**
```bash
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`:
```csharp
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**
```bash
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:
```csharp
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:
```csharp
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:
```csharp
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**
```bash
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**
```bash
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`:
```csharp
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):
```csharp
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:
```csharp
[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**
```bash
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:
```csharp
[ObservableProperty] private PrimeActionKind _kind;
```
Add the radio-binding members after `TimeText`:
```csharp
/// <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;`:
```csharp
Kind = dto.Kind;
```
And `ToDto`:
```csharp
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:
```csharp
[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:
```csharp
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:
```csharp
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:
```csharp
var dto = new PrimeScheduleDto(
Id: Guid.NewGuid(),
Days: 31, // MonFri
TimeOfDay: new TimeSpan(7, 0, 0),
Enabled: true,
LastRunAt: null,
PromptOverride: null,
Kind: PrimeActionKind.Ping);
```
- [ ] **Step 6: Run the tests to verify they pass**
```bash
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**
```bash
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` (the `settings.prime` block)
- Modify: `src/ClaudeDo.Localization/locales/de.json:90-104`
- [ ] **Step 1: Add the English keys**
In `en.json`, inside `settings.prime`, after `"dailyPrepMaxTasks"`:
```json
"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:
```json
"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:
```json
"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“.",
```
```json
"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**
```bash
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**
```bash
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:
```xml
<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):
```xml
<TextBlock Classes="meta" TextWrapping="Wrap"
Text="{loc:Tr settings.prime.actionHint}"/>
```
- [ ] **Step 3: Gate the max-tasks field**
Replace lines 312-316 with:
```xml
<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**
```bash
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**
```bash
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` (the `PrimeScheduleEntity` line)
- Modify: `src/ClaudeDo.Worker/CLAUDE.md` (the "Daily Prep (Prime Claude)" section and the `Prime/` folder line)
- Modify: `src/ClaudeDo.Ui/CLAUDE.md` (the `SettingsModalViewModel` row)
- [ ] **Step 1: Update `src/ClaudeDo.Data/CLAUDE.md`**
Replace the `PrimeScheduleEntity` bullet:
```markdown
- **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:
```markdown
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:
```markdown
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**
```bash
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**
```bash
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**
```bash
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 `DailyPrepMaxTasks` field 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.
- **`.slnx` needs .NET 9.** Build individual `.csproj` files with `-c Release`; a running Worker locks the `Debug` output.
- Do not push. Committing is fine; pushing needs the user's explicit go-ahead.