# Planning Session Worktree 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:** Make `mcp__claudedo__*` tools available inside planning sessions by running each session in an ephemeral git worktree that holds a project-scope `.mcp.json` and a settings override that auto-trusts project MCP servers.

**Architecture:** `PlanningSessionManager` creates a short-lived git worktree from `HEAD` of the list's working directory on `StartAsync`, writes `.mcp.json` (with env-var expansion for the bearer token) and `.claude/settings.local.json` into it, and returns the worktree path as the spawn directory. `WindowsTerminalPlanningLauncher` passes the token via env var (`CLAUDEDO_PLANNING_TOKEN`) and stops passing `--mcp-config`. Finalize/Discard force-remove the worktree and branch.

**Tech Stack:** .NET 8, xUnit, real SQLite (DbFixture), real git worktrees via `ClaudeDo.Data.Git.GitService`.

**Spec:** `docs/superpowers/specs/2026-04-24-planning-worktree-design.md`

---

## File Structure

**Modify:**
- `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs` — add `Token`, `WorktreePath`, `BranchName` to start context; add `Token` and rename `McpConfigPath` → `WorktreePath` on resume context
- `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs` — drop `McpConfigPath` field
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — worktree create/cleanup, token persistence, new ctor deps
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs` — env var, drop `--mcp-config`
- `src/ClaudeDo.Worker/Program.cs` — DI wiring for new ctor signature
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` — add git init, update existing assertions
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs` — add git init in setup
- `tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs` — assert env var, no `--mcp-config`

Each file has one clear responsibility; no new files needed.

---

## Task 1: Extend context records with token and worktree info

**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs`

- [ ] **Step 1: Edit the records**

Replace the full file content with:

```csharp
namespace ClaudeDo.Worker.Planning;

public sealed record PlanningSessionStartContext(
    string ParentTaskId,
    string WorkingDir,
    string Token,
    string WorktreePath,
    string BranchName,
    PlanningSessionFiles Files);

public sealed record PlanningSessionResumeContext(
    string ParentTaskId,
    string WorkingDir,
    string ClaudeSessionId,
    string Token,
    string WorktreePath);
```

Note: `WorkingDir` on both records now points at the worktree (callers that used it as "spawn dir" remain correct; callers that needed "list working dir" must be updated separately — no such callers exist today).

- [ ] **Step 2: Build to see breakage**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: FAIL — `PlanningSessionManager` and `WindowsTerminalPlanningLauncher` no longer match these signatures.

- [ ] **Step 3: Commit stub**

```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs
git commit -m "refactor(worker): extend planning contexts with token and worktree"
```

---

## Task 2: Drop `McpConfigPath` from `PlanningSessionFiles`

**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs`

- [ ] **Step 1: Edit the record**

Replace the full file content with:

```csharp
namespace ClaudeDo.Worker.Planning;

public sealed record PlanningSessionFiles(
    string SessionDirectory,
    string SystemPromptPath,
    string InitialPromptPath);
