Files
ClaudeDo/docs/superpowers/plans/2026-06-04-refine-task.md
2026-06-04 23:00:01 +02:00

32 KiB
Raw Permalink Blame History

Refine Task Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. Subagents use the sonnet model and stage files explicitly by path (never git add -A).

Goal: Add a one-click "Refine Task" button to each Idle task card that spawns a headless Claude session which rewrites the task's description and adds subtasks (steps), then updates the task live in the UI.

Architecture: A new headless RefineRunner (modeled on PrimeRunner) runs claude -p read-only in the list's working dir, using the globally-registered claudedo MCP. Claude calls update_task (existing) and a new add_subtask tool. The task stays Idle; refine only mutates Title/Description/subtasks. UI shows a busy state via new RefineStarted/RefineFinished SignalR events; content updates arrive via the existing TaskUpdated events.

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

Spec: docs/superpowers/specs/2026-06-04-refine-task-design.md

Build/test reminders: Build individual csproj with -c Release (a running Worker locks Debug). dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release, dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release, dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release. Keep locales/en.json and locales/de.json keys in parity.


File structure

Create:

  • src/ClaudeDo.Worker/Refine/RefineRunner.cs — headless refine run orchestrator
  • src/ClaudeDo.Worker/Refine/RefinePrompt.cs — prompt + CLI args + log path helper
  • src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs — interface + RefineRunOutcome
  • src/ClaudeDo.Worker/Refine/Interfaces/IRefineBroadcaster.csRefineStartedAsync/RefineFinishedAsync

Modify:

  • src/ClaudeDo.Data/PromptFiles.cs — add Refine to PromptKind, path, default
  • src/ClaudeDo.Worker/External/ExternalMcpService.cs — add add_subtask tool
  • src/ClaudeDo.Worker/Hub/HubBroadcaster.cs — implement RefineStarted/RefineFinished + IRefineBroadcaster
  • src/ClaudeDo.Worker/Hub/WorkerHub.cs — add RefineTask(string taskId) method
  • src/ClaudeDo.Worker/Program.cs — register IRefineRunner/IRefineBroadcaster
  • src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.csRefineTaskAsync + RefineStartedEvent/RefineFinishedEvent
  • src/ClaudeDo.Ui/Services/WorkerClient.cs — implement call + subscribe events
  • src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.csIsRefining + CanRefine
  • src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.csRefineTaskCommand + event wiring
  • src/ClaudeDo.Ui/Design/IslandStyles.axamlIcon.Refine geometry
  • src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml — refine button
  • locales/en.json, locales/de.json — tooltip key
  • Test fakes implementing IWorkerClient in tests/ClaudeDo.Ui.Tests (and any other project that hand-rolls it)

Test:

  • tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs
  • tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs
  • tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs

Task 1: add_subtask MCP tool

Files:

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

The ExternalMcpService already injects IDbContextFactory<ClaudeDoDbContext> _dbFactory, TaskRepository _tasks, and HubBroadcaster _broadcaster. Reuse them; new up a SubtaskRepository from a fresh context (matching the SetMyDay/GetDailyPrepCandidates pattern in the same file).

  • Step 1: Write the failing test

Create tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs. Follow the existing External tool test setup in that test project (look at a sibling test, e.g. an ExternalMcpService/UpdateTask test, for the in-memory-real-SQLite fixture + broadcaster fake construction; reuse that exact fixture pattern).

using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

public class AddSubtaskToolTests
{
    [Fact]
    public async Task AddSubtask_appends_row_with_next_order()
    {
        await using var f = new ExternalMcpServiceFixture(); // reuse the project's existing fixture helper
        var list = await f.SeedListAsync();
        var task = await f.SeedTaskAsync(list.Id, status: TaskStatus.Idle);

        await f.Service.AddSubtask(task.Id, "First step", orderNum: null, CancellationToken.None);
        await f.Service.AddSubtask(task.Id, "Second step", orderNum: null, CancellationToken.None);

        await using var ctx = f.CreateContext();
        var subs = await new SubtaskRepository(ctx).GetByTaskIdAsync(task.Id);
        Assert.Equal(new[] { "First step", "Second step" }, subs.Select(s => s.Title));
        Assert.Equal(new[] { 0, 1 }, subs.Select(s => s.OrderNum));
        Assert.All(subs, s => Assert.False(s.Completed));
    }

