Files
ClaudeDo/docs/superpowers/specs/2026-04-24-planning-worktree-design.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

11 KiB

Planning Session MCP via Ephemeral Worktree

Date: 2026-04-24 Status: Design approved, pending implementation plan Scope: ClaudeDo.Worker — planning session launch, MCP config delivery

Problem

When a user starts a planning session, claude is spawned in the list's working directory via Windows Terminal and passed --mcp-config <absolute-path> pointing at a session-local mcp.json. In practice, the spawned claude session does not pick up the ClaudeDo MCP server: mcp__claudedo__* tools are not available, and no trust prompt is shown. The user has to fall back to the built-in TaskCreate tool, which writes nothing to ClaudeDo.

The --mcp-config flag is documented for headless (-p) invocations; in interactive TUI mode it appears to be either ignored or silently dropped on at least some CLI versions. The JSON payload itself is already correct (verified against Claude Code docs — type: "http" + Authorization header is the documented form).

The reliable path per Claude Code docs is project-root .mcp.json auto-discovery plus a one-time trust approval (or enableAllProjectMcpServers: true).

Goal

Spawn planning sessions so that mcp__claudedo__* tools are available immediately, without modifying any file in the user's working directory and without requiring a trust prompt.

Non-goals

  • Installer-time MCP registration (rejected — loses per-session token isolation; pollutes every claude invocation on the machine).
  • Changing how task execution (non-planning) spawns claude.
  • Supporting planning on a working directory that is not a git repository.

Approach: ephemeral planning worktree

Each planning session runs inside its own short-lived git worktree, created from HEAD of the list's working directory. The worktree is the isolated surface where we write .mcp.json and the settings override. The worktree is force-removed on FinalizeAsync / DiscardAsync.

Files changed

  • src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
  • src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs (extend to carry worktree path + branch name)
  • src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs (may drop McpConfigPath if no longer used)
  • src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
  • src/ClaudeDo.Worker/Runner/WorktreeMaintenanceService.cs (optional — defensive startup prune)
  • DI registration in src/ClaudeDo.Worker/Program.cs (inject GitService, WorkerConfig, IDbContextFactory<ClaudeDoDbContext> into PlanningSessionManager)

Data flow on StartAsync

  1. Resolve list.WorkingDir; hard-error if null, not a directory, or not a git repo (GitService.IsGitRepoAsync).
  2. Resolve HEAD via GitService.RevParseHeadAsync.
  3. Resolve worktree strategy from AppSettingsRepository.GetAsync (same resolution as WorktreeManager.CreateAsync):
    • sibling<parent-of-WorkingDir>\.claudedo-worktrees\planning\<taskId>
    • central<CentralWorktreeRoot>\planning\<taskId> Normalize with Path.GetFullPath.
  4. Branch name: claudedo/planning/<taskId-stripped-of-dashes>.
  5. GitService.WorktreeAddAsync(list.WorkingDir, branchName, worktreePath, baseCommit, ct). On "already exists" failure, run the same self-heal pattern as WorktreeManager.CreateAsync (list worktrees for branch → force-remove stale → prune → delete branch → retry once).
  6. Write into the worktree:
    • <worktreePath>\.mcp.json — JSON with env-var expansion for the token (see below).
    • <worktreePath>\.claude\settings.local.json{ "enableAllProjectMcpServers": true } (create .claude dir if missing).
  7. Write session artifacts in the session directory (unchanged from today): system-prompt.md, initial-prompt.txt. The session-local mcp.json is no longer written — drop that write.
  8. Return PlanningSessionStartContext with WorkingDir = worktreePath and a new WorktreePath field (redundant with WorkingDir for now, but explicit for cleanup). Also carry BranchName so finalize/discard can delete it.

MCP JSON payload

{
  "mcpServers": {
    "claudedo": {
      "type": "http",
      "url": "http://127.0.0.1:47821/mcp",
      "headers": {
        "Authorization": "Bearer ${CLAUDEDO_PLANNING_TOKEN}"
      }
    }
  }
}

The token never lives on disk in literal form — ${CLAUDEDO_PLANNING_TOKEN} is expanded by Claude Code at load time from the spawned process's environment.

.claude/settings.local.json payload

{
  "enableAllProjectMcpServers": true
}

Since the worktree is always empty of user customizations (fresh checkout), we write this file unconditionally. No merge / backup logic needed.