```

- [ ] **Step 2: Commit**

```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs
git commit -m "refactor(worker): drop McpConfigPath from PlanningSessionFiles"
```

---

## Task 3: Extend `PlanningSessionManager` constructors

**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (fields + constructors only)

- [ ] **Step 1: Add using directives**

At the top of `PlanningSessionManager.cs`, add these imports alongside the existing ones:

```csharp
using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Config;
```

- [ ] **Step 2: Replace fields and constructors**

Replace the block from `private const string McpServerUrl` down to the end of `CreateRepos()` with:

```csharp
    private const string McpServerUrl = "http://127.0.0.1:47821/mcp";

    private readonly IDbContextFactory<ClaudeDoDbContext>? _factory;
    private readonly TaskRepository? _tasksOverride;
    private readonly ListRepository? _listsOverride;
    private readonly AppSettingsRepository? _settingsOverride;
    private readonly GitService _git;
    private readonly WorkerConfig _cfg;
    private readonly string _rootDirectory;

    // DI constructor.
    public PlanningSessionManager(
        IDbContextFactory<ClaudeDoDbContext> factory,
        GitService git,
        WorkerConfig cfg,
        string rootDirectory)
    {
        _factory = factory;
        _git = git;
        _cfg = cfg;
        _rootDirectory = rootDirectory;
    }

    // Test constructor.
    public PlanningSessionManager(
        TaskRepository tasks,
        ListRepository lists,
        AppSettingsRepository settings,
        GitService git,
        WorkerConfig cfg,
        string rootDirectory)
    {
        _tasksOverride = tasks;
        _listsOverride = lists;
        _settingsOverride = settings;
        _git = git;
        _cfg = cfg;
        _rootDirectory = rootDirectory;
    }

    private (TaskRepository tasks, ListRepository lists, AppSettingsRepository settings, ClaudeDoDbContext? ctx) CreateRepos()
    {
        if (_tasksOverride is not null)
            return (_tasksOverride, _listsOverride!, _settingsOverride!, null);
        var ctx = _factory!.CreateDbContext();
        return (new TaskRepository(ctx), new ListRepository(ctx), new AppSettingsRepository(ctx), ctx);
    }
```

- [ ] **Step 3: Update all `CreateRepos()` call-sites in this file**

Every call currently binds `(tasks, lists, ctx)`. Change each to `(tasks, lists, settings, ctx)` (search the file for `= CreateRepos();`).

The `_` and `__` discard patterns on the returned `ctx` (lines like `await using var _ = ctx;`) remain valid.

- [ ] **Step 4: Build — expect test breakage**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS (production code compiles).

Run: `dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
Expected: FAIL — test ctor calls don't match. Will be fixed in Task 10.

- [ ] **Step 5: Commit**

```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "refactor(worker): inject GitService and WorkerConfig into PlanningSessionManager"
```

---

## Task 4: Add a worktree-path helper and the token-file helpers

**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (add private helpers)

- [ ] **Step 1: Add three private helpers at the bottom of the class (before the closing `}`)**

```csharp
    private static string BranchNameFor(string taskId) =>
        $"claudedo/planning/{taskId.Replace("-", "")}";

    private string WorktreePathFor(string taskId, string strategy, string? centralRootOverride, string listWorkingDir)
    {
        var centralRoot = !string.IsNullOrWhiteSpace(centralRootOverride)
            ? centralRootOverride!
            : _cfg.CentralWorktreeRoot;

        var raw = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
            ? Path.Combine(centralRoot, "planning", taskId)
            : Path.Combine(Path.GetDirectoryName(listWorkingDir)!, ".claudedo-worktrees", "planning", taskId);

        return Path.GetFullPath(raw);
    }

    private static string TokenFilePathFor(string sessionDir) =>
        Path.Combine(sessionDir, "token");

    private static async Task WriteTokenFileAsync(string path, string token, CancellationToken ct)
    {
        await File.WriteAllTextAsync(path, token, ct);
        // Best-effort current-user-only ACL on Windows. On non-Windows the inherited
        // perms from the parent dir apply; acceptable because sessionDir is already
        // under the user's home (~/.todo-app/sessions/).
        if (OperatingSystem.IsWindows())
        {
            try
            {
                var fi = new FileInfo(path);
                var ac = fi.GetAccessControl();
                ac.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
                var me = System.Security.Principal.WindowsIdentity.GetCurrent().User!;
                ac.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
                    me,
                    System.Security.AccessControl.FileSystemRights.FullControl,
                    System.Security.AccessControl.AccessControlType.Allow));
                fi.SetAccessControl(ac);
            }
            catch { /* ACL hardening is best-effort */ }
        }
    }

    private static async Task<string> ReadTokenFileAsync(string path, CancellationToken ct)
    {
        if (!File.Exists(path))
            throw new InvalidOperationException($"Token file missing: {path}");
        return (await File.ReadAllTextAsync(path, ct)).Trim();
    }
```