    [Fact]
    public async Task AddSubtask_refuses_running_task()
    {
        await using var f = new ExternalMcpServiceFixture();
        var list = await f.SeedListAsync();
        var task = await f.SeedTaskAsync(list.Id, status: TaskStatus.Running);

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => f.Service.AddSubtask(task.Id, "x", null, CancellationToken.None));
    }
}

If the test project has no reusable ExternalMcpServiceFixture, mirror the construction already used by the nearest existing ExternalMcpService test (same ctor args, real SQLite via IDbContextFactory, a no-op/recording broadcaster). Do not invent a new pattern.

  • Step 2: Run the test to verify it fails (compile error / method missing)

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter AddSubtaskToolTests Expected: FAIL — AddSubtask not defined.

  • Step 3: Implement add_subtask

Add to ExternalMcpService (near UpdateTask):

[McpServerTool, Description(
    "Append a subtask (step) to a task. orderNum defaults to the end. " +
    "Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list.")]
public async Task<TaskDto> AddSubtask(
    string taskId,
    string title,
    int? orderNum,
    CancellationToken cancellationToken)
{
    if (string.IsNullOrWhiteSpace(title))
        throw new InvalidOperationException("title is required.");

    await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
    var tasks = new TaskRepository(ctx);
    var subtasks = new SubtaskRepository(ctx);

    var task = await tasks.GetByIdAsync(taskId, cancellationToken)
        ?? throw new InvalidOperationException($"Task {taskId} not found.");
    if (task.Status == TaskStatus.Running)
        throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first.");

    var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken);
    var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1);

    await subtasks.AddAsync(new SubtaskEntity
    {
        Id = Guid.NewGuid().ToString(),
        TaskId = taskId,
        Title = title.Trim(),
        Completed = false,
        OrderNum = order,
        CreatedAt = DateTime.UtcNow,
    }, cancellationToken);

    await _broadcaster.TaskUpdated(taskId);
    return ToDto(task);
}

Add using ClaudeDo.Data.Repositories; if not present (it is). SubtaskEntity is in ClaudeDo.Data.Models (already imported).

  • Step 4: Run the test to verify it passes

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter AddSubtaskToolTests Expected: PASS (2 tests).

  • Step 5: Commit
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs
git commit -m "feat(mcp): add add_subtask tool to claudedo MCP"

Task 2: Refine prompt (PromptKind.Refine)

Files:

  • Modify: src/ClaudeDo.Data/PromptFiles.cs

  • Step 1: Add the enum value

Change the enum line in PromptFiles.cs:

public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine }
  • Step 2: Add the path mapping

In PathFor, add before the _ => throw:

PromptKind.Refine => Path.Combine(Root, "refine.md"),
  • Step 3: Add the default mapping

In DefaultFor, add:

PromptKind.Refine => RefineDefault,
  • Step 4: Add the default prompt constant

Add near the other private const string ...Default blocks:

private const string RefineDefault = """
    You are refining ONE ClaudeDo task so it is ready to run autonomously later.
    You are NOT executing the task  only improving its specification.

    The task you are refining:
    - id: {taskId}
    - title: {title}
    - description: {description}
    - current subtasks (steps):
    {subtasks}

    What to do:
    1. If a repository is available, read the relevant code (read-only) to ground your
       understanding. Do NOT edit, create, or delete any files. Do NOT run commands.
    2. Rewrite the description so it is clear, specific, and self-contained: what to change,
       where, and what "done" looks like. Keep scope tight  do not invent adjacent work.
    3. Call mcp__claudedo__update_task to save the improved title (only if it genuinely
       helps) and description.
    4. If the work is clearer as discrete steps, add them as subtasks with
       mcp__claudedo__add_subtask (one call per step, in order). Only add steps that are
       not already present in the current subtasks above.

    Use ONLY these tools: mcp__claudedo__get_task, mcp__claudedo__update_task,
    mcp__claudedo__add_subtask, and read-only Read/Grep/Glob. When you have updated the
    task, stop.
    """;
  • Step 5: Build to verify it compiles

