1356 lines
51 KiB
Markdown
1356 lines
51 KiB
Markdown
# 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, // 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
|
||
<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.
|