- [ ] **Step 2: Build**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "refactor(worker): add worktree path and token file helpers"
```

---

## Task 5: Rewrite `BuildMcpConfigJson` to use env-var expansion

**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`

- [ ] **Step 1: Replace `BuildMcpConfigJson` body**

Find the existing `private static string BuildMcpConfigJson(string token)` method. Replace with:

```csharp
    private static string BuildMcpConfigJson()
    {
        var payload = new
        {
            mcpServers = new
            {
                claudedo = new
                {
                    type = "http",
                    url = McpServerUrl,
                    headers = new Dictionary<string, string>
                    {
                        ["Authorization"] = "Bearer ${CLAUDEDO_PLANNING_TOKEN}"
                    }
                }
            }
        };
        return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
    }
```

(The token argument is dropped — claude expands `${CLAUDEDO_PLANNING_TOKEN}` at load time from the spawned process environment.)

- [ ] **Step 2: Also add settings override builder below it**

```csharp
    private const string SettingsLocalJson = """
        {
          "enableAllProjectMcpServers": true
        }
        """;
```

- [ ] **Step 3: Commit**

```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "refactor(worker): switch MCP config to env-var token expansion"
```

---

## Task 6: Rewrite `StartAsync` to create the worktree

**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (body of `StartAsync` only)

- [ ] **Step 1: Replace `StartAsync` body (keep signature)**

Replace the entire method body with:

```csharp
    public async Task<PlanningSessionStartContext> StartAsync(string taskId, CancellationToken ct)
    {
        var (tasks, lists, settings, ctx) = CreateRepos();
        await using var _ = ctx;

        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 list = await lists.GetByIdAsync(task.ListId, ct)
            ?? throw new InvalidOperationException($"List {task.ListId} not found.");
        var listWorkingDir = list.WorkingDir
            ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");

        if (!await _git.IsGitRepoAsync(listWorkingDir, ct))
            throw new InvalidOperationException($"Working directory is not a git repository: {listWorkingDir}");

        var appSettings = await settings.GetAsync(ct);
        var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
        var branchName = BranchNameFor(taskId);
        var baseCommit = await _git.RevParseHeadAsync(listWorkingDir, ct);

        Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!);
        try
        {
            await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
        }
        catch (InvalidOperationException ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
        {
            // Self-heal: remove phantom worktrees, prune, delete branch, retry once.
            var stalePaths = await _git.ListWorktreePathsForBranchAsync(listWorkingDir, branchName, ct);
            foreach (var stale in stalePaths)
            {
                try { await _git.WorktreeRemoveAsync(listWorkingDir, stale, force: true, ct); } catch { }
            }
            try { await _git.WorktreePruneAsync(listWorkingDir, ct); } catch { }
            try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
            await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
        }

        // Write .mcp.json and .claude/settings.local.json into the worktree.
        var mcpPath = Path.Combine(worktreePath, ".mcp.json");
        await File.WriteAllTextAsync(mcpPath, BuildMcpConfigJson(), ct);

        var claudeDir = Path.Combine(worktreePath, ".claude");
        Directory.CreateDirectory(claudeDir);
        await File.WriteAllTextAsync(Path.Combine(claudeDir, "settings.local.json"), SettingsLocalJson, ct);

        // Session dir + token + prompt files.
        var token = GenerateToken();
        var started = await tasks.SetPlanningStartedAsync(taskId, token, ct)
            ?? throw new InvalidOperationException("Failed to transition task to Planning.");

        var sessionDir = Path.Combine(_rootDirectory, taskId);
        Directory.CreateDirectory(sessionDir);

        var files = new PlanningSessionFiles(
            sessionDir,
            Path.Combine(sessionDir, "system-prompt.md"),
            Path.Combine(sessionDir, "initial-prompt.txt"));

        await WriteTokenFileAsync(TokenFilePathFor(sessionDir), token, ct);
        await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct);
        await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct);

        return new PlanningSessionStartContext(
            ParentTaskId: taskId,
            WorkingDir: worktreePath,
            Token: token,
            WorktreePath: worktreePath,
            BranchName: branchName,
            Files: files);
    }
```