Launcher changes (WindowsTerminalPlanningLauncher)

  • LaunchStartAsync:
    • Set psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token (new field on PlanningSessionStartContext).
    • -d now points at the worktree path (already handled by ctx.WorkingDir change).
    • Remove --mcp-config and its path argument.
    • Keep --allowedTools mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,SkillenableAllProjectMcpServers only handles trust, not per-tool pre-approval.
    • Keep --append-system-prompt-file as the "single-value flag buffer" before the positional prompt (the existing arg-order concern is unchanged).
  • LaunchResumeAsync:
    • Same env-var setup.
    • Same -d <worktreePath>.
    • Remove --mcp-config (the worktree's .mcp.json is discovered automatically).
    • Keep --resume <ClaudeSessionId>.

Finalize / Discard

PlanningSessionManager.FinalizeAsync and DiscardAsync gain:

  1. Look up the worktree path + branch name (deterministic from taskId → reuse the same resolution code as StartAsync).
  2. GitService.WorktreeRemoveAsync(list.WorkingDir, worktreePath, force: true, ct)--force because claude may have created scratch files.
  3. GitService.BranchDeleteAsync(list.WorkingDir, branchName, force: true, ct).
  4. Delete the session dir as today.

All three steps are best-effort in DiscardAsync (log warnings, don't throw — the user explicitly asked to discard). FinalizeAsync should propagate failures, since a failed cleanup leaves resources we care about.

Resume

Resume already looks up list.WorkingDir from the list; the worktree path is deterministic from taskId. ResumeAsync must:

  1. Verify the worktree directory exists; if not, hard-error ("planning session was discarded or lost — cannot resume").
  2. Return PlanningSessionResumeContext with WorkingDir = worktreePath and the token (re-read from session state — see Token persistence below).

Token persistence

The token today is generated in StartAsync and embedded in mcp.json at creation time — never read again. With env-var expansion, the token must be available on resume. Options:

  • A) Persist token to session dir (<sessionDir>\token) with FileOptions.WriteAllBytes, restrict file ACL to current user. Read on resume.
  • B) Store token hash in DB, raw token in memory only — breaks across Worker restarts → no resume possible.

Chosen: A. Token file sits inside the existing session directory (<PlanningSessionManager._rootDirectory>\<taskId>\token), restricted to the current user via Windows ACLs (File.SetAccessControl with an explicit DACL granting FullControl to WindowsIdentity.GetCurrent() only). Cleaned up in DiscardAsync/FinalizeAsync with the rest of the session dir.

Defensive startup cleanup

WorktreeMaintenanceService already prunes worktrees tracked in the DB. Planning worktrees are not in the DB (they're purely filesystem-backed, keyed by taskId via path convention). Add a lightweight pass:

  • Enumerate directories matching <root>\.claudedo-worktrees\planning\* (for each strategy / central root we know about).
  • For each, check whether a corresponding session dir exists under ~/.todo-app/sessions/<taskId>.
  • If no session dir: git worktree remove --force + git branch -D claudedo/planning/<taskId-stripped>.

This is a small addition; if scoped too large, defer to a follow-up and accept that a crashed Worker leaves orphaned worktrees until manual cleanup.

Edge cases

Case Behavior
list.WorkingDir not a git repo Hard-error on StartAsync. Surface message in UI.
Worktree branch already exists from a prior crashed session Self-heal: force-remove matching worktrees, prune, delete branch, retry once. (Same pattern as WorktreeManager.CreateAsync.)
User closes Windows Terminal without clicking Finalize/Discard Session dir + worktree remain. ResumeAsync works. Startup cleanup handles abandoned sessions whose session dir the user manually deletes.
Claude creates/edits files in the planning worktree Discarded with the worktree. No impact on user's real working dir.
User deletes the session dir out from under the Worker ResumeAsync hard-errors. Startup cleanup GCs the orphaned worktree.
Two simultaneous planning sessions on the same task Already prevented by task status transition (Planning is exclusive). No new consideration.
HEAD is on a detached commit git worktree add handles this fine — base commit is explicit.

Testing

Extend tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs (or a new file) with integration tests using the real-SQLite + real-git pattern the project already uses:

  • Start happy path: worktree dir exists after StartAsync, contains .mcp.json with ${CLAUDEDO_PLANNING_TOKEN} literal, contains .claude/settings.local.json with enableAllProjectMcpServers: true.
  • Finalize cleanup: worktree dir is gone, branch is gone, session dir is gone.
  • Discard cleanup: same as finalize.
  • Self-heal: pre-create a stale branch claudedo/planning/<id>, then StartAsync must succeed.
  • Non-git working dir: StartAsync throws a specific error type.
  • Resume after Worker restart: seed session dir + token file, recreate PlanningSessionManager, ResumeAsync returns context pointing at the still-existing worktree.

Mock IPlanningTerminalLauncher (already an interface) so tests don't actually spawn wt.exe.

Trade-offs and alternatives considered

  1. Write .mcp.json into the user's working dir with backup/restore. Rejected — clobber risk, file-noise on crash, user's .gitignore may not cover it, exposes token alongside source even with env-var expansion (because expansion is on claude's side, the raw ${VAR} string still lives in the user's repo).
  2. User-scope registration via installer (claude mcp add --scope user). Rejected — requires a static secret baked into the Worker, loses per-session isolation, every claude session on the machine sees claudedo tools.
  3. Keep --mcp-config and debug why it's not honored. Rejected — even if it works on the maintainer's machine, the behavior is undocumented for interactive TUI mode, and we'd need a fallback anyway. Fixing to the documented path eliminates the uncertainty.

Open questions resolved

  • WorkingDir must be a git repo? Yes — hard-error.
  • Worktree path strategy? Follow the same sibling/central setting as task execution.
  • HEAD snapshot vs WIP? HEAD snapshot is fine — planning proposes subtasks, doesn't edit files.

Implementation sequencing

A separate implementation plan (via superpowers:writing-plans) will break this into test-first steps.