Run: dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release Expected: Build succeeded.

  • Step 6: Commit
git add src/ClaudeDo.Data/PromptFiles.cs
git commit -m "feat(prompts): add Refine prompt kind and default"

Task 3: RefineRunner, interfaces, prompt/args helper

Files:

  • Create: src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs

  • Create: src/ClaudeDo.Worker/Refine/Interfaces/IRefineBroadcaster.cs

  • Create: src/ClaudeDo.Worker/Refine/RefinePrompt.cs

  • Create: src/ClaudeDo.Worker/Refine/RefineRunner.cs

  • Test: tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs

  • Test: tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs

  • Step 1: Create IRefineRunner.cs

namespace ClaudeDo.Worker.Refine;

public interface IRefineRunner
{
    Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct);
}

public sealed record RefineRunOutcome(bool Success, string Message);
  • Step 2: Create IRefineBroadcaster.cs
namespace ClaudeDo.Worker.Refine;

public interface IRefineBroadcaster
{
    Task RefineStartedAsync(string taskId);
    Task RefineFinishedAsync(string taskId, bool success, string? error);
}
  • Step 3: Create RefinePrompt.cs
using ClaudeDo.Data;
using ClaudeDo.Data.Models;

namespace ClaudeDo.Worker.Refine;

public static class RefinePrompt
{
    public const string GetTaskTool = "mcp__claudedo__get_task";
    public const string UpdateTaskTool = "mcp__claudedo__update_task";
    public const string AddSubtaskTool = "mcp__claudedo__add_subtask";

    public static string LogPath(string taskId) =>
        System.IO.Path.Combine(Paths.AppDataRoot(), "logs", $"refine-{Short(taskId)}.log");

    // canReadRepo=false drops the read-only filesystem tools (text-only fallback).
    public static string BuildArgs(int maxTurns, bool canReadRepo)
    {
        var tools = canReadRepo
            ? $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool} Read Grep Glob"
            : $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool}";
        return "-p --output-format stream-json --verbose --permission-mode acceptEdits " +
               $"--max-turns {maxTurns} --allowedTools {tools}";
    }

    public static string BuildPrompt(TaskEntity task, IEnumerable<SubtaskEntity> subtasks)
    {
        var open = subtasks.Where(s => !s.Completed).Select(s => $"- {s.Title}").ToList();
        var subText = open.Count == 0 ? "(none)" : string.Join("\n", open);
        return PromptFiles.Render(PromptKind.Refine, new Dictionary<string, string>
        {
            ["taskId"] = task.Id,
            ["title"] = task.Title,
            ["description"] = string.IsNullOrWhiteSpace(task.Description) ? "(empty)" : task.Description!,
            ["subtasks"] = subText,
        });
    }

    private static string Short(string id) => id.Length >= 8 ? id[..8] : id;
}
  • Step 4: Write RefinePromptTests.cs
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Refine;

public class RefinePromptTests
{
    [Fact]
    public void BuildArgs_includes_read_tools_when_repo_available()
    {
        var args = RefinePrompt.BuildArgs(20, canReadRepo: true);
        Assert.Contains("--permission-mode acceptEdits", args);
        Assert.Contains("mcp__claudedo__add_subtask", args);
        Assert.Contains(" Read Grep Glob", args);
    }

    [Fact]
    public void BuildArgs_drops_read_tools_in_text_only_mode()
    {
        var args = RefinePrompt.BuildArgs(20, canReadRepo: false);
        Assert.DoesNotContain("Glob", args);
        Assert.Contains("mcp__claudedo__update_task", args);
    }