- [ ] **Step 2: Build**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "feat(worker): create ephemeral worktree and write .mcp.json in StartAsync"
```

---

## Task 7: Rewrite `ResumeAsync` and add cleanup to `FinalizeAsync` / `DiscardAsync`

**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (three methods)

- [ ] **Step 1: Replace `ResumeAsync` body**

```csharp
    public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
    {
        var (tasks, lists, settings, ctx) = CreateRepos();
        await using var _ = ctx;

        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);
        if (!Directory.Exists(sessionDir))
            throw new InvalidOperationException($"Session directory missing: {sessionDir}");

        var list = await lists.GetByIdAsync(task.ListId, ct)
            ?? throw new InvalidOperationException($"List {task.ListId} not found.");
        var listWorkingDir = list.WorkingDir
            ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");

        var appSettings = await settings.GetAsync(ct);
        var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
        if (!Directory.Exists(worktreePath))
            throw new InvalidOperationException($"Planning worktree missing — cannot resume: {worktreePath}");

        var token = await ReadTokenFileAsync(TokenFilePathFor(sessionDir), ct);

        return new PlanningSessionResumeContext(
            ParentTaskId: taskId,
            WorkingDir: worktreePath,
            ClaudeSessionId: task.PlanningSessionId,
            Token: token,
            WorktreePath: worktreePath);
    }
```

- [ ] **Step 2: Extend `FinalizeAsync` to clean up worktree + branch**

Replace the existing `FinalizeAsync` body with:

```csharp
    public async Task<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
    {
        var (tasks, lists, settings, ctx) = CreateRepos();
        await using var __ = ctx;

        var count = await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);

        // Best-effort cleanup — don't block finalization on git state.
        await TryCleanupWorktreeAsync(taskId, lists, settings, ct);

        var sessionDir = Path.Combine(_rootDirectory, taskId);
        if (Directory.Exists(sessionDir))
        {
            try { Directory.Delete(sessionDir, recursive: true); } catch { }
        }

        return count;
    }
```

- [ ] **Step 3: Extend `DiscardAsync` with the same cleanup**

Replace the body of `DiscardAsync` with:

```csharp
    public async Task DiscardAsync(string taskId, CancellationToken ct)
    {
        var (tasks, lists, settings, ctx) = CreateRepos();
        await using var __ = ctx;

        var ok = await tasks.DiscardPlanningAsync(taskId, ct);

        await TryCleanupWorktreeAsync(taskId, lists, settings, ct);

        var sessionDir = Path.Combine(_rootDirectory, taskId);
        if (Directory.Exists(sessionDir))
        {
            try { Directory.Delete(sessionDir, recursive: true); } catch { }
        }

        if (!ok)
            throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
    }
```

- [ ] **Step 4: Add the `TryCleanupWorktreeAsync` helper**

Add this private method near the other helpers:

```csharp
    private async Task TryCleanupWorktreeAsync(
        string taskId,
        ListRepository lists,
        AppSettingsRepository settings,
        CancellationToken ct)
    {
        try
        {
            var (tasks, _, _, ctx2) = CreateRepos();
            await using var __ = ctx2;

            var task = await tasks.GetByIdAsync(taskId, ct);
            if (task is null) return;

            var list = await lists.GetByIdAsync(task.ListId, ct);
            var listWorkingDir = list?.WorkingDir;
            if (string.IsNullOrEmpty(listWorkingDir) || !Directory.Exists(listWorkingDir)) return;

            var appSettings = await settings.GetAsync(ct);
            var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
            var branchName = BranchNameFor(taskId);

            if (Directory.Exists(worktreePath))
            {
                try { await _git.WorktreeRemoveAsync(listWorkingDir, worktreePath, force: true, ct); }
                catch { /* best effort */ }
            }
            try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
        }
        catch { /* best effort — never block finalize/discard */ }
    }
