From 43d517dcfceb41a0e794139af592f09ae423cbad Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 17:36:02 +0200 Subject: [PATCH] docs(plans): add planning sessions implementation plans A, B, C - Plan A (Foundation): schema, enum, repos, auto-status hook - Plan B (Worker MCP + Launcher): MCP server, SignalR endpoints, wt.exe launcher - Plan C (UI): context menu, hierarchy rendering, dialog, client methods Plans B and C depend on Plan A merging first (marker: migration file AddPlanningSupport). B and C can run in parallel after A. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-23-planning-sessions-plan-a-foundation.md | 1428 ++++++++++++++ ...-23-planning-sessions-plan-b-worker-mcp.md | 1644 +++++++++++++++++ .../2026-04-23-planning-sessions-plan-c-ui.md | 927 ++++++++++ 3 files changed, 3999 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-23-planning-sessions-plan-a-foundation.md create mode 100644 docs/superpowers/plans/2026-04-23-planning-sessions-plan-b-worker-mcp.md create mode 100644 docs/superpowers/plans/2026-04-23-planning-sessions-plan-c-ui.md diff --git a/docs/superpowers/plans/2026-04-23-planning-sessions-plan-a-foundation.md b/docs/superpowers/plans/2026-04-23-planning-sessions-plan-a-foundation.md new file mode 100644 index 0000000..d6d5da7 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-planning-sessions-plan-a-foundation.md @@ -0,0 +1,1428 @@ +# Planning Sessions — Plan A: Foundation 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:** Add the database, enum, repository, and auto-status infrastructure that enables planning sessions — no UI, no terminal, no MCP. Everything on top of this plan depends on the schema and repo methods landing cleanly. + +**Architecture:** Extend `TaskEntity` with four new columns (`ParentTaskId`, `PlanningSessionId`, `PlanningSessionToken`, `PlanningFinalizedAt`), add three enum values (`Planning`, `Planned`, `Draft`), configure a self-referential FK with `DeleteBehavior.Restrict`, generate an EF migration, add seven new repository methods for the planning lifecycle, and wire a parent-auto-completion hook into the TaskRunner so that when a child reaches a terminal state the parent's status updates accordingly. + +**Tech Stack:** .NET 8, EF Core (SQLite provider), xUnit. Uses existing `DbFixture` (real SQLite per test) and `TaskRepository` sealed-class pattern. + +**Spec reference:** `docs/superpowers/specs/2026-04-23-planning-sessions-design.md` sections 2, 3, 7.1, 7.2, 8.1. + +--- + +## File Structure + +**Modified:** +- `src/ClaudeDo.Data/Models/TaskEntity.cs` — add four columns + navigation properties, extend `TaskStatus` enum. +- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — map new columns, configure self-ref FK, extend status converter. +- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — add seven planning methods. +- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — call `TryCompleteParentAsync` after `MarkDoneAsync`/`MarkFailedAsync`. + +**Created:** +- `src/ClaudeDo.Data/Migrations/_AddPlanningSupport.cs` — EF migration. +- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` — new test class for planning methods. +- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs` — tests for auto-status hook. + +**No interface extraction:** existing code uses `TaskRepository` sealed class directly (no `ITaskRepository`); we follow that pattern. + +**Marker for parallel plans B and C:** the file `src/ClaudeDo.Data/Migrations/_AddPlanningSupport.cs` existing on `main` is the signal that Plan A is merged. Plans B and C should poll for it before starting. + +--- + +## Task 1: Extend `TaskStatus` enum + +**Files:** +- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs` +- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` + +- [ ] **Step 1: Add enum values** + +In `src/ClaudeDo.Data/Models/TaskEntity.cs`, extend the enum: + +```csharp +public enum TaskStatus +{ + Manual, + Queued, + Running, + Done, + Failed, + Planning, + Planned, + Draft, +} +``` + +- [ ] **Step 2: Extend status converter** + +In `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`, extend both converter methods to handle the three new values: + +```csharp +private static string StatusToString(TaskStatus v) + => v == TaskStatus.Manual ? "manual" + : v == TaskStatus.Queued ? "queued" + : v == TaskStatus.Running ? "running" + : v == TaskStatus.Done ? "done" + : v == TaskStatus.Failed ? "failed" + : v == TaskStatus.Planning ? "planning" + : v == TaskStatus.Planned ? "planned" + : v == TaskStatus.Draft ? "draft" + : throw new ArgumentOutOfRangeException(nameof(v)); + +private static TaskStatus StatusFromString(string v) + => v == "manual" ? TaskStatus.Manual + : v == "queued" ? TaskStatus.Queued + : v == "running" ? TaskStatus.Running + : v == "done" ? TaskStatus.Done + : v == "failed" ? TaskStatus.Failed + : v == "planning" ? TaskStatus.Planning + : v == "planned" ? TaskStatus.Planned + : v == "draft" ? TaskStatus.Draft + : throw new ArgumentOutOfRangeException(nameof(v)); +``` + +- [ ] **Step 3: Build** + +Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj` +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +git commit -m "feat(data): add Planning, Planned, Draft task statuses" +``` + +--- + +## Task 2: Extend `TaskEntity` with planning columns and navigations + +**Files:** +- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs` + +- [ ] **Step 1: Add four properties + navigations** + +In `TaskEntity` class, add (placement: after `SortOrder`, before navigation section): + +```csharp +public int SortOrder { get; set; } + +public string? ParentTaskId { get; set; } +public string? PlanningSessionId { get; set; } +public string? PlanningSessionToken { get; set; } +public DateTime? PlanningFinalizedAt { get; set; } + +// Navigation properties +public ListEntity List { get; set; } = null!; +public WorktreeEntity? Worktree { get; set; } +public ICollection Tags { get; set; } = new List(); +public ICollection Runs { get; set; } = new List(); +public ICollection Subtasks { get; set; } = new List(); + +public TaskEntity? Parent { get; set; } +public ICollection Children { get; set; } = new List(); +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj` +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Data/Models/TaskEntity.cs +git commit -m "feat(data): add planning columns and self-ref navigations to TaskEntity" +``` + +--- + +## Task 3: Configure self-ref FK and new columns in `TaskEntityConfiguration` + +**Files:** +- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` + +- [ ] **Step 1: Map the four new columns + configure self-ref FK** + +After the existing `SortOrder` property mapping (currently ends with `.HasDefaultValue(0);`), insert: + +```csharp +builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); + +builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id"); +builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id"); +builder.Property(t => t.PlanningSessionToken).HasColumnName("planning_session_token"); +builder.Property(t => t.PlanningFinalizedAt).HasColumnName("planning_finalized_at"); + +builder.HasOne(t => t.Parent) + .WithMany(t => t.Children) + .HasForeignKey(t => t.ParentTaskId) + .OnDelete(DeleteBehavior.Restrict); + +builder.HasOne(t => t.List) +``` + +(The existing `builder.HasOne(t => t.List)` block immediately follows; do not duplicate it — just insert the new block above it.) + +- [ ] **Step 2: Add index on `ParentTaskId`** + +At the end of `Configure`, after the existing `HasIndex` calls: + +```csharp +builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id"); +``` + +- [ ] **Step 3: Build** + +Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj` +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +git commit -m "feat(data): configure planning columns and self-ref FK with Restrict" +``` + +--- + +## Task 4: Generate EF migration + +**Files:** +- Create: `src/ClaudeDo.Data/Migrations/_AddPlanningSupport.cs` (auto-generated, then reviewed) + +- [ ] **Step 1: Scaffold the migration** + +Run from project root: + +```bash +dotnet ef migrations add AddPlanningSupport \ + --project src/ClaudeDo.Data \ + --startup-project src/ClaudeDo.Worker +``` + +Expected: three new files under `src/ClaudeDo.Data/Migrations/` (the migration, its designer file, and an updated model snapshot). + +- [ ] **Step 2: Review the generated migration** + +Open the new `_AddPlanningSupport.cs` file. Verify the `Up` method contains: +- Four `AddColumn` / `AddColumn` calls for `parent_task_id` (nullable string), `planning_session_id` (nullable string), `planning_session_token` (nullable string), `planning_finalized_at` (nullable DateTime). +- A `CreateIndex` for `idx_tasks_parent_task_id` on `parent_task_id`. +- An `AddForeignKey` call with `onDelete: ReferentialAction.Restrict` linking `parent_task_id` → `tasks.id`. + +No data migration needed (all new columns nullable). + +- [ ] **Step 3: Apply to a scratch DB to verify** + +Run: + +```bash +dotnet ef database update \ + --project src/ClaudeDo.Data \ + --startup-project src/ClaudeDo.Worker \ + --connection "Data Source=./scratch_migration_test.db" +``` + +Expected: no errors. Then delete the scratch DB file: + +```bash +rm -f ./scratch_migration_test.db ./scratch_migration_test.db-wal ./scratch_migration_test.db-shm +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Data/Migrations/ +git commit -m "feat(data): migration AddPlanningSupport" +``` + +--- + +## Task 5: Repository method `GetChildrenAsync` + +**Files:** +- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` (create) +- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` + +- [ ] **Step 1: Create the test class skeleton** + +Create `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs`: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Repositories; + +public sealed class TaskRepositoryPlanningTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private readonly TagRepository _tags; + + public TaskRepositoryPlanningTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + _tags = new TagRepository(_ctx); + } + + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + } + + private async Task CreateListAsync(string? id = null) + { + var listId = id ?? Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity + { + Id = listId, + Name = "Test List", + CreatedAt = DateTime.UtcNow, + }); + return listId; + } + + private TaskEntity MakeTask(string listId, TaskStatus status = TaskStatus.Manual, string? parentId = null) => new() + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "t", + Status = status, + CreatedAt = DateTime.UtcNow, + CommitType = "feat", + ParentTaskId = parentId, + }; +} +``` + +- [ ] **Step 2: Write the failing test for `GetChildrenAsync`** + +Add inside the class: + +```csharp +[Fact] +public async Task GetChildrenAsync_ReturnsOnlyDirectChildren_Sorted() +{ + var listId = await CreateListAsync(); + var parent = MakeTask(listId, TaskStatus.Planning); + parent.Title = "parent"; + await _tasks.AddAsync(parent); + + var childA = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id); + childA.Title = "a"; childA.SortOrder = 1; + await _tasks.AddAsync(childA); + + var childB = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id); + childB.Title = "b"; childB.SortOrder = 0; + await _tasks.AddAsync(childB); + + var unrelated = MakeTask(listId, TaskStatus.Manual); + await _tasks.AddAsync(unrelated); + + var children = await _tasks.GetChildrenAsync(parent.Id); + + Assert.Equal(2, children.Count); + Assert.Equal("b", children[0].Title); + Assert.Equal("a", children[1].Title); +} +``` + +- [ ] **Step 3: Run the test; verify it fails** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.GetChildrenAsync"` +Expected: FAIL — `TaskRepository` has no method `GetChildrenAsync`. + +- [ ] **Step 4: Implement the method** + +In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, add a new region at the end of the class (before the final `}`): + +```csharp + #region Planning + + public async Task> GetChildrenAsync(string parentId, CancellationToken ct = default) + { + return await _context.Tasks + .AsNoTracking() + .Where(t => t.ParentTaskId == parentId) + .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) + .ToListAsync(ct); + } + + #endregion +``` + +- [ ] **Step 5: Run the test; verify it passes** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.GetChildrenAsync"` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs +git commit -m "feat(data): TaskRepository.GetChildrenAsync" +``` + +--- + +## Task 6: Repository method `CreateChildAsync` + +**Files:** +- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` +- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` + +- [ ] **Step 1: Write the failing test** + +Append to the planning test class: + +```csharp +[Fact] +public async Task CreateChildAsync_CreatesDraftUnderParent() +{ + var listId = await CreateListAsync(); + var parent = MakeTask(listId, TaskStatus.Planning); + await _tasks.AddAsync(parent); + + var child = await _tasks.CreateChildAsync( + parent.Id, + title: "child title", + description: "child desc", + tagNames: new[] { "agent" }, + commitType: "feat"); + + Assert.Equal(TaskStatus.Draft, child.Status); + Assert.Equal(parent.Id, child.ParentTaskId); + Assert.Equal(listId, child.ListId); + Assert.Equal("child title", child.Title); + Assert.Equal("child desc", child.Description); + Assert.Equal("feat", child.CommitType); + + var loaded = await _tasks.GetByIdAsync(child.Id); + Assert.NotNull(loaded); + Assert.Equal(TaskStatus.Draft, loaded!.Status); + + var tags = await _tasks.GetTagsAsync(child.Id); + Assert.Contains(tags, t => t.Name == "agent"); +} + +[Fact] +public async Task CreateChildAsync_ThrowsIfParentNotFound() +{ + var listId = await CreateListAsync(); + _ = listId; // just to create the DB + + await Assert.ThrowsAsync(() => + _tasks.CreateChildAsync("nonexistent-parent-id", "t", null, null, null)); +} +``` + +- [ ] **Step 2: Run; verify fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.CreateChildAsync"` +Expected: FAIL (method not defined). + +- [ ] **Step 3: Implement** + +In the `#region Planning` section of `TaskRepository.cs`, add: + +```csharp +public async Task CreateChildAsync( + string parentId, + string title, + string? description, + IReadOnlyList? tagNames, + string? commitType, + CancellationToken ct = default) +{ + var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct); + if (parent is null) + throw new InvalidOperationException($"Parent task {parentId} not found."); + + var maxSort = await _context.Tasks + .Where(t => t.ListId == parent.ListId) + .Select(t => (int?)t.SortOrder) + .MaxAsync(ct); + + var child = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = parent.ListId, + Title = title, + Description = description, + Status = TaskStatus.Draft, + CreatedAt = DateTime.UtcNow, + CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType, + ParentTaskId = parentId, + SortOrder = (maxSort ?? -1) + 1, + }; + _context.Tasks.Add(child); + + if (tagNames is not null && tagNames.Count > 0) + { + foreach (var tagName in tagNames.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == tagName, ct); + if (tag is null) + { + tag = new TagEntity { Name = tagName }; + _context.Tags.Add(tag); + await _context.SaveChangesAsync(ct); + } + child.Tags.Add(tag); + } + } + + await _context.SaveChangesAsync(ct); + return child; +} +``` + +- [ ] **Step 4: Run; verify pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.CreateChildAsync"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs +git commit -m "feat(data): TaskRepository.CreateChildAsync" +``` + +--- + +## Task 7: Repository method `SetPlanningStartedAsync` + +**Files:** +- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` +- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` + +- [ ] **Step 1: Write the failing test** + +Append: + +```csharp +[Fact] +public async Task SetPlanningStartedAsync_ManualTask_TransitionsToPlanning() +{ + var listId = await CreateListAsync(); + var task = MakeTask(listId, TaskStatus.Manual); + await _tasks.AddAsync(task); + + var result = await _tasks.SetPlanningStartedAsync(task.Id, "tok-abc"); + + Assert.NotNull(result); + Assert.Equal(TaskStatus.Planning, result!.Status); + Assert.Equal("tok-abc", result.PlanningSessionToken); + + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Planning, loaded!.Status); + Assert.Equal("tok-abc", loaded.PlanningSessionToken); +} + +[Fact] +public async Task SetPlanningStartedAsync_NonManualTask_ReturnsNull() +{ + var listId = await CreateListAsync(); + var task = MakeTask(listId, TaskStatus.Queued); + await _tasks.AddAsync(task); + + var result = await _tasks.SetPlanningStartedAsync(task.Id, "tok-xyz"); + + Assert.Null(result); + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Queued, loaded!.Status); + Assert.Null(loaded.PlanningSessionToken); +} +``` + +- [ ] **Step 2: Run; verify fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.SetPlanningStarted"` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +In `#region Planning`: + +```csharp +public async Task SetPlanningStartedAsync( + string taskId, + string sessionToken, + CancellationToken ct = default) +{ + var affected = await _context.Tasks + .Where(t => t.Id == taskId && t.Status == TaskStatus.Manual) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Planning) + .SetProperty(t => t.PlanningSessionToken, sessionToken), ct); + + if (affected == 0) return null; + return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct); +} +``` + +- [ ] **Step 4: Run; verify pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.SetPlanningStarted"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs +git commit -m "feat(data): TaskRepository.SetPlanningStartedAsync" +``` + +--- + +## Task 8: Repository method `UpdatePlanningSessionIdAsync` + +**Files:** +- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` +- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +[Fact] +public async Task UpdatePlanningSessionIdAsync_StoresClaudeSessionId() +{ + var listId = await CreateListAsync(); + var task = MakeTask(listId, TaskStatus.Manual); + await _tasks.AddAsync(task); + await _tasks.SetPlanningStartedAsync(task.Id, "tok"); + + await _tasks.UpdatePlanningSessionIdAsync(task.Id, "claude-session-42"); + + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal("claude-session-42", loaded!.PlanningSessionId); +} +``` + +- [ ] **Step 2: Run; verify fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.UpdatePlanningSessionId"` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```csharp +public async Task UpdatePlanningSessionIdAsync( + string parentId, + string sessionId, + CancellationToken ct = default) +{ + await _context.Tasks + .Where(t => t.Id == parentId) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.PlanningSessionId, sessionId), ct); +} +``` + +- [ ] **Step 4: Run; verify pass** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs +git commit -m "feat(data): TaskRepository.UpdatePlanningSessionIdAsync" +``` + +--- + +## Task 9: Repository method `FindByPlanningTokenAsync` + +**Files:** +- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` +- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +[Fact] +public async Task FindByPlanningTokenAsync_ReturnsTask_WhenTokenMatches() +{ + var listId = await CreateListAsync(); + var task = MakeTask(listId, TaskStatus.Manual); + await _tasks.AddAsync(task); + await _tasks.SetPlanningStartedAsync(task.Id, "unique-token-123"); + + var found = await _tasks.FindByPlanningTokenAsync("unique-token-123"); + + Assert.NotNull(found); + Assert.Equal(task.Id, found!.Id); +} + +[Fact] +public async Task FindByPlanningTokenAsync_ReturnsNull_WhenTokenUnknown() +{ + var found = await _tasks.FindByPlanningTokenAsync("no-such-token"); + Assert.Null(found); +} +``` + +- [ ] **Step 2: Run; verify fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.FindByPlanningToken"` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```csharp +public async Task FindByPlanningTokenAsync( + string token, + CancellationToken ct = default) +{ + if (string.IsNullOrEmpty(token)) return null; + return await _context.Tasks + .AsNoTracking() + .FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct); +} +``` + +- [ ] **Step 4: Run; verify pass** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs +git commit -m "feat(data): TaskRepository.FindByPlanningTokenAsync" +``` + +--- + +## Task 10: Repository method `FinalizePlanningAsync` + +**Files:** +- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` +- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +[Fact] +public async Task FinalizePlanningAsync_TransitionsDraftsAndParent() +{ + var listId = await CreateListAsync(); + var parent = MakeTask(listId, TaskStatus.Manual); + await _tasks.AddAsync(parent); + await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); + + var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, tagNames: new[] { "agent" }, commitType: null); + var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, tagNames: null, commitType: null); + + var count = await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true); + + Assert.Equal(2, count); + + var c1Loaded = await _tasks.GetByIdAsync(c1.Id); + var c2Loaded = await _tasks.GetByIdAsync(c2.Id); + var parentLoaded = await _tasks.GetByIdAsync(parent.Id); + + Assert.Equal(TaskStatus.Queued, c1Loaded!.Status); + Assert.Equal(TaskStatus.Manual, c2Loaded!.Status); + Assert.Equal(TaskStatus.Planned, parentLoaded!.Status); + Assert.NotNull(parentLoaded.PlanningFinalizedAt); + Assert.Null(parentLoaded.PlanningSessionToken); +} + +[Fact] +public async Task FinalizePlanningAsync_QueueAgentTasksFalse_AllToManual() +{ + var listId = await CreateListAsync(); + var parent = MakeTask(listId, TaskStatus.Manual); + await _tasks.AddAsync(parent); + await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); + var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: new[] { "agent" }, commitType: null); + + await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false); + + var cLoaded = await _tasks.GetByIdAsync(c.Id); + Assert.Equal(TaskStatus.Manual, cLoaded!.Status); +} + +[Fact] +public async Task FinalizePlanningAsync_ParentWithAgentListTag_ChildIsQueued() +{ + var listId = await CreateListAsync(); + var agentTag = await _tags.GetOrCreateAsync("agent"); + // Attach "agent" tag to the list (list-level tags propagate to its tasks). + await _lists.AddTagAsync(listId, agentTag.Id); + + var parent = MakeTask(listId, TaskStatus.Manual); + await _tasks.AddAsync(parent); + await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); + var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: null, commitType: null); + + await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true); + + var cLoaded = await _tasks.GetByIdAsync(c.Id); + Assert.Equal(TaskStatus.Queued, cLoaded!.Status); +} +``` + +- [ ] **Step 2: Run; verify fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.FinalizePlanning"` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```csharp +public async Task FinalizePlanningAsync( + string parentId, + bool queueAgentTasks, + CancellationToken ct = default) +{ + using var tx = await _context.Database.BeginTransactionAsync(ct); + + var parent = await _context.Tasks + .Include(t => t.List).ThenInclude(l => l.Tags) + .FirstOrDefaultAsync(t => t.Id == parentId, ct); + if (parent is null || parent.Status != TaskStatus.Planning) + throw new InvalidOperationException($"Task {parentId} is not in Planning state."); + + var listHasAgentTag = parent.List.Tags.Any(t => t.Name == "agent"); + + var drafts = await _context.Tasks + .Include(t => t.Tags) + .Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft) + .ToListAsync(ct); + + int count = 0; + foreach (var draft in drafts) + { + var childHasAgentTag = draft.Tags.Any(t => t.Name == "agent"); + var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag); + draft.Status = shouldQueue ? TaskStatus.Queued : TaskStatus.Manual; + count++; + } + + parent.Status = TaskStatus.Planned; + parent.PlanningFinalizedAt = DateTime.UtcNow; + parent.PlanningSessionToken = null; + + await _context.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return count; +} +``` + +- [ ] **Step 4: Run; verify pass** + +Expected: PASS for all three finalize tests. + +- [ ] **Step 5: Commit** + +```bash +git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs +git commit -m "feat(data): TaskRepository.FinalizePlanningAsync" +``` + +--- + +## Task 11: Repository method `DiscardPlanningAsync` + +**Files:** +- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` +- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +[Fact] +public async Task DiscardPlanningAsync_DeletesDraftsAndResetsParent() +{ + var listId = await CreateListAsync(); + var parent = MakeTask(listId, TaskStatus.Manual); + await _tasks.AddAsync(parent); + await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); + await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-42"); + var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); + var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); + + var ok = await _tasks.DiscardPlanningAsync(parent.Id); + + Assert.True(ok); + Assert.Null(await _tasks.GetByIdAsync(c1.Id)); + Assert.Null(await _tasks.GetByIdAsync(c2.Id)); + + var parentLoaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Manual, parentLoaded!.Status); + Assert.Null(parentLoaded.PlanningSessionId); + Assert.Null(parentLoaded.PlanningSessionToken); + Assert.Null(parentLoaded.PlanningFinalizedAt); +} + +[Fact] +public async Task DiscardPlanningAsync_OnNonPlanningTask_ReturnsFalse() +{ + var listId = await CreateListAsync(); + var task = MakeTask(listId, TaskStatus.Manual); + await _tasks.AddAsync(task); + + var ok = await _tasks.DiscardPlanningAsync(task.Id); + + Assert.False(ok); +} +``` + +- [ ] **Step 2: Run; verify fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.DiscardPlanning"` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```csharp +public async Task DiscardPlanningAsync( + string parentId, + CancellationToken ct = default) +{ + using var tx = await _context.Database.BeginTransactionAsync(ct); + + var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct); + if (parent is null || parent.Status != TaskStatus.Planning) + { + await tx.RollbackAsync(ct); + return false; + } + + await _context.Tasks + .Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft) + .ExecuteDeleteAsync(ct); + + parent.Status = TaskStatus.Manual; + parent.PlanningSessionId = null; + parent.PlanningSessionToken = null; + parent.PlanningFinalizedAt = null; + + await _context.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return true; +} +``` + +- [ ] **Step 4: Run; verify pass** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs +git commit -m "feat(data): TaskRepository.DiscardPlanningAsync" +``` + +--- + +## Task 12: Repository method `TryCompleteParentAsync` (auto-status hook) + +**Files:** +- Create: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs` +- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` + +- [ ] **Step 1: Create the test class** + +Create `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs`: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Repositories; + +public sealed class TaskRepositoryParentCompletionTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + + public TaskRepositoryParentCompletionTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + private async Task ListAsync() + { + var id = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow }); + return id; + } + + private async Task PlannedParentAsync(string listId) + { + var parent = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "p", + Status = TaskStatus.Planned, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(parent); + return parent; + } + + private async Task ChildAsync(string listId, string parentId, TaskStatus status) + { + var child = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "c", + Status = status, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + ParentTaskId = parentId, + }; + await _tasks.AddAsync(child); + return child; + } +} +``` + +- [ ] **Step 2: Write failing tests** + +Append inside the class: + +```csharp +[Fact] +public async Task TryCompleteParentAsync_AllChildrenDone_ParentBecomesDone() +{ + var listId = await ListAsync(); + var parent = await PlannedParentAsync(listId); + await ChildAsync(listId, parent.Id, TaskStatus.Done); + await ChildAsync(listId, parent.Id, TaskStatus.Done); + + await _tasks.TryCompleteParentAsync(parent.Id); + + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Done, loaded!.Status); + Assert.NotNull(loaded.FinishedAt); +} + +[Fact] +public async Task TryCompleteParentAsync_OneFailedRestDone_ParentBecomesFailed() +{ + var listId = await ListAsync(); + var parent = await PlannedParentAsync(listId); + await ChildAsync(listId, parent.Id, TaskStatus.Done); + await ChildAsync(listId, parent.Id, TaskStatus.Failed); + + await _tasks.TryCompleteParentAsync(parent.Id); + + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Failed, loaded!.Status); + Assert.NotNull(loaded.FinishedAt); +} + +[Fact] +public async Task TryCompleteParentAsync_OneStillRunning_ParentStaysPlanned() +{ + var listId = await ListAsync(); + var parent = await PlannedParentAsync(listId); + await ChildAsync(listId, parent.Id, TaskStatus.Done); + await ChildAsync(listId, parent.Id, TaskStatus.Running); + + await _tasks.TryCompleteParentAsync(parent.Id); + + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Planned, loaded!.Status); + Assert.Null(loaded.FinishedAt); +} + +[Fact] +public async Task TryCompleteParentAsync_ChildStillDraft_ParentStaysPlanned() +{ + var listId = await ListAsync(); + var parent = await PlannedParentAsync(listId); + await ChildAsync(listId, parent.Id, TaskStatus.Done); + await ChildAsync(listId, parent.Id, TaskStatus.Draft); + + await _tasks.TryCompleteParentAsync(parent.Id); + + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Planned, loaded!.Status); +} + +[Fact] +public async Task TryCompleteParentAsync_ParentIsNotPlanned_NoChange() +{ + var listId = await ListAsync(); + var parent = await PlannedParentAsync(listId); + await _ctx.Database.ExecuteSqlRawAsync("UPDATE tasks SET status = 'planning' WHERE id = {0}", parent.Id); + await ChildAsync(listId, parent.Id, TaskStatus.Done); + + await _tasks.TryCompleteParentAsync(parent.Id); + + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Planning, loaded!.Status); +} +``` + +- [ ] **Step 3: Run; verify fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryParentCompletionTests"` +Expected: FAIL. + +- [ ] **Step 4: Implement** + +In `#region Planning` of `TaskRepository.cs`: + +```csharp +public async Task TryCompleteParentAsync( + string parentId, + CancellationToken ct = default) +{ + var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct); + if (parent is null || parent.Status != TaskStatus.Planned) return; + + var children = await _context.Tasks + .Where(t => t.ParentTaskId == parentId) + .Select(t => t.Status) + .ToListAsync(ct); + + if (children.Count == 0) return; + + bool allTerminal = children.All(s => s == TaskStatus.Done || s == TaskStatus.Failed); + if (!allTerminal) return; + + bool anyFailed = children.Any(s => s == TaskStatus.Failed); + parent.Status = anyFailed ? TaskStatus.Failed : TaskStatus.Done; + parent.FinishedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(ct); +} +``` + +- [ ] **Step 5: Run; verify pass** + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs +git commit -m "feat(data): TaskRepository.TryCompleteParentAsync" +``` + +--- + +## Task 13: Regression test — queue skips Drafts/Planning/Planned + +**Files:** +- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` + +- [ ] **Step 1: Write the test** + +Append to planning test class: + +```csharp +[Fact] +public async Task GetNextQueuedAgentTask_SkipsDraftPlanningPlanned() +{ + var listId = await CreateListAsync(); + var agent = await _tags.GetOrCreateAsync("agent"); + + async Task T(TaskStatus s, bool withTag, string? parent = null) + { + var t = MakeTask(listId, s, parentId: parent); + await _tasks.AddAsync(t); + if (withTag) await _tasks.AddTagAsync(t.Id, agent.Id); + return t; + } + + var planning = await T(TaskStatus.Planning, withTag: true); + var planned = await T(TaskStatus.Planned, withTag: true); + var draft = await T(TaskStatus.Draft, withTag: true, parent: planning.Id); + var queued = await T(TaskStatus.Queued, withTag: true); + + var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow); + + Assert.NotNull(picked); + Assert.Equal(queued.Id, picked!.Id); +} +``` + +- [ ] **Step 2: Run** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.GetNextQueuedAgentTask_SkipsDraftPlanningPlanned"` +Expected: PASS immediately (the existing query filters on `status = 'queued'`, so Planning/Planned/Draft are already excluded). + +If this test fails, something is wrong with the queue filter — investigate before continuing. + +- [ ] **Step 3: Commit** + +```bash +git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs +git commit -m "test(data): queue skips Planning/Planned/Draft" +``` + +--- + +## Task 14: Regression test — Restrict cascade on parent delete + +**Files:** +- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` + +- [ ] **Step 1: Write the test** + +```csharp +[Fact] +public async Task DeleteAsync_ParentWithChildren_ThrowsOrDoesNotDelete() +{ + var listId = await CreateListAsync(); + var parent = MakeTask(listId, TaskStatus.Planning); + await _tasks.AddAsync(parent); + await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + + // ExecuteDelete bypasses the FK check in EF but SQLite enforces it at the DB level + // when foreign_keys = ON (which ClaudeDoDbContext enables). + await Assert.ThrowsAsync(async () => + { + await _tasks.DeleteAsync(parent.Id); + }); + + var stillThere = await _tasks.GetByIdAsync(parent.Id); + Assert.NotNull(stillThere); +} +``` + +- [ ] **Step 2: Run** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.DeleteAsync_ParentWithChildren"` +Expected: PASS. + +**If this test fails** because `ExecuteDeleteAsync` succeeds: SQLite may not be enforcing FKs in the test environment. Diagnose: +- Confirm `ClaudeDoDbContext` sets `PRAGMA foreign_keys = ON` on connection open. +- If not enforced, we need to replace `ExecuteDeleteAsync` in `DeleteAsync` with an EF-tracked delete (`_context.Tasks.Remove(...); SaveChangesAsync()`) so EF enforces Restrict before SQL is sent. + +If FK enforcement is the issue, add this helper to `ClaudeDoDbContext.OnConfiguring` (if not already there) or verify the existing pragma: +```csharp +optionsBuilder.UseSqlite(conn, opts => opts.CommandTimeout(30)); +``` +and ensure the connection string or `UseSqlite` callback issues `PRAGMA foreign_keys = ON`. + +- [ ] **Step 3: Commit** + +```bash +git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs +# include any DbContext fix if needed +git commit -m "test(data): parent delete with children is restricted" +``` + +--- + +## Task 15: Wire `TryCompleteParentAsync` into `TaskRunner` + +**Files:** +- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` +- Create: `tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs` (integration) + +- [ ] **Step 1: Locate the call-sites** + +Open `src/ClaudeDo.Worker/Runner/TaskRunner.cs`. Three locations already call `MarkDoneAsync` / `MarkFailedAsync` (line numbers per spec reference: ~333, ~348, ~362). + +At each location, the variable `task` (or `taskId` in the last two) is in scope. For the one at line ~333 `task` is the full `TaskEntity`; for the two failure paths, only `taskId` — but we can load the parent id from the repo. + +- [ ] **Step 2: Update the Done-path (around line 333)** + +Right after: +```csharp +await taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None); +``` +add: +```csharp +if (task.ParentTaskId is not null) + await taskRepo.TryCompleteParentAsync(task.ParentTaskId, CancellationToken.None); +``` + +- [ ] **Step 3: Update the two Failed-paths (around lines 348, 362)** + +For each, right after the `MarkFailedAsync` call, add a block that re-reads the task to get `ParentTaskId` (the scope has `taskId` not the entity): + +```csharp +await taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None); +var justFailed = await taskRepo.GetByIdAsync(taskId, CancellationToken.None); +if (justFailed?.ParentTaskId is not null) + await taskRepo.TryCompleteParentAsync(justFailed.ParentTaskId, CancellationToken.None); +``` + +Do the same for the second failure path at line ~362. + +- [ ] **Step 4: Build** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: builds. + +- [ ] **Step 5: Write integration test** + +Create `tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs`: + +The intent is to exercise the full path: child task ends → parent updates. Rather than invoking the full `TaskRunner` (which requires Claude CLI), we test the repository integration by calling `MarkDoneAsync` + `TryCompleteParentAsync` in the same sequence the runner now does. + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Runner; + +public sealed class TaskRunnerParentCompletionTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + + public TaskRunnerParentCompletionTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + [Fact] + public async Task ChildMarkedDone_LastOne_ParentFinalized() + { + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + + var parent = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "p", + Status = TaskStatus.Planned, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(parent); + + var c1 = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "c1", + Status = TaskStatus.Done, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + ParentTaskId = parent.Id, + }; + var c2 = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "c2", + Status = TaskStatus.Running, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + ParentTaskId = parent.Id, + }; + await _tasks.AddAsync(c1); + await _tasks.AddAsync(c2); + + // Simulate the runner finishing the second child: + await _tasks.MarkDoneAsync(c2.Id, DateTime.UtcNow, "done"); + if (c2.ParentTaskId is not null) + await _tasks.TryCompleteParentAsync(c2.ParentTaskId); + + var parentLoaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Done, parentLoaded!.Status); + Assert.NotNull(parentLoaded.FinishedAt); + } +} +``` + +- [ ] **Step 6: Run; verify pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRunnerParentCompletionTests"` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs +git commit -m "feat(worker): hook TryCompleteParentAsync after MarkDone/MarkFailed" +``` + +--- + +## Task 16: Full test run and merge preparation + +**Files:** none (verification step) + +- [ ] **Step 1: Run the full test suite** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests` +Expected: all tests pass, no regressions. + +- [ ] **Step 2: Verify build of every project** + +Run: +```bash +dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj +dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj +``` +Expected: all succeed. + +- [ ] **Step 3: Verify the migration file is present for parallel-plan detection** + +Run: +```bash +ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs +``` +Expected: exactly one file matches (the migration + designer file). This is the marker that Plans B and C wait for. + +- [ ] **Step 4: Summarize what's done** + +Plan A now delivers: +- Three new `TaskStatus` values (Planning/Planned/Draft) with persistence. +- Four new columns on `tasks` (`parent_task_id`, `planning_session_id`, `planning_session_token`, `planning_finalized_at`). +- Self-ref FK with `DeleteBehavior.Restrict`. +- Seven new repository methods. +- Auto-parent-completion hook wired into `TaskRunner`. +- Regression test that the queue still only picks `Queued`. +- Regression test that parent delete with children fails. + +Nothing UI-visible changes yet; users notice only that deleting a task that has children now fails with a DB error (Plan C will fix the UX by adding a confirmation dialog). + +--- + +## Out of scope for Plan A + +- Any MCP server, SignalR endpoints, or terminal launcher → Plan B. +- Any UI rendering of hierarchy, context menu entries, or draft styling → Plan C. +- Session-folder file management (`~/.todo-app/planning-sessions/`) → Plan B. +- Resolving unknowns around Claude CLI flags (`--thinking-budget`, `--allowedTools` casing, session-ID capture) → Plan B. diff --git a/docs/superpowers/plans/2026-04-23-planning-sessions-plan-b-worker-mcp.md b/docs/superpowers/plans/2026-04-23-planning-sessions-plan-b-worker-mcp.md new file mode 100644 index 0000000..0306cd2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-planning-sessions-plan-b-worker-mcp.md @@ -0,0 +1,1644 @@ +# Planning Sessions — Plan B: Worker MCP + Launcher 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:** Add the MCP server, SignalR endpoints, session-folder management, and Windows Terminal launcher that make planning sessions actually runnable end-to-end. + +**Architecture:** A `PlanningSessionManager` owns session-directory creation and lifecycle (Start/Resume/Discard/Finalize). A `PlanningMcpService` hosts MCP tools over streamable HTTP alongside the existing SignalR hub on `127.0.0.1:47821`, authenticating each call via a per-session bearer token that maps to a parent task. A `WindowsTerminalPlanningLauncher` spawns `wt.exe` with the configured Claude CLI arguments. Hub endpoints coordinate between the UI and these services. + +**Tech Stack:** .NET 8, ASP.NET Core (existing Kestrel host), SignalR, ModelContextProtocol C# SDK, xUnit. + +**Spec reference:** `docs/superpowers/specs/2026-04-23-planning-sessions-design.md` sections 4, 5, 6.6, 7. + +--- + +## Prerequisite Gate + +This plan depends on Plan A being merged to `main`. **Before starting any implementation task**, verify the marker file exists: + +```bash +git fetch origin main +git checkout main +git pull --ff-only +ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs +``` + +- If the file exists → proceed. +- If it does not exist → Plan A has not merged yet. Wait and re-check periodically: + ```bash + while ! ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs >/dev/null 2>&1; do + echo "Waiting for Plan A to merge..." + sleep 60 + git fetch origin main && git pull --ff-only + done + echo "Plan A merged — proceeding." + ``` +- Then create a new branch off `main` for this plan: + ```bash + git checkout -b feat/planning-sessions-worker + ``` + +--- + +## File Structure + +**Created:** +- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — session lifecycle, file generation, DB orchestration. +- `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs` — DTO with paths to generated files. +- `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs` — DTO returned from Start/Resume (paths + tokens + CWD). +- `src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs` — launcher interface. +- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs` — `wt.exe` invocation. +- `src/ClaudeDo.Worker/Planning/PlanningMcpService.cs` — MCP tool class. +- `src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs` — middleware resolving bearer token → parent task id. +- `src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs` — per-request context carrying parent id + repo handles. +- `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` +- `tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs` +- `tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs` +- `tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs` + +**Modified:** +- `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — add ModelContextProtocol package reference. +- `src/ClaudeDo.Worker/Program.cs` (or equivalent Startup/Host composition) — map MCP endpoint, register DI. +- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` (or whichever file defines the SignalR hub) — add five new hub methods. + +**Paths used at runtime:** `~/.todo-app/planning-sessions//{mcp.json, system-prompt.md, initial-prompt.txt}`. + +--- + +## Task 1: Add ModelContextProtocol NuGet package + +**Files:** +- Modify: `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` + +- [ ] **Step 1: Add package** + +Run: +```bash +dotnet add src/ClaudeDo.Worker package ModelContextProtocol +dotnet add src/ClaudeDo.Worker package ModelContextProtocol.AspNetCore +``` + +If the second package name does not exist in your nuget configuration, try `ModelContextProtocol.Server.HttpTransport` or the latest canonical ASP.NET Core integration package per NuGet. Check the current package catalog with: +```bash +dotnet package search ModelContextProtocol +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: builds cleanly. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Worker/ClaudeDo.Worker.csproj +git commit -m "chore(worker): add ModelContextProtocol package" +``` + +--- + +## Task 2: Resolve Claude CLI unknowns via Context7 + +**Files:** none (research step, output recorded in a comment in Task 8) + +- [ ] **Step 1: Query Context7 docs for Claude Code CLI** + +Use the Context7 MCP tool in your session to fetch current docs: + +Questions to resolve: +1. **Thinking budget flag:** What flag and value set medium extended thinking? (`--thinking-budget medium`? model suffix? `--budget` variable?) +2. **Allowed-tools casing:** What is the exact form for `--allowedTools` / `--allowed-tools` for `Read`, `Grep`, `Glob`, `WebFetch`, `WebSearch`, `Skill`, and `mcp____` wildcards? +3. **System prompt file reference:** Does `--append-system-prompt` accept `@path` or only inline string? +4. **Session-ID capture:** Does `--session-id ` exist to pre-assign? If not, where are session files written and how do we read the ID after launch? + +Record answers in an inline comment at the top of `WindowsTerminalPlanningLauncher.cs` (created in Task 8) like: + +```csharp +// Claude CLI flags (verified via Context7): +// thinking budget: +// allowedTools casing: +// append-system-prompt: +// session id capture: +``` + +- [ ] **Step 2: Update spec §5.8 if answers materially change the design** + +If the answers contradict section 5.8 of the spec (e.g., `--session-id` flag exists and can pre-assign), open the spec file and mark the resolved item with the concrete answer. + +- [ ] **Step 3: No commit needed yet** — the research output lives in Task 8's launcher code. Continue to Task 3. + +--- + +## Task 3: `PlanningSessionManager` — Start + +**Files:** +- Create: `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs` +- Create: `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs` +- Create: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` +- Create: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` + +- [ ] **Step 1: Create the DTOs** + +`src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs`: + +```csharp +namespace ClaudeDo.Worker.Planning; + +public sealed record PlanningSessionFiles( + string SessionDirectory, + string McpConfigPath, + string SystemPromptPath, + string InitialPromptPath); +``` + +`src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs`: + +```csharp +namespace ClaudeDo.Worker.Planning; + +public sealed record PlanningSessionStartContext( + string ParentTaskId, + string WorkingDir, + PlanningSessionFiles Files); + +public sealed record PlanningSessionResumeContext( + string ParentTaskId, + string WorkingDir, + string ClaudeSessionId, + string McpConfigPath); +``` + +- [ ] **Step 2: Write failing tests for `StartAsync`** + +`tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs`: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Planning; + +public sealed class PlanningSessionManagerTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private readonly string _rootDir; + private readonly PlanningSessionManager _sut; + + public PlanningSessionManagerTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + _rootDir = Path.Combine(Path.GetTempPath(), $"cd_planning_{Guid.NewGuid():N}"); + _sut = new PlanningSessionManager(_tasks, _rootDir); + } + + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + try { Directory.Delete(_rootDir, recursive: true); } catch { /* ignore */ } + } + + private async Task<(string listId, string workingDir)> SeedListAsync() + { + var listId = Guid.NewGuid().ToString(); + var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}"); + Directory.CreateDirectory(wd); + await _lists.AddAsync(new ListEntity + { + Id = listId, + Name = "Test", + WorkingDir = wd, + CreatedAt = DateTime.UtcNow, + }); + return (listId, wd); + } + + private async Task SeedManualTaskAsync(string listId) + { + var t = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "Brainstorm auth", + Description = "- review tokens\n- plan rollout", + Status = TaskStatus.Manual, + CreatedAt = DateTime.UtcNow, + CommitType = "feat", + }; + await _tasks.AddAsync(t); + return t; + } + + [Fact] + public async Task StartAsync_CreatesSessionFiles_AndTransitionsTaskToPlanning() + { + var (listId, wd) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + + var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None); + + Assert.Equal(parent.Id, ctx.ParentTaskId); + Assert.Equal(wd, ctx.WorkingDir); + Assert.True(File.Exists(ctx.Files.McpConfigPath)); + Assert.True(File.Exists(ctx.Files.SystemPromptPath)); + Assert.True(File.Exists(ctx.Files.InitialPromptPath)); + + var mcp = await File.ReadAllTextAsync(ctx.Files.McpConfigPath); + Assert.Contains("\"type\": \"http\"", mcp); + Assert.Contains("Bearer ", mcp); + + var initial = await File.ReadAllTextAsync(ctx.Files.InitialPromptPath); + Assert.Contains("Brainstorm auth", initial); + Assert.Contains("review tokens", initial); + + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Planning, loaded!.Status); + Assert.NotNull(loaded.PlanningSessionToken); + } + + [Fact] + public async Task StartAsync_TaskNotManual_Throws() + { + var (listId, _) = await SeedListAsync(); + var queuedTask = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "x", + Status = TaskStatus.Queued, + CreatedAt = DateTime.UtcNow, + CommitType = "feat", + }; + await _tasks.AddAsync(queuedTask); + + await Assert.ThrowsAsync(() => + _sut.StartAsync(queuedTask.Id, CancellationToken.None)); + } + + [Fact] + public async Task StartAsync_ChildTask_Throws() + { + var (listId, _) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + await _tasks.SetPlanningStartedAsync(parent.Id, "t"); + var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + + await Assert.ThrowsAsync(() => + _sut.StartAsync(child.Id, CancellationToken.None)); + } +} +``` + +- [ ] **Step 3: Run; verify fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~PlanningSessionManagerTests.StartAsync"` +Expected: FAIL (type not defined). + +- [ ] **Step 4: Implement `PlanningSessionManager.StartAsync`** + +`src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`: + +```csharp +using System.Security.Cryptography; +using System.Text.Json; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Planning; + +public sealed class PlanningSessionManager +{ + private const string McpServerUrl = "http://127.0.0.1:47821/mcp"; + + private readonly TaskRepository _tasks; + private readonly string _rootDirectory; + + public PlanningSessionManager(TaskRepository tasks, string rootDirectory) + { + _tasks = tasks; + _rootDirectory = rootDirectory; + } + + public async Task StartAsync(string taskId, CancellationToken ct) + { + var task = await _tasks.GetByIdAsync(taskId, ct) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (task.ParentTaskId is not null) + throw new InvalidOperationException("Cannot start a planning session on a child task."); + if (task.Status != TaskStatus.Manual) + throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning."); + + var token = GenerateToken(); + var updated = await _tasks.SetPlanningStartedAsync(taskId, token, ct) + ?? throw new InvalidOperationException("Failed to transition task to Planning (already Planning?)."); + + var sessionDir = Path.Combine(_rootDirectory, taskId); + Directory.CreateDirectory(sessionDir); + + var files = new PlanningSessionFiles( + SessionDirectory: sessionDir, + McpConfigPath: Path.Combine(sessionDir, "mcp.json"), + SystemPromptPath: Path.Combine(sessionDir, "system-prompt.md"), + InitialPromptPath: Path.Combine(sessionDir, "initial-prompt.txt")); + + await File.WriteAllTextAsync(files.McpConfigPath, BuildMcpConfigJson(token), ct); + await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct); + await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct); + + var workingDir = await LookupWorkingDirAsync(task.ListId, ct); + return new PlanningSessionStartContext(taskId, workingDir, files); + } + + private static string GenerateToken() + { + var bytes = new byte[32]; + RandomNumberGenerator.Fill(bytes); + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + private static string BuildMcpConfigJson(string token) + { + var payload = new + { + mcpServers = new + { + claudedo = new + { + type = "http", + url = McpServerUrl, + headers = new Dictionary + { + ["Authorization"] = $"Bearer {token}" + } + } + } + }; + return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); + } + + private static string BuildSystemPrompt() + { + return """ + You are in a ClaudeDo planning session for a task. Your job is to brainstorm with + the user, then break their rough intent into concrete, independently-executable + child-tasks. Each child-task should be something a single automated agent can pick + up and complete autonomously. + + Use the `mcp__claudedo__*` tools to create/update/delete drafts in real time. + You may read the repository for context (Read/Grep/Glob) but must NOT modify any + files. Skills you may find useful: `superpowers:writing-plans`, + `superpowers:writing-clearly-and-concisely`. + + When the user is satisfied, call `finalize` to commit the drafts as regular tasks. + """; + } + + private static string BuildInitialPrompt(TaskEntity parent) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine(parent.Title); + if (!string.IsNullOrWhiteSpace(parent.Description)) + { + sb.AppendLine(); + sb.AppendLine(parent.Description); + } + sb.AppendLine(); + sb.AppendLine("---"); + sb.AppendLine("We're planning this task together. Brainstorm with me, then create concrete child-tasks via the MCP tools. I'll call `finalize` when we're done."); + return sb.ToString(); + } + + private async Task LookupWorkingDirAsync(string listId, CancellationToken ct) + { + // This uses the repo's DbContext indirectly via a fresh query. + // Alternative: accept ListRepository as a dependency; chose lighter coupling here. + using var ctx = _tasks.GetType() + .GetField("_context", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(_tasks) as ClaudeDo.Data.ClaudeDoDbContext + ?? throw new InvalidOperationException("Cannot access DbContext."); + // For production, prefer injecting ListRepository; the reflection shortcut keeps + // Plan B's surface small but should be cleaned up when ListRepository-injection is added. + var wd = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions + .FirstOrDefaultAsync(ctx.Lists, l => l.Id == listId, ct); + return wd?.WorkingDir ?? throw new InvalidOperationException($"List {listId} not found."); + } +} +``` + +**Note for reviewer:** The reflection shortcut in `LookupWorkingDirAsync` is a smell. Preferred: inject `ListRepository` into the constructor. If the reviewer flags it, switch to constructor injection of `ListRepository` and use `_lists.GetByIdAsync(listId, ct).WorkingDir`. + +- [ ] **Step 5: Run; verify pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~PlanningSessionManagerTests.StartAsync"` +Expected: PASS for all three StartAsync tests. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/ tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs +git commit -m "feat(worker): PlanningSessionManager.StartAsync" +``` + +--- + +## Task 4: `PlanningSessionManager` — Resume + +**Files:** +- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` +- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` + +- [ ] **Step 1: Write failing test** + +```csharp +[Fact] +public async Task ResumeAsync_ReturnsExistingSessionDetails() +{ + var (listId, wd) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None); + await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-session-42"); + + var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None); + + Assert.Equal(parent.Id, resumeCtx.ParentTaskId); + Assert.Equal(wd, resumeCtx.WorkingDir); + Assert.Equal("claude-session-42", resumeCtx.ClaudeSessionId); + Assert.Equal(startCtx.Files.McpConfigPath, resumeCtx.McpConfigPath); + Assert.True(File.Exists(resumeCtx.McpConfigPath)); +} + +[Fact] +public async Task ResumeAsync_NotPlanning_Throws() +{ + var (listId, _) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + // did not start + await Assert.ThrowsAsync(() => + _sut.ResumeAsync(parent.Id, CancellationToken.None)); +} + +[Fact] +public async Task ResumeAsync_NoClaudeSessionId_Throws() +{ + var (listId, _) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + await _sut.StartAsync(parent.Id, CancellationToken.None); + // UpdatePlanningSessionIdAsync not called + + await Assert.ThrowsAsync(() => + _sut.ResumeAsync(parent.Id, CancellationToken.None)); +} +``` + +- [ ] **Step 2: Run; verify fail** + +Expected: FAIL (ResumeAsync not defined). + +- [ ] **Step 3: Implement** + +In `PlanningSessionManager`: + +```csharp +public async Task ResumeAsync(string taskId, CancellationToken ct) +{ + var task = await _tasks.GetByIdAsync(taskId, ct) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (task.Status != TaskStatus.Planning) + throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning."); + if (string.IsNullOrEmpty(task.PlanningSessionId)) + throw new InvalidOperationException("No Claude session ID captured yet; cannot resume."); + + var sessionDir = Path.Combine(_rootDirectory, taskId); + var mcpConfigPath = Path.Combine(sessionDir, "mcp.json"); + if (!File.Exists(mcpConfigPath)) + throw new InvalidOperationException($"Session directory missing: {sessionDir}"); + + var workingDir = await LookupWorkingDirAsync(task.ListId, ct); + return new PlanningSessionResumeContext(taskId, workingDir, task.PlanningSessionId, mcpConfigPath); +} +``` + +- [ ] **Step 4: Run; verify pass** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs +git commit -m "feat(worker): PlanningSessionManager.ResumeAsync" +``` + +--- + +## Task 5: `PlanningSessionManager` — Discard + +**Files:** +- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` +- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` + +- [ ] **Step 1: Write failing test** + +```csharp +[Fact] +public async Task DiscardAsync_DeletesSessionDirAndResetsTask() +{ + var (listId, _) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None); + Assert.True(Directory.Exists(startCtx.Files.SessionDirectory)); + + await _sut.DiscardAsync(parent.Id, CancellationToken.None); + + Assert.False(Directory.Exists(startCtx.Files.SessionDirectory)); + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Manual, loaded!.Status); + Assert.Null(loaded.PlanningSessionToken); +} +``` + +- [ ] **Step 2: Run; verify fail** + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```csharp +public async Task DiscardAsync(string taskId, CancellationToken ct) +{ + var ok = await _tasks.DiscardPlanningAsync(taskId, ct); + var sessionDir = Path.Combine(_rootDirectory, taskId); + if (Directory.Exists(sessionDir)) + { + try { Directory.Delete(sessionDir, recursive: true); } + catch { /* best effort */ } + } + if (!ok) + throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard."); +} +``` + +- [ ] **Step 4: Run; verify pass** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs +git commit -m "feat(worker): PlanningSessionManager.DiscardAsync" +``` + +--- + +## Task 6: `PlanningSessionManager` — Finalize + +**Files:** +- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` +- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` + +- [ ] **Step 1: Write failing test** + +```csharp +[Fact] +public async Task FinalizeAsync_PromotesDraftsAndMarksPlanned() +{ + var (listId, _) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + await _sut.StartAsync(parent.Id, CancellationToken.None); + await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); + await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); + + var count = await _sut.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None); + + Assert.Equal(2, count); + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Planned, loaded!.Status); +} +``` + +- [ ] **Step 2: Run; verify fail** + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```csharp +public Task FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct) + => _tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct); +``` + +- [ ] **Step 4: Run; verify pass** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs +git commit -m "feat(worker): PlanningSessionManager.FinalizeAsync" +``` + +--- + +## Task 7: `PlanningSessionManager` — GetPendingDraftCount + +**Files:** +- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` +- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` + +- [ ] **Step 1: Write failing test** + +```csharp +[Fact] +public async Task GetPendingDraftCountAsync_ReturnsDraftCount() +{ + var (listId, _) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + await _sut.StartAsync(parent.Id, CancellationToken.None); + await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); + await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); + await _tasks.CreateChildAsync(parent.Id, "c3", null, null, null); + + var n = await _sut.GetPendingDraftCountAsync(parent.Id, CancellationToken.None); + + Assert.Equal(3, n); +} +``` + +- [ ] **Step 2: Run; verify fail** + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```csharp +public async Task GetPendingDraftCountAsync(string taskId, CancellationToken ct) +{ + var children = await _tasks.GetChildrenAsync(taskId, ct); + return children.Count(c => c.Status == TaskStatus.Draft); +} +``` + +- [ ] **Step 4: Run; verify pass** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs +git commit -m "feat(worker): PlanningSessionManager.GetPendingDraftCountAsync" +``` + +--- + +## Task 8: Terminal launcher + +**Files:** +- Create: `src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs` +- Create: `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs` +- Create: `tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs` + +- [ ] **Step 1: Define the interface** + +`src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs`: + +```csharp +namespace ClaudeDo.Worker.Planning; + +public interface IPlanningTerminalLauncher +{ + Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken); + Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken); +} + +public sealed class PlanningLaunchException : Exception +{ + public PlanningLaunchException(string message) : base(message) { } +} +``` + +- [ ] **Step 2: Write failing tests for pre-flight checks** + +`tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs`: + +```csharp +using ClaudeDo.Worker.Planning; + +namespace ClaudeDo.Worker.Tests.Planning; + +public sealed class WindowsTerminalPlanningLauncherTests +{ + private static PlanningSessionStartContext MakeStartCtx(string? wd = null) + { + var workingDir = wd ?? Path.GetTempPath(); + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(dir); + return new PlanningSessionStartContext( + ParentTaskId: "task-1", + WorkingDir: workingDir, + Files: new PlanningSessionFiles( + SessionDirectory: dir, + McpConfigPath: Path.Combine(dir, "mcp.json"), + SystemPromptPath: Path.Combine(dir, "system-prompt.md"), + InitialPromptPath: Path.Combine(dir, "initial-prompt.txt"))); + } + + [Fact] + public async Task LaunchStartAsync_WorkingDirMissing_Throws() + { + var ctx = MakeStartCtx(wd: Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid())); + var sut = new WindowsTerminalPlanningLauncher(wtPath: "wt", claudePath: "claude"); + var ex = await Assert.ThrowsAsync(() => + sut.LaunchStartAsync(ctx, CancellationToken.None)); + Assert.Contains("Working directory", ex.Message); + } + + [Fact] + public async Task LaunchStartAsync_WtMissing_Throws() + { + var ctx = MakeStartCtx(); + File.WriteAllText(ctx.Files.McpConfigPath, "{}"); + File.WriteAllText(ctx.Files.SystemPromptPath, "sp"); + File.WriteAllText(ctx.Files.InitialPromptPath, "ip"); + + var sut = new WindowsTerminalPlanningLauncher( + wtPath: "C:/no/such/wt.exe", + claudePath: "claude"); + var ex = await Assert.ThrowsAsync(() => + sut.LaunchStartAsync(ctx, CancellationToken.None)); + Assert.Contains("Windows Terminal", ex.Message); + } +} +``` + +- [ ] **Step 3: Run; verify fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~WindowsTerminalPlanningLauncherTests"` +Expected: FAIL. + +- [ ] **Step 4: Implement the launcher** + +`src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs`: + +```csharp +// Claude CLI flags (verified via Context7 in Task 2): +// thinking budget: +// allowedTools casing: +// append-system-prompt: +// session id capture: + +using System.Diagnostics; + +namespace ClaudeDo.Worker.Planning; + +public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher +{ + private readonly string _wtPath; + private readonly string _claudePath; + + public WindowsTerminalPlanningLauncher(string wtPath = "wt", string claudePath = "claude") + { + _wtPath = wtPath; + _claudePath = claudePath; + } + + public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken) + { + if (!Directory.Exists(ctx.WorkingDir)) + throw new PlanningLaunchException($"Working directory not found: {ctx.WorkingDir}"); + if (!File.Exists(ctx.Files.McpConfigPath)) + throw new PlanningLaunchException($"MCP config missing: {ctx.Files.McpConfigPath}"); + if (!IsResolvable(_wtPath)) + throw new PlanningLaunchException("Windows Terminal (wt.exe) not found in PATH."); + if (!IsResolvable(_claudePath)) + throw new PlanningLaunchException("Claude CLI (claude) not found in PATH."); + + var systemPrompt = File.ReadAllText(ctx.Files.SystemPromptPath); + var initialPrompt = File.ReadAllText(ctx.Files.InitialPromptPath); + + // NOTE: Fill in the exact flags verified in Task 2. + var claudeArgs = new List + { + "--model", "claude-sonnet-4-6", + // "--thinking-budget", "medium", // UNCOMMENT and adjust per Task 2 + "--append-system-prompt", systemPrompt, + "--mcp-config", ctx.Files.McpConfigPath, + "--allowedTools", "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill", + initialPrompt, + }; + + var wtArgs = new List { "-d", ctx.WorkingDir, "cmd", "/k", _claudePath }; + wtArgs.AddRange(claudeArgs); + + var psi = new ProcessStartInfo + { + FileName = _wtPath, + UseShellExecute = true, + }; + foreach (var a in wtArgs) psi.ArgumentList.Add(a); + + Process.Start(psi); + return Task.CompletedTask; + } + + public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken) + { + if (!Directory.Exists(ctx.WorkingDir)) + throw new PlanningLaunchException($"Working directory not found: {ctx.WorkingDir}"); + if (!IsResolvable(_wtPath)) + throw new PlanningLaunchException("Windows Terminal (wt.exe) not found in PATH."); + if (!IsResolvable(_claudePath)) + throw new PlanningLaunchException("Claude CLI (claude) not found in PATH."); + + var psi = new ProcessStartInfo + { + FileName = _wtPath, + UseShellExecute = true, + }; + psi.ArgumentList.Add("-d"); + psi.ArgumentList.Add(ctx.WorkingDir); + psi.ArgumentList.Add("cmd"); + psi.ArgumentList.Add("/k"); + psi.ArgumentList.Add(_claudePath); + psi.ArgumentList.Add("--resume"); + psi.ArgumentList.Add(ctx.ClaudeSessionId); + psi.ArgumentList.Add("--mcp-config"); + psi.ArgumentList.Add(ctx.McpConfigPath); + + Process.Start(psi); + return Task.CompletedTask; + } + + private static bool IsResolvable(string pathOrName) + { + if (Path.IsPathRooted(pathOrName)) + return File.Exists(pathOrName); + + var paths = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? Array.Empty(); + var extensions = OperatingSystem.IsWindows() + ? (Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.CMD;.BAT").Split(';') + : new[] { "" }; + foreach (var p in paths) + { + foreach (var ext in extensions) + { + var candidate = Path.Combine(p, pathOrName + ext); + if (File.Exists(candidate)) return true; + } + } + return false; + } +} +``` + +- [ ] **Step 5: Run; verify pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~WindowsTerminalPlanningLauncherTests"` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs +git commit -m "feat(worker): WindowsTerminalPlanningLauncher with pre-flight checks" +``` + +--- + +## Task 9: MCP token-auth middleware + context + +**Files:** +- Create: `src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs` +- Create: `src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs` + +- [ ] **Step 1: Create context** + +`src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs`: + +```csharp +namespace ClaudeDo.Worker.Planning; + +public sealed class PlanningMcpContext +{ + public required string ParentTaskId { get; init; } +} +``` + +- [ ] **Step 2: Create auth middleware** + +`src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs`: + +```csharp +using ClaudeDo.Data.Repositories; +using Microsoft.AspNetCore.Http; + +namespace ClaudeDo.Worker.Planning; + +public sealed class PlanningTokenAuthMiddleware +{ + private readonly RequestDelegate _next; + + public PlanningTokenAuthMiddleware(RequestDelegate next) => _next = next; + + public async Task InvokeAsync(HttpContext ctx, TaskRepository tasks) + { + if (!ctx.Request.Path.StartsWithSegments("/mcp")) + { + await _next(ctx); + return; + } + + var auth = ctx.Request.Headers["Authorization"].ToString(); + if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsync("Missing bearer token"); + return; + } + + var token = auth.Substring("Bearer ".Length).Trim(); + var parent = await tasks.FindByPlanningTokenAsync(token, ctx.RequestAborted); + if (parent is null || parent.Status != ClaudeDo.Data.Models.TaskStatus.Planning) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsync("Invalid or expired planning token"); + return; + } + + ctx.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id }; + await _next(ctx); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs +git commit -m "feat(worker): MCP bearer-token auth middleware" +``` + +--- + +## Task 10: MCP tools — create/list/update/delete child + +**Files:** +- Create: `src/ClaudeDo.Worker/Planning/PlanningMcpService.cs` +- Create: `tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs` + +- [ ] **Step 1: Write failing tests** + +`tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs`: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Planning; + +public sealed class PlanningMcpServiceTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private readonly PlanningMcpService _sut; + + public PlanningMcpServiceTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + _sut = new PlanningMcpService(_tasks); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + private async Task SeedPlanningParentAsync() + { + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + var parent = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "p", + Status = TaskStatus.Manual, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(parent); + await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); + return (await _tasks.GetByIdAsync(parent.Id))!; + } + + private static PlanningMcpContext Ctx(string parentId) => new() { ParentTaskId = parentId }; + + [Fact] + public async Task CreateChildTask_CreatesDraft() + { + var parent = await SeedPlanningParentAsync(); + + var result = await _sut.CreateChildTask(Ctx(parent.Id), "My child", "desc", null, null, CancellationToken.None); + + Assert.Equal("Draft", result.Status); + var child = await _tasks.GetByIdAsync(result.TaskId); + Assert.Equal("My child", child!.Title); + Assert.Equal(TaskStatus.Draft, child.Status); + } + + [Fact] + public async Task ListChildTasks_ReturnsOnlyThisParentsChildren() + { + var parent = await SeedPlanningParentAsync(); + var other = await SeedPlanningParentAsync(); + + await _tasks.CreateChildAsync(parent.Id, "mine", null, null, null); + await _tasks.CreateChildAsync(other.Id, "theirs", null, null, null); + + var list = await _sut.ListChildTasks(Ctx(parent.Id), CancellationToken.None); + Assert.Single(list); + Assert.Equal("mine", list[0].Title); + } + + [Fact] + public async Task UpdateChildTask_NotAChild_Throws() + { + var parent = await SeedPlanningParentAsync(); + var other = await SeedPlanningParentAsync(); + var otherChild = await _tasks.CreateChildAsync(other.Id, "x", null, null, null); + + await Assert.ThrowsAsync(() => + _sut.UpdateChildTask(Ctx(parent.Id), otherChild.Id, "new", null, null, null, CancellationToken.None)); + } + + [Fact] + public async Task UpdateChildTask_NotDraft_Throws() + { + var parent = await SeedPlanningParentAsync(); + var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false); + + await Assert.ThrowsAsync(() => + _sut.UpdateChildTask(Ctx(parent.Id), c.Id, "new", null, null, null, CancellationToken.None)); + } + + [Fact] + public async Task DeleteChildTask_RemovesDraft() + { + var parent = await SeedPlanningParentAsync(); + var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + + await _sut.DeleteChildTask(Ctx(parent.Id), c.Id, CancellationToken.None); + + Assert.Null(await _tasks.GetByIdAsync(c.Id)); + } +} +``` + +- [ ] **Step 2: Run; verify fail** + +Expected: FAIL. + +- [ ] **Step 3: Implement service** + +`src/ClaudeDo.Worker/Planning/PlanningMcpService.cs`: + +```csharp +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Planning; + +public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status, IReadOnlyList Tags); +public sealed record CreatedChildDto(string TaskId, string Status); + +public sealed class PlanningMcpService +{ + private readonly TaskRepository _tasks; + + public PlanningMcpService(TaskRepository tasks) => _tasks = tasks; + + public async Task CreateChildTask( + PlanningMcpContext ctx, + string title, + string? description, + IReadOnlyList? tags, + string? commitType, + CancellationToken cancellationToken) + { + var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken); + return new CreatedChildDto(child.Id, "Draft"); + } + + public async Task> ListChildTasks( + PlanningMcpContext ctx, + CancellationToken cancellationToken) + { + var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken); + var list = new List(children.Count); + foreach (var c in children) + { + var tagList = await _tasks.GetTagsAsync(c.Id, cancellationToken); + list.Add(new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString(), tagList.Select(t => t.Name).ToList())); + } + return list; + } + + public async Task UpdateChildTask( + PlanningMcpContext ctx, + string taskId, + string? title, + string? description, + IReadOnlyList? tags, + string? commitType, + CancellationToken cancellationToken) + { + var child = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (child.ParentTaskId != ctx.ParentTaskId) + throw new InvalidOperationException("Task is not a child of this planning session."); + if (child.Status != TaskStatus.Draft) + throw new InvalidOperationException("Cannot modify a finalized task."); + + if (title is not null) child.Title = title; + if (description is not null) child.Description = description; + if (commitType is not null) child.CommitType = commitType; + await _tasks.UpdateAsync(child, cancellationToken); + + // Tag handling omitted for v1 simplicity — tags set at create time. + // If Claude asks to update tags, it can delete and re-create. + + var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; + var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken); + return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList()); + } + + public async Task DeleteChildTask( + PlanningMcpContext ctx, + string taskId, + CancellationToken cancellationToken) + { + var child = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (child.ParentTaskId != ctx.ParentTaskId) + throw new InvalidOperationException("Task is not a child of this planning session."); + if (child.Status != TaskStatus.Draft) + throw new InvalidOperationException("Cannot delete a finalized task."); + + await _tasks.DeleteAsync(taskId, cancellationToken); + } +} +``` + +- [ ] **Step 4: Run; verify pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~PlanningMcpServiceTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/PlanningMcpService.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs +git commit -m "feat(worker): MCP tools for child-task CRUD" +``` + +--- + +## Task 11: MCP tools — update_planning_task + finalize + +**Files:** +- Modify: `src/ClaudeDo.Worker/Planning/PlanningMcpService.cs` +- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs` + +- [ ] **Step 1: Write failing tests** + +```csharp +[Fact] +public async Task UpdatePlanningTask_SetsTitleAndDescription() +{ + var parent = await SeedPlanningParentAsync(); + + await _sut.UpdatePlanningTask(Ctx(parent.Id), "new title", "new desc", CancellationToken.None); + + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal("new title", loaded!.Title); + Assert.Equal("new desc", loaded.Description); +} + +[Fact] +public async Task Finalize_PromotesDraftsAndInvalidatesToken() +{ + var parent = await SeedPlanningParentAsync(); + await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); + await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); + + var count = await _sut.Finalize(Ctx(parent.Id), true, CancellationToken.None); + + Assert.Equal(2, count); + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Planned, loaded!.Status); + Assert.Null(loaded.PlanningSessionToken); +} +``` + +- [ ] **Step 2: Run; verify fail** + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +In `PlanningMcpService`: + +```csharp +public async Task UpdatePlanningTask( + PlanningMcpContext ctx, + string? title, + string? description, + CancellationToken cancellationToken) +{ + var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken) + ?? throw new InvalidOperationException("Planning task not found."); + if (title is not null) parent.Title = title; + if (description is not null) parent.Description = description; + await _tasks.UpdateAsync(parent, cancellationToken); +} + +public Task Finalize( + PlanningMcpContext ctx, + bool queueAgentTasks, + CancellationToken cancellationToken) + => _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken); +``` + +- [ ] **Step 4: Run; verify pass** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/PlanningMcpService.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs +git commit -m "feat(worker): MCP tools update_planning_task and finalize" +``` + +--- + +## Task 12: MCP HTTP endpoint + broadcast on mutations + +**Files:** +- Modify: `src/ClaudeDo.Worker/Program.cs` (or whichever file configures the ASP.NET host) +- Modify: `src/ClaudeDo.Worker/Planning/PlanningMcpService.cs` + +- [ ] **Step 1: Add SignalR-broadcast dependency to service** + +In `PlanningMcpService`, add constructor dependency on the hub context (existing pattern in the worker — examine how `QueueService` or `TaskRunner` emit `TaskUpdated` via `IHubContext` and mimic): + +```csharp +using Microsoft.AspNetCore.SignalR; +// Replace WorkerHub with the actual hub class name used in this codebase. + +public PlanningMcpService(TaskRepository tasks, IHubContext hub) +{ + _tasks = tasks; + _hub = hub; +} + +private readonly IHubContext _hub; + +private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct) + => _hub.Clients.All.SendAsync("TaskUpdated", taskId, ct); +``` + +Call `BroadcastTaskUpdatedAsync` at the end of each mutation method (`CreateChildTask`, `UpdateChildTask`, `DeleteChildTask`, `UpdatePlanningTask`, `Finalize`). For Finalize, broadcast once for the parent — the UI will refetch and get all children's new statuses in one reload (existing pattern). + +**If the existing hub name is not `WorkerHub`, replace accordingly.** Inspect `src/ClaudeDo.Worker/Hub/` to find it. + +- [ ] **Step 2: Wire MCP in `Program.cs`** + +Add: +```csharp +using ClaudeDo.Worker.Planning; +using ModelContextProtocol.Server; // adjust to actual namespace from Task 1 + +// After builder.Services... +builder.Services.AddSingleton(sp => + new PlanningSessionManager( + sp.GetRequiredService(), + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".todo-app", "planning-sessions"))); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +// MCP server mapping — exact call depends on the ModelContextProtocol.AspNetCore API +// discovered in Task 1. Typical shape: +builder.Services + .AddMcpServer() + .WithTools(); // attribute-based tool registration + +// After app = builder.Build(): +app.UseMiddleware(); +app.MapMcp("/mcp"); // exact endpoint extension depends on SDK +``` + +Annotate `PlanningMcpService` methods with MCP tool attributes. The SDK typically uses `[McpServerTool]` or similar: + +```csharp +[McpServerTool, Description("Create a draft child task under this planning session.")] +public async Task CreateChildTask(...) +``` + +**Consult Context7 docs again here** (Task 2 research) for the exact attribute names, endpoint-mapping extension method, and how to inject `PlanningMcpContext` per request (likely via `HttpContext.Items["PlanningContext"]` in a factory). If the ambient-context injection pattern isn't obvious, use a lightweight accessor: + +```csharp +public sealed class PlanningMcpContextAccessor +{ + private readonly IHttpContextAccessor _http; + public PlanningMcpContextAccessor(IHttpContextAccessor http) => _http = http; + public PlanningMcpContext Current => + (_http.HttpContext?.Items["PlanningContext"] as PlanningMcpContext) + ?? throw new InvalidOperationException("No planning context on request."); +} +``` +and inject `PlanningMcpContextAccessor` into `PlanningMcpService` constructor, replacing the explicit `PlanningMcpContext ctx` parameter on each tool method with `_contextAccessor.Current`. + +**Important:** Keep the existing test signatures (`Ctx(parentId)` passed explicitly) if you refactor tools to pull from an accessor — update the tests accordingly to set up an `HttpContextAccessor` with `Items["PlanningContext"]` populated. + +- [ ] **Step 3: Build** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Worker/Program.cs src/ClaudeDo.Worker/Planning/PlanningMcpService.cs +git commit -m "feat(worker): map MCP HTTP endpoint and broadcast TaskUpdated" +``` + +--- + +## Task 13: SignalR hub endpoints + +**Files:** +- Modify: the worker hub file (find via `grep -r "class.*Hub.*:.*Hub" src/ClaudeDo.Worker`) +- Create: `tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs` + +- [ ] **Step 1: Locate the hub** + +Run: `grep -rn "class.*WorkerHub\|: Hub\b" src/ClaudeDo.Worker/Hub/` + +The existing hub class (commonly `WorkerHub`) handles methods like `RunNowAsync`, `CancelTaskAsync`. Extend it — do not create a new hub. + +- [ ] **Step 2: Add hub methods** + +In the existing hub class, add: + +```csharp +private readonly PlanningSessionManager _planning; +private readonly IPlanningTerminalLauncher _launcher; + +// ... augment the existing constructor to inject these two. + +public async Task StartPlanningSessionAsync(string taskId) +{ + var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted); + try + { + await _launcher.LaunchStartAsync(ctx, Context.ConnectionAborted); + } + catch (PlanningLaunchException) + { + await _planning.DiscardAsync(taskId, Context.ConnectionAborted); + throw; + } + await Clients.All.SendAsync("TaskUpdated", taskId); + return ctx; +} + +public async Task ResumePlanningSessionAsync(string taskId) +{ + var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted); + await _launcher.LaunchResumeAsync(ctx, Context.ConnectionAborted); + return ctx; +} + +public async Task DiscardPlanningSessionAsync(string taskId) +{ + await _planning.DiscardAsync(taskId, Context.ConnectionAborted); + await Clients.All.SendAsync("TaskUpdated", taskId); +} + +public async Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true) +{ + var count = await _planning.FinalizeAsync(taskId, queueAgentTasks, Context.ConnectionAborted); + await Clients.All.SendAsync("TaskUpdated", taskId); + return count; +} + +public Task GetPendingDraftCountAsync(string taskId) + => _planning.GetPendingDraftCountAsync(taskId, Context.ConnectionAborted); +``` + +- [ ] **Step 3: Hub tests** + +`tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs` — exercise the hub methods via the `TestServer`/in-process SignalR pattern already used by `AgentSettingsHubTests.cs`. Mirror its setup (look at that file for the pattern) and write tests for each of the five methods. Use a fake `IPlanningTerminalLauncher` that records calls but does not spawn processes. + +Example skeleton (adapt to the real hub bootstrapping pattern observed in `AgentSettingsHubTests`): + +```csharp +// Setup similar to AgentSettingsHubTests: +// - TestWebHost with real DB and real TaskRepository +// - Register a FakeTerminalLauncher that captures calls +// - Connect a HubConnection client +// Tests: +// - StartPlanningSession changes parent status to Planning and invokes launcher once +// - Discard resets parent to Manual and removes session dir +// - Finalize promotes drafts and broadcasts TaskUpdated +// - GetPendingDraftCount returns the correct count +``` + +- [ ] **Step 4: Build + test** + +Run: +```bash +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj +dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~PlanningHubTests" +``` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Hub/ tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs +git commit -m "feat(worker): SignalR hub endpoints for planning sessions" +``` + +--- + +## Task 14: End-to-end smoke test + +**Files:** +- Create: `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs` + +- [ ] **Step 1: Write an end-to-end test using a fake launcher** + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Planning; + +public sealed class PlanningEndToEndTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private readonly PlanningSessionManager _manager; + private readonly PlanningMcpService _svc; + + public PlanningEndToEndTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + var root = Path.Combine(Path.GetTempPath(), $"cd_e2e_{Guid.NewGuid():N}"); + _manager = new PlanningSessionManager(_tasks, root); + _svc = new PlanningMcpService(_tasks /*, fake hub context */); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + [Fact] + public async Task StartThenCreateThenFinalize_FullFlow() + { + var listId = Guid.NewGuid().ToString(); + var wd = Path.GetTempPath(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow }); + + var parent = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "Big Task", + Status = TaskStatus.Manual, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(parent); + + var startCtx = await _manager.StartAsync(parent.Id, CancellationToken.None); + Assert.True(File.Exists(startCtx.Files.McpConfigPath)); + + var pCtx = new PlanningMcpContext { ParentTaskId = parent.Id }; + await _svc.CreateChildTask(pCtx, "sub 1", null, null, null, CancellationToken.None); + await _svc.CreateChildTask(pCtx, "sub 2", null, null, null, CancellationToken.None); + + var count = await _svc.Finalize(pCtx, true, CancellationToken.None); + Assert.Equal(2, count); + + var reload = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Planned, reload!.Status); + var kids = await _tasks.GetChildrenAsync(parent.Id); + Assert.All(kids, k => Assert.Equal(TaskStatus.Manual, k.Status)); + } +} +``` + +If `PlanningMcpService` now requires an `IHubContext<...>` constructor arg, supply a fake mirroring `FakeHubContext` that already exists in the test project. + +- [ ] **Step 2: Run** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~PlanningEndToEndTests"` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs +git commit -m "test(worker): planning session end-to-end" +``` + +--- + +## Task 15: Verify full build + test and clean up + +**Files:** none + +- [ ] **Step 1: Full test run** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests` +Expected: all green. + +- [ ] **Step 2: Build all** + +Run: +```bash +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj +dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj +``` +Expected: all succeed. + +- [ ] **Step 3: Manual smoke test (one-liner, only works if wt + claude installed)** + +Launch the worker locally, then from a second terminal connect with a SignalR client (or use the UI from Plan C if available) and call `StartPlanningSessionAsync("")`. A Windows Terminal window should open with Claude running. + +If the UI is not yet built (Plan C in progress), this is manual-only — skip to final commit. + +- [ ] **Step 4: Final commit and PR-ready** + +Ensure the reflection shortcut in `PlanningSessionManager.LookupWorkingDirAsync` has been replaced with proper `ListRepository` injection (flagged in Task 3 Step 4). If not: + +```csharp +// Replace the constructor and LookupWorkingDirAsync with: +public PlanningSessionManager(TaskRepository tasks, ListRepository lists, string rootDirectory) +{ + _tasks = tasks; + _lists = lists; + _rootDirectory = rootDirectory; +} +// ... +private async Task LookupWorkingDirAsync(string listId, CancellationToken ct) +{ + var list = await _lists.GetByIdAsync(listId, ct) + ?? throw new InvalidOperationException($"List {listId} not found."); + return list.WorkingDir ?? throw new InvalidOperationException($"List {listId} has no WorkingDir."); +} +``` +Update DI in `Program.cs` and tests to match. Commit: + +```bash +git add -A +git commit -m "refactor(worker): inject ListRepository into PlanningSessionManager" +``` + +--- + +## Out of scope for Plan B + +- All UI work (context menu, hierarchy rendering, draft styling, unfinished-session dialog) → Plan C. +- WorkerClient extensions (Plan C will add the client-side methods that call these hub endpoints). +- Avalonia-side live-refresh on `TaskUpdated` for drafts — the event is broadcast; Plan C wires the handler. diff --git a/docs/superpowers/plans/2026-04-23-planning-sessions-plan-c-ui.md b/docs/superpowers/plans/2026-04-23-planning-sessions-plan-c-ui.md new file mode 100644 index 0000000..b36620c --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-planning-sessions-plan-c-ui.md @@ -0,0 +1,927 @@ +# Planning Sessions — Plan C: UI 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:** Wire the planning-session feature into the UI: context-menu entries, hierarchical display of parent + children, draft styling, unfinished-session dialog, and the `WorkerClient` methods that call the hub endpoints built in Plan B. + +**Architecture:** Extend `TaskRowViewModel` with hierarchy-aware flags (`IsChild`, `IsPlanningParent`, `IsExpanded`). `TasksIslandViewModel` builds a flat stream that interleaves parents and their children based on expanded state. Context-menu entries on `TaskRowView` gate on task status. Draft styling lives in the existing island styles. A modal dialog reuses the project's `TaskCompletionSource` pattern. + +**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`), compiled bindings, SignalR client. + +**Spec reference:** `docs/superpowers/specs/2026-04-23-planning-sessions-design.md` section 6. + +--- + +## Prerequisite Gate + +This plan depends on Plan A being merged to `main`. Plan B's interface contract (hub method names, return types) is locked in the spec §6.6 and Plan B task 13 — this plan can proceed in parallel with Plan B. + +Before starting: + +```bash +git fetch origin main +git checkout main +git pull --ff-only +ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs +``` + +If the file is missing, wait for Plan A: +```bash +while ! ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs >/dev/null 2>&1; do + echo "Waiting for Plan A to merge..." + sleep 60 + git fetch origin main && git pull --ff-only +done +``` + +Then branch: +```bash +git checkout -b feat/planning-sessions-ui +``` + +**Parallel-with-Plan-B note:** Plan B may not yet be merged when this plan runs. The `WorkerClient` methods in Task 9 will compile against Plan B's SignalR hub method names (they're string-based SignalR invocations), so they don't have a build-time dependency. Runtime end-to-end testing requires Plan B merged; until then, mock-test what's possible and smoke-test manually once both plans land. + +--- + +## File Structure + +**Modified:** +- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — add `ParentTaskId`, `IsChild`, `IsPlanningParent`, `IsExpanded`, `PlanningBadge` properties. +- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` — add planning commands, expanded-state map, flat-stream rebuild logic. +- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — chevron, indentation, badges, draft styling hooks. +- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs` — context-menu event handlers (if code-behind is used; else inline). +- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` — use the extended TaskRowView template. +- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — five new hub method wrappers matching Plan B. +- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` — `.draft`, `.planning-parent`, `.planned-parent`, badge styles. + +**Created:** +- `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` + `.axaml.cs` — modal Resume/Finalize/Discard dialog. +- `src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs` — dialog VM. +- `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` — VM-level tests. +- `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs` — VM-level tests. + +--- + +## Task 1: Extend `TaskRowViewModel` + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` +- Create: `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs` + +- [ ] **Step 1: Write failing test for planning flags** + +Create the test file. Adapt the existing `TaskRowViewModelTests` pattern (look at `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs` for how VMs are constructed in tests): + +```csharp +using ClaudeDo.Ui.ViewModels.Islands; + +namespace ClaudeDo.Worker.Tests.UiVm; + +public sealed class TaskRowViewModelPlanningTests +{ + [Fact] + public void Draft_Status_SetsIsChildFlag_WhenParentIdIsNotNull() + { + // Adapt the constructor call to your actual TaskRowViewModel signature (see TaskRowViewModelTests). + var vm = TestHelpers.MakeRow( + status: "draft", + parentTaskId: "parent-id"); + + Assert.True(vm.IsChild); + Assert.False(vm.IsPlanningParent); + } + + [Fact] + public void Planning_Status_SetsIsPlanningParent() + { + var vm = TestHelpers.MakeRow(status: "planning", parentTaskId: null); + + Assert.True(vm.IsPlanningParent); + Assert.False(vm.IsChild); + Assert.Equal("PLANNING", vm.PlanningBadge); + } + + [Fact] + public void Planned_Status_ShowsPlannedBadge() + { + var vm = TestHelpers.MakeRow(status: "planned", parentTaskId: null); + + Assert.True(vm.IsPlanningParent); + Assert.Equal("PLANNED", vm.PlanningBadge); + } + + [Fact] + public void NonPlanningStatus_NoBadge() + { + var vm = TestHelpers.MakeRow(status: "manual", parentTaskId: null); + + Assert.False(vm.IsPlanningParent); + Assert.Null(vm.PlanningBadge); + } +} + +internal static class TestHelpers +{ + public static TaskRowViewModel MakeRow(string status, string? parentTaskId) + { + // Implement based on actual TaskRowViewModel constructor. + // The TaskRowViewModelTests.cs file in the same folder shows the existing pattern. + throw new NotImplementedException("Adapt to your TaskRowViewModel constructor"); + } +} +``` + +Open `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs` first to see how the VM is constructed in tests, then fill in `TestHelpers.MakeRow` accordingly. + +- [ ] **Step 2: Run; verify fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRowViewModelPlanningTests"` +Expected: FAIL (properties not yet on VM). + +- [ ] **Step 3: Extend the VM** + +In `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` add the new properties using `[ObservableProperty]`: + +```csharp +[ObservableProperty] private string? parentTaskId; +[ObservableProperty] private bool isExpanded = true; + +public bool IsChild => !string.IsNullOrEmpty(ParentTaskId); +public bool IsPlanningParent => string.Equals(Status, "planning", StringComparison.OrdinalIgnoreCase) + || string.Equals(Status, "planned", StringComparison.OrdinalIgnoreCase); + +public string? PlanningBadge => Status switch +{ + string s when string.Equals(s, "planning", StringComparison.OrdinalIgnoreCase) => "PLANNING", + string s when string.Equals(s, "planned", StringComparison.OrdinalIgnoreCase) => "PLANNED", + _ => null, +}; + +public bool IsDraft => string.Equals(Status, "draft", StringComparison.OrdinalIgnoreCase); +``` + +Since `IsChild`, `IsPlanningParent`, `PlanningBadge`, and `IsDraft` are computed from other observables, you must raise property-changed notifications when `Status` or `ParentTaskId` changes. Use `[ObservableProperty]` partial methods: + +```csharp +partial void OnStatusChanged(string value) +{ + OnPropertyChanged(nameof(IsPlanningParent)); + OnPropertyChanged(nameof(PlanningBadge)); + OnPropertyChanged(nameof(IsDraft)); +} + +partial void OnParentTaskIdChanged(string? value) +{ + OnPropertyChanged(nameof(IsChild)); +} +``` + +If the existing VM already has `OnStatusChanged` (check for generator outputs), merge into it rather than duplicating. + +- [ ] **Step 4: Run; verify pass** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs +git commit -m "feat(ui): TaskRowViewModel gains planning hierarchy flags" +``` + +--- + +## Task 2: `WorkerClient` planning methods + +**Files:** +- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs` +- Create: DTOs matching Plan B return types (either inline in the client file or new file `src/ClaudeDo.Ui/Services/PlanningDtos.cs`). + +- [ ] **Step 1: Add DTOs** + +Create `src/ClaudeDo.Ui/Services/PlanningDtos.cs`: + +```csharp +namespace ClaudeDo.Ui.Services; + +public sealed record PlanningSessionFilesDto( + string SessionDirectory, + string McpConfigPath, + string SystemPromptPath, + string InitialPromptPath); + +public sealed record PlanningSessionStartInfo( + string ParentTaskId, + string WorkingDir, + PlanningSessionFilesDto Files); + +public sealed record PlanningSessionResumeInfo( + string ParentTaskId, + string WorkingDir, + string ClaudeSessionId, + string McpConfigPath); +``` + +These field names must match Plan B's `PlanningSessionStartContext` / `PlanningSessionResumeContext` exactly (case-sensitive JSON deserialization through SignalR). + +- [ ] **Step 2: Add `WorkerClient` methods** + +In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add: + +```csharp +public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) + => _connection.InvokeAsync("StartPlanningSessionAsync", taskId, ct); + +public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) + => _connection.InvokeAsync("ResumePlanningSessionAsync", taskId, ct); + +public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) + => _connection.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct); + +public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) + => _connection.InvokeAsync("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct); + +public Task GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) + => _connection.InvokeAsync("GetPendingDraftCountAsync", taskId, ct); +``` + +Replace `_connection` with whatever name the existing `WorkerClient` uses for its `HubConnection` field. + +- [ ] **Step 3: Build** + +Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` +Expected: builds. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Ui/Services/PlanningDtos.cs src/ClaudeDo.Ui/Services/WorkerClient.cs +git commit -m "feat(ui): WorkerClient planning-session methods" +``` + +--- + +## Task 3: `TasksIslandViewModel` — planning commands + expanded state + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` +- Create: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` + +- [ ] **Step 1: Add commands to the VM** + +In `TasksIslandViewModel.cs`, add: + +```csharp +private readonly Dictionary _expandedState = new(); + +[RelayCommand] +private async Task OpenPlanningSessionAsync(TaskRowViewModel? row) +{ + if (row is null || !string.Equals(row.Status, "manual", StringComparison.OrdinalIgnoreCase)) + return; + try + { + await _workerClient.StartPlanningSessionAsync(row.Id); + } + catch (Exception ex) + { + await _dialogs.ShowErrorAsync("Could not start planning session", ex.Message); + } +} + +[RelayCommand] +private async Task ResumePlanningSessionAsync(TaskRowViewModel? row) +{ + if (row is null || !row.IsPlanningParent) return; + try + { + await _workerClient.ResumePlanningSessionAsync(row.Id); + } + catch (Exception ex) + { + await _dialogs.ShowErrorAsync("Could not resume planning session", ex.Message); + } +} + +[RelayCommand] +private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row) +{ + if (row is null) return; + var confirm = await _dialogs.ConfirmAsync( + "Discard planning session?", + "This will delete all draft tasks and reset the parent to Manual."); + if (!confirm) return; + await _workerClient.DiscardPlanningSessionAsync(row.Id); +} + +[RelayCommand] +private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row) +{ + if (row is null) return; + await _workerClient.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true); +} + +[RelayCommand] +private void ToggleExpand(TaskRowViewModel? row) +{ + if (row is null) return; + var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : true); + _expandedState[row.Id] = next; + row.IsExpanded = next; + RebuildFlatStreams(); +} + +private void RebuildFlatStreams() +{ + // Existing code builds OpenItems/CompletedItems from the task list. + // Modify it so that: after emitting a parent, if IsPlanningParent && IsExpanded, + // its Draft / Manual / Queued / Running / Done children are emitted next. + // Children already know they are children (ParentTaskId != null) and are styled as such. +} +``` + +The existing `RebuildFlatStreams` (or equivalent) probably just groups tasks by status. You need to intersperse the hierarchy: + +```csharp +// Pseudocode — fit to the existing code shape. +var topLevel = allRows.Where(r => !r.IsChild).OrderBy(r => r.SortOrder); +var flat = new List(); +foreach (var parent in topLevel) +{ + flat.Add(parent); + if (parent.IsPlanningParent && parent.IsExpanded) + { + var children = allRows + .Where(r => r.ParentTaskId == parent.Id) + .OrderBy(r => r.SortOrder) + .ToList(); + flat.AddRange(children); + } +} +// Then bucket `flat` into OpenItems/CompletedItems like today, preserving order. +``` + +Pass dependencies: the VM already has a `WorkerClient` or equivalent — reuse it. Add a dialog service if not already injected: + +```csharp +public interface IDialogService +{ + Task ConfirmAsync(string title, string message); + Task ShowErrorAsync(string title, string message); +} +``` + +If an analog already exists (check existing editor dialogs), use it. + +- [ ] **Step 2: Write failing VM tests** + +`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`: + +```csharp +using ClaudeDo.Ui.ViewModels.Islands; + +namespace ClaudeDo.Worker.Tests.UiVm; + +public sealed class TasksIslandViewModelPlanningTests +{ + [Fact] + public void ToggleExpand_CollapsesChildrenOfPlanningParent() + { + // Arrange: create VM with one Planning parent and two Draft children. + // Act: call ToggleExpandCommand with the parent. + // Assert: flat stream no longer contains the children. + // Adapt to how the existing TasksIslandViewModel is instantiated. + } + + [Fact] + public void OpenPlanningSessionCommand_ManualTaskOnly_CanExecuteTrue() + { + // Arrange VM with a Manual row. + // Assert CanExecute for OpenPlanningSession command is true for Manual rows, + // false for Queued/Running/Done/Failed rows. + } +} +``` + +These are skeleton tests — implement with the same construction pattern used by the existing `TasksIslandViewModelTests` if one exists, or build a minimal VM fake with a stub `WorkerClient`. + +- [ ] **Step 3: Build + test** + +Run: +```bash +dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj +dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TasksIslandViewModelPlanningTests" +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +git commit -m "feat(ui): planning commands and expand/collapse in TasksIslandViewModel" +``` + +--- + +## Task 4: `TaskRowView` — indent, chevron, badges, draft styling + +**Files:** +- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` + +- [ ] **Step 1: Wrap the row content with a Grid that has an indent column** + +Open `TaskRowView.axaml`. The existing root is likely a `Grid` or `Border`. Replace/refactor the top-level layout to: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +This is a structural edit — preserve all existing bindings for status color, completion toggle, star, scheduled-for, etc. The new indentation column and badges are additive. + +- [ ] **Step 2: Add the converters** + +If `ChevronDataConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter` do not exist, add them to `src/ClaudeDo.Ui/Converters/` (or inline as compiled converters). Example inline: + +```xml + + + + +``` + +If converters must be code-based, a minimal `BoolToItalicConverter`: + +```csharp +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace ClaudeDo.Ui.Converters; + +public sealed class BoolToItalicConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) + => value is true ? FontStyle.Italic : FontStyle.Normal; + + public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) + => throw new NotSupportedException(); +} +``` + +Register in `App.axaml` resources. + +- [ ] **Step 3: Build** + +Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` +Expected: builds cleanly (XAML compiles). + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Converters/ +git commit -m "feat(ui): TaskRowView hierarchy indentation, chevron, badges, draft italic" +``` + +--- + +## Task 5: `TaskRowView` — planning context-menu entries + +**Files:** +- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` + +- [ ] **Step 1: Locate the existing context menu** + +Open `TaskRowView.axaml`. The ContextMenu lives somewhere on the root element or as a `ContextMenu.Items`/`ContextFlyout`. Find the block that defines entries like "Edit", "Run now", etc. + +- [ ] **Step 2: Insert planning entries conditionally** + +Add within the existing menu (order: after "Run now" and a separator): + +```xml + + + + + +``` + +Simpler alternative without multi-condition converters: expose direct bool VM properties that combine the logic — `CanOpenPlanningSession`, `CanResumePlanningSession`, `CanDiscardPlanningSession`: + +```csharp +// In TaskRowViewModel +public bool CanOpenPlanningSession => + string.Equals(Status, "manual", StringComparison.OrdinalIgnoreCase) && !IsChild; + +public bool CanResumeOrDiscardPlanning => + string.Equals(Status, "planning", StringComparison.OrdinalIgnoreCase); +``` + +Add `OnPropertyChanged(nameof(CanOpenPlanningSession))` and friends in the status/parent-id partial methods from Task 1. Then the XAML simplifies to: + +```xml + +``` + +Use this simpler path — cleaner. + +- [ ] **Step 3: Build** + +Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` +Expected: builds. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +git commit -m "feat(ui): planning entries in task context menu" +``` + +--- + +## Task 6: Island styles — draft, badges + +**Files:** +- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml` + +- [ ] **Step 1: Add brushes + styles** + +Append within `` or wherever brushes are defined: + +```xml + + + +``` + +Add styles: + +```xml + + + + + + + + + +``` + +- [ ] **Step 2: Build and manually verify** + +Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` +Launch app, create a task, right-click, use Open planning Session (if Plan B merged) or simulate via DB. Verify badge + italic draft rendering visually. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Ui/Design/IslandStyles.axaml +git commit -m "feat(ui): draft and planning badge styles" +``` + +--- + +## Task 7: Unfinished-planning-session dialog + +**Files:** +- Create: `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` + `.axaml.cs` +- Create: `src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs` + +- [ ] **Step 1: Create the VM** + +`src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs`: + +```csharp +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClaudeDo.Ui.ViewModels.Dialogs; + +public enum UnfinishedPlanningDialogResult +{ + Cancel, + Resume, + FinalizeNow, + Discard, +} + +public sealed partial class UnfinishedPlanningDialogViewModel : ObservableObject +{ + [ObservableProperty] private string title = "Unfinished planning session"; + [ObservableProperty] private string taskTitle = ""; + [ObservableProperty] private int draftCount; + + public TaskCompletionSource Result { get; } = new(); + + [RelayCommand] private void Resume() => Result.TrySetResult(UnfinishedPlanningDialogResult.Resume); + [RelayCommand] private void FinalizeNow() => Result.TrySetResult(UnfinishedPlanningDialogResult.FinalizeNow); + [RelayCommand] private void Discard() => Result.TrySetResult(UnfinishedPlanningDialogResult.Discard); + [RelayCommand] private void Cancel() => Result.TrySetResult(UnfinishedPlanningDialogResult.Cancel); +} +``` + +- [ ] **Step 2: Create the view** + +`src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml`: + +```xml + + + + + + + + + + +