    [Fact]
    public void BuildPrompt_seeds_task_fields_and_open_subtasks()
    {
        var task = new TaskEntity { Id = "abc12345", ListId = "l", Title = "T", Description = "D",
            Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow };
        var subs = new[]
        {
            new SubtaskEntity { Id="1", TaskId="abc12345", Title="open one", Completed=false, OrderNum=0, CreatedAt=DateTime.UtcNow },
            new SubtaskEntity { Id="2", TaskId="abc12345", Title="done one", Completed=true,  OrderNum=1, CreatedAt=DateTime.UtcNow },
        };
        var prompt = RefinePrompt.BuildPrompt(task, subs);
        Assert.Contains("abc12345", prompt);
        Assert.Contains("open one", prompt);
        Assert.DoesNotContain("done one", prompt);
    }
}

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RefinePromptTests Expected: PASS (3 tests).

  • Step 5: Create RefineRunner.cs

IClaudeProcess.RunAsync(arguments, prompt, workingDirectory, onStdoutLine, ct) returns a result with .IsSuccess and .ExitCode (same as used by PrimeRunner). Resolve the working dir from the task's list; fall back to a sandbox dir + text-only when missing/invalid. Per-task single-flight via a guarded HashSet<string>.

using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Refine;

public sealed class RefineRunner : IRefineRunner
{
    private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5);
    private const int MaxTurns = 25;

    private readonly IClaudeProcess _claude;
    private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
    private readonly ILogger<RefineRunner> _logger;
    private readonly IRefineBroadcaster _broadcaster;

    private readonly object _lock = new();
    private readonly HashSet<string> _inFlight = new();

    public RefineRunner(
        IClaudeProcess claude,
        IDbContextFactory<ClaudeDoDbContext> dbFactory,
        ILogger<RefineRunner> logger,
        IRefineBroadcaster broadcaster)
    {
        _claude = claude;
        _dbFactory = dbFactory;
        _logger = logger;
        _broadcaster = broadcaster;
    }

    public async Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct)
    {
        lock (_lock)
        {
            if (!_inFlight.Add(taskId))
                return new RefineRunOutcome(false, "Already refining this task");
        }

        var success = false;
        string? error = null;
        try
        {
            ClaudeDo.Data.Models.TaskEntity task;
            List<ClaudeDo.Data.Models.SubtaskEntity> subs;
            string? workingDir;
            await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
            {
                var tasks = new TaskRepository(dbCtx);
                task = await tasks.GetByIdAsync(taskId, ct)
                    ?? throw new InvalidOperationException($"Task {taskId} not found.");
                if (task.Status != TaskStatus.Idle)
                    return new RefineRunOutcome(false, $"Task must be Idle to refine (is {task.Status}).");
                subs = await new SubtaskRepository(dbCtx).GetByTaskIdAsync(taskId, ct);
                var list = await new ListRepository(dbCtx).GetByIdAsync(task.ListId, ct);
                workingDir = list?.WorkingDir;
            }

            var canReadRepo = !string.IsNullOrWhiteSpace(workingDir) && Directory.Exists(workingDir);
            var cwd = canReadRepo ? workingDir! : Paths.AppDataRoot();
            Directory.CreateDirectory(cwd);

            var logPath = RefinePrompt.LogPath(taskId);
            try { if (File.Exists(logPath)) File.Delete(logPath); } catch { }
            await using var logWriter = new LogWriter(logPath);

            await _broadcaster.RefineStartedAsync(taskId);

            var prompt = RefinePrompt.BuildPrompt(task, subs);
            var args = RefinePrompt.BuildArgs(MaxTurns, canReadRepo);

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

            var result = await _claude.RunAsync(
                arguments: args,
                prompt: prompt,
                workingDirectory: cwd,
                onStdoutLine: async line => await logWriter.WriteLineAsync(line),
                ct: timeoutCts.Token);

            success = result.IsSuccess;
            if (!success) error = $"exit code {result.ExitCode}";
            return success
                ? new RefineRunOutcome(true, "Refine complete")
                : new RefineRunOutcome(false, error!);
        }
        catch (OperationCanceledException) when (!ct.IsCancellationRequested)
        {
            error = $"timed out after {RunTimeout.TotalMinutes:0} min";
            return new RefineRunOutcome(false, error);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Refine run failed for {TaskId}", taskId);
            error = ex.Message;
            return new RefineRunOutcome(false, ex.Message);
        }
        finally
        {
            await _broadcaster.RefineFinishedAsync(taskId, success, error);
            lock (_lock) { _inFlight.Remove(taskId); }
        }
    }
}
  • Step 6: Write RefineRunnerTests.cs (guards, with a fake IClaudeProcess)