```

- [ ] **Step 5: Build**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "feat(worker): cleanup planning worktree and branch on finalize/discard"
```

---

## Task 8: Update `WindowsTerminalPlanningLauncher`

**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs`

- [ ] **Step 1: Rewrite `LaunchStartAsync`**

Replace the full method body with:

```csharp
    public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
    {
        if (!Directory.Exists(ctx.WorkingDir))
            throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");

        if (!File.Exists(ctx.Files.SystemPromptPath))
            throw new PlanningLaunchException($"System prompt file not found: {ctx.Files.SystemPromptPath}");
        if (!File.Exists(ctx.Files.InitialPromptPath))
            throw new PlanningLaunchException($"Initial prompt file not found: {ctx.Files.InitialPromptPath}");

        var resolvedWt = Resolve(_wtPath);
        if (resolvedWt is null)
            throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");

        var resolvedClaude = Resolve(_claudePath);
        if (resolvedClaude is null)
            throw new PlanningLaunchException($"claude executable not found: {_claudePath}");

        var psi = new ProcessStartInfo
        {
            FileName = resolvedWt,
            UseShellExecute = false,
            CreateNoWindow = false,
        };

        psi.Environment["MAX_THINKING_TOKENS"] = "20000";
        psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;

        // Arg order: --allowedTools is variadic (space-separated). The positional
        // prompt must follow a single-value flag, or it will be swallowed.
        // --append-system-prompt-file serves as that buffer.
        psi.ArgumentList.Add("-d");
        psi.ArgumentList.Add(ctx.WorkingDir);
        psi.ArgumentList.Add(resolvedClaude);
        psi.ArgumentList.Add("--model");
        psi.ArgumentList.Add(Model);
        psi.ArgumentList.Add("--allowedTools");
        psi.ArgumentList.Add(AllowedTools);
        psi.ArgumentList.Add("--append-system-prompt-file");
        psi.ArgumentList.Add(ctx.Files.SystemPromptPath);
        psi.ArgumentList.Add(File.ReadAllText(ctx.Files.InitialPromptPath));

        var proc = Process.Start(psi)
            ?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");

        return Task.CompletedTask;
    }
```

- [ ] **Step 2: Rewrite `LaunchResumeAsync`**

Replace the full method body with:

```csharp
    public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
    {
        if (!Directory.Exists(ctx.WorkingDir))
            throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");

        var resolvedWt = Resolve(_wtPath);
        if (resolvedWt is null)
            throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");

        var resolvedClaude = Resolve(_claudePath);
        if (resolvedClaude is null)
            throw new PlanningLaunchException($"claude executable not found: {_claudePath}");

        var psi = new ProcessStartInfo
        {
            FileName = resolvedWt,
            UseShellExecute = false,
            CreateNoWindow = false,
        };

        psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;

        psi.ArgumentList.Add("-d");
        psi.ArgumentList.Add(ctx.WorkingDir);
        psi.ArgumentList.Add(resolvedClaude);
        psi.ArgumentList.Add("--resume");
        psi.ArgumentList.Add(ctx.ClaudeSessionId);

        var proc = Process.Start(psi)
            ?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");

        return Task.CompletedTask;
    }
```

- [ ] **Step 3: Build**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
git commit -m "feat(worker): launcher passes planning token via env, drops --mcp-config"
```

---

## Task 9: Update DI wiring in `Program.cs`

**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs` (around line 59–62)

- [ ] **Step 1: Update the registration**

Find:

```csharp
builder.Services.AddSingleton(sp =>
    new PlanningSessionManager(
        sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
        planningSessionsDir));
