From 2dec590dfc6b81ad308f38afa835ed6131e2cc30 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 24 Aug 2026 16:36:00 +0200 Subject: [PATCH] docs(prime): implementation plan for schedule action kinds --- .../plans/2026-08-24-prime-action-kind.md | 1355 +++++++++++++++++ .../2026-08-24-prime-action-kind-design.md | 9 +- 2 files changed, 1361 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-24-prime-action-kind.md diff --git a/docs/superpowers/plans/2026-08-24-prime-action-kind.md b/docs/superpowers/plans/2026-08-24-prime-action-kind.md new file mode 100644 index 00000000..6eb00ffd --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-prime-action-kind.md @@ -0,0 +1,1355 @@ +# 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/_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; + +/// What a Prime schedule actually does when it fires. +public enum PrimeActionKind +{ + /// One throwaway turn that only opens the usage window. Default for new schedules. + Ping, + /// Run the daily prep prompt and let Claude fill MyDay. + FillMyDay, + /// Run the schedule's own prompt with the ClaudeDo MCP tools available. + 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 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/_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 `_AddPrimeActionKind.cs`. It must be exactly one `AddColumn`: + +```csharp +migrationBuilder.AddColumn( + 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? 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; + +/// Prompts and CLI args for the non-daily-prep Prime actions. +/// The daily-prep ones live in . +public static class PrimePrompts +{ + /// 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. + public const string PingPrompt = "Reply with the single word: ok"; + + /// 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. + public static IReadOnlyList BuildPingArgs() => + [ + "-p", "--output-format", "stream-json", "--verbose", + "--permission-mode", "default", + "--max-turns", "1", + "--strict-mcp-config", + ]; + + /// 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. + public static IReadOnlyList 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 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 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 RunPingAsync(CancellationToken ct) => + RunQuietAsync(PrimePrompts.BuildPingArgs(), PrimePrompts.PingPrompt, "Ping complete", "Ping", ct); + + private Task 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); + } + + /// 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. + private async Task RunQuietAsync( + IReadOnlyList 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 +{ + /// Returns an error message, or null when the schedule is safe to persist. + 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 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 + /// Per-row group name. A shared GroupName would make every schedule in the + /// ItemsControl share one selection. + 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, // 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** + +```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 + + + + + + + + +``` + +`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 + +``` + +- [ ] **Step 3: Gate the max-tasks field** + +Replace lines 312-316 with: + +```xml + + + + + +``` + +- [ ] **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. diff --git a/docs/superpowers/specs/2026-08-24-prime-action-kind-design.md b/docs/superpowers/specs/2026-08-24-prime-action-kind-design.md index a504f688..8c6a81e7 100644 --- a/docs/superpowers/specs/2026-08-24-prime-action-kind-design.md +++ b/docs/superpowers/specs/2026-08-24-prime-action-kind-design.md @@ -108,9 +108,12 @@ naming the row's time. and on `Kind` changes. A live number that controls nothing is worse than a greyed-out one. New locale keys in both `en.json` and `de.json` (parity is test-enforced): -`settings.prime.actionLabel`, `actionPing`, `actionFillMyDay`, `actionCustom`, -`actionPingHint`, `customPromptLabel`, `customPromptPlaceholder`, `customPromptRequired`, -`maxTasksDisabledHint`. +`settings.prime.actionLabel`, `actionPing`, `actionFillMyDay`, `actionCustom`, `actionHint`, +`customPromptLabel`, `customPromptPlaceholder`, `maxTasksDisabledHint`. The tab's existing +`description` is reworded — it currently claims every schedule fires a ping. + +The validation message stays a hardcoded English string in `PrimeClaudeTabViewModel.Validate`, +matching the messages already there ("select at least one day."). ## Tests