The test project already has a fake/stub for IClaudeProcess used by Prime tests — reuse it (recording invocation + returning a configurable success result). Do NOT spawn the real CLI.

public class RefineRunnerTests
{
    [Fact]
    public async Task Refuses_when_task_not_idle()
    {
        await using var f = new RefineRunnerFixture(); // mirror Prime test fixture wiring
        var task = await f.SeedTaskAsync(status: TaskStatus.Queued);
        var outcome = await f.Runner.RefineAsync(task.Id, CancellationToken.None);
        Assert.False(outcome.Success);
        Assert.Equal(0, f.Claude.RunCount); // never invoked the CLI
    }

    [Fact]
    public async Task Idle_task_invokes_claude_once_and_brackets_with_events()
    {
        await using var f = new RefineRunnerFixture();
        var task = await f.SeedTaskAsync(status: TaskStatus.Idle);
        var outcome = await f.Runner.RefineAsync(task.Id, CancellationToken.None);
        Assert.True(outcome.Success);
        Assert.Equal(1, f.Claude.RunCount);
        Assert.Equal(1, f.Broadcaster.Started);
        Assert.Equal(1, f.Broadcaster.Finished);
    }
}

Build the RefineRunnerFixture/fakes by copying the Prime test's IClaudeProcess stub + real-SQLite IDbContextFactory setup and a recording IRefineBroadcaster. If a Prime fixture exists, mirror it; otherwise construct inline.

Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RefineRunnerTests Expected: PASS (2 tests).

  • Step 7: Commit
git add src/ClaudeDo.Worker/Refine tests/ClaudeDo.Worker.Tests/Refine
git commit -m "feat(refine): add RefineRunner, prompt/args helper, and interfaces"

Task 4: Worker wiring — broadcaster, hub, DI

Files:

  • Modify: src/ClaudeDo.Worker/Hub/HubBroadcaster.cs

  • Modify: src/ClaudeDo.Worker/Hub/WorkerHub.cs

  • Modify: src/ClaudeDo.Worker/Program.cs

  • Step 1: Implement events on HubBroadcaster

Add IRefineBroadcaster to the class's interface list (public sealed class HubBroadcaster : ..., IRefineBroadcaster) and add (mirroring the Prep* block):

public Task RefineStarted(string taskId) => _hub.Clients.All.SendAsync("RefineStarted", taskId);
public Task RefineFinished(string taskId, bool success, string? error) =>
    _hub.Clients.All.SendAsync("RefineFinished", taskId, success, error);

Task IRefineBroadcaster.RefineStartedAsync(string taskId) => RefineStarted(taskId);
Task IRefineBroadcaster.RefineFinishedAsync(string taskId, bool success, string? error) =>
    RefineFinished(taskId, success, error);

Add using ClaudeDo.Worker.Refine;.

  • Step 2: Add RefineTask to WorkerHub

WorkerHub injects services via its constructor. Add a private readonly IRefineRunner _refineRunner; field, add the parameter to the constructor and assign it. Add the method (fire-and-forget; the runner brackets with its own events):

public Task RefineTask(string taskId)
{
    _ = _refineRunner.RefineAsync(taskId, CancellationToken.None);
    return Task.CompletedTask;
}

Add using ClaudeDo.Worker.Refine;.

  • Step 3: Register DI in Program.cs

Near the Prime registrations:

builder.Services.AddSingleton<IRefineRunner, RefineRunner>();
builder.Services.AddSingleton<IRefineBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());