```

Replace with:

```csharp
builder.Services.AddSingleton(sp =>
    new PlanningSessionManager(
        sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
        sp.GetRequiredService<GitService>(),
        cfg,
        planningSessionsDir));
```

- [ ] **Step 2: Build full worker**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add src/ClaudeDo.Worker/Program.cs
git commit -m "chore(worker): wire GitService and WorkerConfig into PlanningSessionManager DI"
```

---

## Task 10: Fix existing tests (add git init, update constructor calls, drop McpConfigPath assertions)

**Files:**
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs`

- [ ] **Step 1: Add a shared git-init helper**

Create `tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs`:

```csharp
using System.Diagnostics;

namespace ClaudeDo.Worker.Tests.Infrastructure;

public static class GitRepoFixture
{
    public static void InitRepoWithInitialCommit(string dir)
    {
        Directory.CreateDirectory(dir);
        Run(dir, "init", "-b", "main");
        Run(dir, "config", "user.email", "test@claudedo.local");
        Run(dir, "config", "user.name", "test");
        File.WriteAllText(Path.Combine(dir, "README.md"), "seed\n");
        Run(dir, "add", "-A");
        Run(dir, "commit", "-m", "chore: seed");
    }

    private static void Run(string cwd, params string[] args)
    {
        var psi = new ProcessStartInfo("git") { WorkingDirectory = cwd, RedirectStandardError = true, RedirectStandardOutput = true };
        foreach (var a in args) psi.ArgumentList.Add(a);
        var p = Process.Start(psi)!;
        p.WaitForExit();
        if (p.ExitCode != 0)
            throw new InvalidOperationException($"git {string.Join(" ", args)} failed: {p.StandardError.ReadToEnd()}");
    }
}
```

- [ ] **Step 2: Update `PlanningSessionManagerTests` constructor and seed helper**

In `PlanningSessionManagerTests.cs`, find the constructor and add after `_rootDir = …;`:

```csharp
        _git = new ClaudeDo.Data.Git.GitService();
        _cfg = new ClaudeDo.Worker.Config.WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") };
        _settingsRepo = new ClaudeDo.Data.Repositories.AppSettingsRepository(_ctx);
        // Seed settings row so the manager can read strategy.
        _settingsRepo.UpsertAsync(new ClaudeDo.Data.Models.AppSettingsEntity { Id = 1, WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
        _sut = new PlanningSessionManager(_tasks, _lists, _settingsRepo, _git, _cfg, _rootDir);
```

Add three private fields to the class:

```csharp
    private readonly ClaudeDo.Data.Git.GitService _git;
    private readonly ClaudeDo.Worker.Config.WorkerConfig _cfg;
    private readonly ClaudeDo.Data.Repositories.AppSettingsRepository _settingsRepo;
```

Change `SeedListAsync` to init a git repo:

```csharp
    private async Task<(string listId, string workingDir)> SeedListAsync()
    {
        var listId = Guid.NewGuid().ToString();
        var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
        ClaudeDo.Worker.Tests.Infrastructure.GitRepoFixture.InitRepoWithInitialCommit(wd);
        await _lists.AddAsync(new ListEntity
        {
            Id = listId,
            Name = "Test",
            WorkingDir = wd,
            CreatedAt = DateTime.UtcNow,
        });
        return (listId, wd);
    }
```

- [ ] **Step 3: Update assertions in the existing `StartAsync_…` test**

The old test asserts `ctx.Files.McpConfigPath`. Replace with worktree-based assertions:

```csharp
        Assert.Equal(parent.Id, ctx.ParentTaskId);
        Assert.Equal(ctx.WorktreePath, ctx.WorkingDir);
        Assert.True(Directory.Exists(ctx.WorktreePath));
        var mcpPath = Path.Combine(ctx.WorktreePath, ".mcp.json");
        Assert.True(File.Exists(mcpPath));
        Assert.True(File.Exists(Path.Combine(ctx.WorktreePath, ".claude", "settings.local.json")));
        Assert.True(File.Exists(ctx.Files.SystemPromptPath));
        Assert.True(File.Exists(ctx.Files.InitialPromptPath));

        var mcp = await File.ReadAllTextAsync(mcpPath);
        Assert.Contains("${CLAUDEDO_PLANNING_TOKEN}", mcp);
        Assert.DoesNotContain(ctx.Token, mcp);
```

- [ ] **Step 4: Update `PlanningEndToEndTests` SUT construction similarly**

Add the same fields + ctor arguments. Replace any `new PlanningSessionManager(tasks, lists, rootDir)` with `new PlanningSessionManager(tasks, lists, settingsRepo, git, cfg, rootDir)` and ensure the seeded working directory is git-initialized.

- [ ] **Step 5: Update `WindowsTerminalPlanningLauncherTests`**

If the existing tests construct `PlanningSessionStartContext` manually, update to supply the new `Token`, `WorktreePath`, `BranchName` fields. Add an assertion that the test observes (via a fake `IPlanningTerminalLauncher`-level check or by verifying the psi after a refactor seam) that the env var is set.

If the existing launcher test only verifies behavior that's no longer directly testable (it spawns wt.exe), leave those tests as-is but ensure they still compile with the new ctor shape.

- [ ] **Step 6: Run all planning tests**

Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~Planning"`
Expected: PASS for all tests that previously passed.

- [ ] **Step 7: Commit**

```bash
git add tests/ClaudeDo.Worker.Tests/Planning/ tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs
git commit -m "test(worker): adapt planning tests to git-backed worktree flow"
```

---

## Task 11: New tests — worktree creation, cleanup, self-heal, resume

**Files:**
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` (append new tests)

- [ ] **Step 1: Write the failing "worktree is removed on discard" test**

Append to the test class:

```csharp
    [Fact]
    public async Task DiscardAsync_RemovesWorktreeAndBranch()
    {
        var (listId, wd) = await SeedListAsync();
        var parent = await SeedManualTaskAsync(listId);

        var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
        Assert.True(Directory.Exists(ctx.WorktreePath));

        await _sut.DiscardAsync(parent.Id, CancellationToken.None);

        Assert.False(Directory.Exists(ctx.WorktreePath));
        // branch deleted
        var paths = await _git.ListWorktreePathsForBranchAsync(wd, ctx.BranchName);
        Assert.Empty(paths);
    }
```

- [ ] **Step 2: Run — expect PASS**

Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "DiscardAsync_RemovesWorktreeAndBranch"`
Expected: PASS.

- [ ] **Step 3: Add "non-git working dir errors" test**

```csharp
    [Fact]
    public async Task StartAsync_ThrowsWhenWorkingDirIsNotGitRepo()
    {
        var listId = Guid.NewGuid().ToString();
        var wd = Path.Combine(Path.GetTempPath(), $"cd_nogit_{Guid.NewGuid():N}");
        Directory.CreateDirectory(wd);
        await _lists.AddAsync(new ListEntity { Id = listId, Name = "NoGit", WorkingDir = wd, CreatedAt = DateTime.UtcNow });

        var t = await SeedManualTaskAsync(listId);

        await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.StartAsync(t.Id, CancellationToken.None));
    }
```

Run and expect PASS.

- [ ] **Step 4: Add self-heal test**

```csharp
    [Fact]
    public async Task StartAsync_SelfHealsWhenBranchAlreadyExists()
    {
        var (listId, wd) = await SeedListAsync();
        var parent = await SeedManualTaskAsync(listId);

        // Pre-create a colliding branch.
        var branch = $"claudedo/planning/{parent.Id.Replace("-", "")}";
        var head = await _git.RevParseHeadAsync(wd);
        var procInfo = new System.Diagnostics.ProcessStartInfo("git") { WorkingDirectory = wd };
        procInfo.ArgumentList.Add("branch");
        procInfo.ArgumentList.Add(branch);
        procInfo.ArgumentList.Add(head);
        var p = System.Diagnostics.Process.Start(procInfo)!;
        p.WaitForExit();

        var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
        Assert.True(Directory.Exists(ctx.WorktreePath));
    }
```

Run and expect PASS.

- [ ] **Step 5: Add resume test**

```csharp
    [Fact]
    public async Task ResumeAsync_ReturnsContextWithTokenAndWorktree()
    {
        var (listId, wd) = await SeedListAsync();
        var parent = await SeedManualTaskAsync(listId);

        var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);

        // Simulate the claude session capturing its session id.
        await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "session-abc", CancellationToken.None);

        var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);

        Assert.Equal(startCtx.Token, resumeCtx.Token);
        Assert.Equal(startCtx.WorktreePath, resumeCtx.WorktreePath);
        Assert.Equal("session-abc", resumeCtx.ClaudeSessionId);
    }
```

Run and expect PASS. If `UpdatePlanningSessionIdAsync` doesn't exist, use whatever repository method captures the Claude session id in this codebase (search the repo for the existing pattern) and substitute; do **not** skip this step.

- [ ] **Step 6: Run all planning tests**

Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~Planning"`
Expected: all PASS.

- [ ] **Step 7: Commit**

```bash
git add tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
git commit -m "test(worker): cover planning worktree lifecycle and self-heal"
```

---

## Task 12: Manual end-to-end verification

**Files:** none (manual)

- [ ] **Step 1: Build all projects**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj && dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.

- [ ] **Step 2: Start Worker + UI, create a manual task on a list whose WorkingDir is a real git repo, hit "Start planning"**

Expected:
- A Windows Terminal opens with `claude` running in a worktree under `<parent-of-WorkingDir>\.claudedo-worktrees\planning\<taskId>` (or the central root if strategy=central).
- No trust prompt appears for the `claudedo` MCP server.
- Inside claude, `/mcp` lists `claudedo` as connected.
- Asking claude "create a subtask" invokes `mcp__claudedo__*` tools and the new child task appears in the UI.

- [ ] **Step 3: Click Discard**

Expected:
- The worktree directory is gone; `git branch --list claudedo/planning/*` returns nothing; `~/.todo-app/sessions/<taskId>` is gone.

- [ ] **Step 4: Repeat with Finalize** — same expected cleanup.

- [ ] **Step 5: Close Windows Terminal mid-session, then "Resume"** — same worktree opens again with `--resume`.

---

## Deferred / follow-up

- **Defensive startup cleanup of orphaned planning worktrees.** Enumerate `.claudedo-worktrees/planning/*` (both sibling and central) and GC any whose session dir no longer exists. Ship as a follow-up plan if orphans become a real problem in practice.

---

## Self-Review Notes

- **Spec coverage:** Every section in `docs/superpowers/specs/2026-04-24-planning-worktree-design.md` maps to a task above (data flow → Task 6; launcher → Task 8; cleanup → Task 7; self-heal → Task 6 + Task 11.4; non-git error → Task 11.3; resume → Task 7 + Task 11.5; trust prompt bypass → Task 5 + Task 6). The one spec item deferred is the defensive startup cleanup.
- **Placeholder scan:** One conditional in Task 11.5 ("use whatever repository method captures the Claude session id") — this is deliberate: the existing codebase has an accessor whose exact name depends on local conventions and it's faster for the engineer to grep than for me to guess wrong. Every other step has full code.
- **Type consistency:** `PlanningSessionStartContext.WorktreePath` and `ResumeContext.WorktreePath` both `string`. `BranchName` only on Start (Resume recomputes via `BranchNameFor`). `Token` on both. `Files.McpConfigPath` removed everywhere.
