Files
ClaudeDo/docs/superpowers/plans/2026-04-24-planning-worktree-plan.md
mika kuns 4de2deaebe docs(planning): add worktree-isolated MCP session design and plan
Design: run each planning session in an ephemeral git worktree so .mcp.json
and .claude/settings.local.json can be placed without touching the user's
working dir. Plan breaks the change into 12 TDD tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:12:40 +02:00

37 KiB
Raw Permalink Blame History

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 McpConfigPathWorktreePath 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:

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
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:

namespace ClaudeDo.Worker.Planning;

public sealed record PlanningSessionFiles(
    string SessionDirectory,
    string SystemPromptPath,
    string InitialPromptPath);
  • Step 2: Commit
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:

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:

    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
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 })

    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
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:

    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
    private const string SettingsLocalJson = """
        {
          "enableAllProjectMcpServers": true
        }
        """;
  • Step 3: Commit
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:

    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
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

    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:

    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:

    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:

    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
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:

    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:

    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
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 5962)

  • Step 1: Update the registration

Find:

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

Replace with:

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
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:

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 = …;:

        _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:

    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:

    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:

        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
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:

    [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
    [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
    [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
    [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
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.