Add using ClaudeDo.Worker.Refine; if needed. (HubBroadcaster is already registered as a singleton — confirm and reuse that registration; do not double-register it.)

  • Step 4: Build the worker

Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release Expected: Build succeeded.

  • Step 5: Commit
git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/Program.cs
git commit -m "feat(refine): wire RefineTask hub method, broadcaster events, and DI"

Task 5: UI worker client — call + events + fakes

Files:

  • Modify: src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs

  • Modify: src/ClaudeDo.Ui/Services/WorkerClient.cs

  • Modify: test fakes implementing IWorkerClient

  • Step 1: Extend the interface

In IWorkerClient.cs add (near RunDailyPrepNowAsync and the Prep* events):

Task RefineTaskAsync(string taskId);

event Action<string>? RefineStartedEvent;
event Action<string, bool, string?>? RefineFinishedEvent;
  • Step 2: Implement in WorkerClient

Add the method (mirror RunDailyPrepNowAsync):

public Task RefineTaskAsync(string taskId) => _hub.InvokeAsync("RefineTask", taskId);

Declare the events:

public event Action<string>? RefineStartedEvent;
public event Action<string, bool, string?>? RefineFinishedEvent;

Subscribe in the constructor (mirror the Prep* subscriptions block):

_hub.On<string>("RefineStarted", id =>
    Dispatcher.UIThread.Post(() => RefineStartedEvent?.Invoke(id)));
_hub.On<string, bool, string?>("RefineFinished", (id, ok, err) =>
    Dispatcher.UIThread.Post(() => RefineFinishedEvent?.Invoke(id, ok, err)));
  • Step 3: Update test fakes

Find every hand-rolled IWorkerClient implementation (search the test projects) and add RefineTaskAsync (return Task.CompletedTask) plus the two events (= delegate {} or add{}remove{} no-ops as the fake convention dictates). Build each affected test project.

  • Step 4: Build UI + test projects

Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release Then build the UI test project(s). Expected: Build succeeded.

  • Step 5: Commit
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs <fake files>
git commit -m "feat(ui): add RefineTask client call and refine events"

Task 6: UI — icon, button, view model, command

Files:

  • Modify: src/ClaudeDo.Ui/Design/IslandStyles.axaml

  • Modify: src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml

  • Modify: src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs

  • Modify: src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs

  • Modify: locales/en.json, locales/de.json

  • Step 1: Add the Icon.Refine geometry

In IslandStyles.axaml, near the other Icon.* StreamGeometry resources, add the supplied SVG converted to path data (line-art, rendered stroked via plan-icon):

<StreamGeometry x:Key="Icon.Refine">M3,5 L11,5 M3,9 L9,9 M3,13 L7,13 M19,1.8 L19.7,3.9 L21.7,4.6 L19.7,5.3 L19,7.4 L18.3,5.3 L16.3,4.6 L18.3,3.9 Z M18,10.5 L12.2,16.3 M16.6,9.1 L19.4,11.9 M12.2,16.3 L11,18.5 L13.2,17.5 Z</StreamGeometry>
  • Step 2: Add IsRefining/CanRefine to TaskRowViewModel

Add the observable property (with the other [ObservableProperty] fields):

[ObservableProperty] private bool _isRefining;

Add a computed gate (refine is only offered for Idle, non-parent tasks). Place near other Can* getters:

public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining;

If Status/PlanningPhase/IsRefining are [ObservableProperty], raise CanRefine change notifications via partial On<Prop>Changed hooks:

partial void OnStatusChanged(TaskStatus value) => OnPropertyChanged(nameof(CanRefine));
partial void OnPlanningPhaseChanged(PlanningPhase value) => OnPropertyChanged(nameof(CanRefine));
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));

If On...Changed partials already exist for Status/PlanningPhase, add the OnPropertyChanged(nameof(CanRefine)) line inside them instead of redeclaring.

  • Step 3: Add RefineTaskCommand + event wiring to TasksIslandViewModel

Add the command (mirror an existing per-row command like ToggleStarCommand, which takes a TaskRowViewModel):

[RelayCommand]
private async Task RefineTask(TaskRowViewModel row)
{
    if (row is null || !row.CanRefine) return;
    row.IsRefining = true;
    try { await _worker.RefineTaskAsync(row.Id); }
    catch { row.IsRefining = false; }
}

Use the same injected worker-client field name this VM already uses (e.g. _worker/_client). Match it.

Subscribe to the refine events where the VM wires other worker events (where OnWorkerTaskUpdated is subscribed). Add handlers that flip the row flag:

private void OnRefineStarted(string taskId)
{
    var row = Items.FirstOrDefault(r => r.Id == taskId);
    if (row is not null) row.IsRefining = true;
}

private void OnRefineFinished(string taskId, bool ok, string? error)
{
    var row = Items.FirstOrDefault(r => r.Id == taskId);
    if (row is not null) row.IsRefining = false;
}

Wire them next to the existing subscriptions (and unsubscribe in the same place the VM unsubscribes others, if it does):

_worker.RefineStartedEvent += OnRefineStarted;
_worker.RefineFinishedEvent += OnRefineFinished;

(Content changes—new description/subtasks—arrive through the existing TaskUpdatedOnWorkerTaskUpdated path; no extra work needed.)

  • Step 4: Add the button to TaskRowView.axaml

Mirror the star button (Grid.Column="5" area). Add a refine icon-btn (e.g. as a new column or beside the star) bound to the parent ItemsControl's command, passing the row as parameter. Use the plan-icon stroked Path inside a Viewbox (matching the Plan-day button), gate visibility on CanRefine, and disable/spin on IsRefining:

<Button Classes="icon-btn refine-btn"
        IsVisible="{Binding CanRefine}"
        Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
        CommandParameter="{Binding}"
        ToolTip.Tip="{loc:Tr tasks.refineTip}">
  <Viewbox Width="16" Height="16">
    <Path Classes="plan-icon" Data="{StaticResource Icon.Refine}"/>
  </Viewbox>
</Button>

Match the column layout already in TaskRowView.axaml. If a new grid column is needed, widen ColumnDefinitions accordingly and place the refine button left of the star (Grid.Column). Keep the existing vm: / loc: xmlns aliases the file already declares.

Optionally show a spinning/dimmed state while IsRefining (e.g. a style Selector="Button.refine-btn:disabled" or bind opacity to IsRefining). Keep it simple; a disabled look is enough.

  • Step 5: Add localization keys

Add to both locales/en.json and locales/de.json under the tasks group (keys must stay in parity):

  • en: "tasks.refineTip": "Refine this task with Claude"
  • de: "tasks.refineTip": "Aufgabe mit Claude verfeinern"

Match the file's actual key structure (flat "tasks.x" vs nested tasks: { x })—look at an existing tasks.* tooltip key (e.g. the plan-day tip) and follow it exactly.

  • Step 6: Build UI

Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release Then run the Localization parity tests: dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release Expected: Build succeeded; locale parity passes.

  • Step 7: Commit
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs locales/en.json locales/de.json
git commit -m "feat(ui): add Refine button, icon, and command to task card"

Task 7: Full build + test sweep, manual smoke

  • Step 1: Build all main projects
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release

Expected: Build succeeded for both.

  • Step 2: Run the worker + UI test suites
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

Expected: all green.

  • Step 3: Manual smoke (visual + real CLI — flag to user)

Cannot be automated (no real-Claude in tests). Verify by hand: start Worker + UI, on an Idle task click the refine icon → button shows busy → after the run the description improves and steps appear in the Steps card → task stays Idle. Confirm the refine icon is hidden for Queued/Running/Done tasks and for planning parents. Report this as a visual-verification gap for the user to confirm.


Notes on parallelism / execution

  • Tasks 14 are backend and largely sequential (4 depends on 3). Tasks 1 and 2 are independent and could be done first in either order.
  • Tasks 56 (UI) depend on Task 4's hub/event contract.
  • Per project convention: subagents use sonnet, stage files by explicit path, and do NOT run git/build inside parallel agents — the orchestrator builds, tests, and commits after each task.