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>
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
claudeinvocation 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.cssrc/ClaudeDo.Worker/Planning/PlanningSessionContext.cs(extend to carry worktree path + branch name)src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs(may dropMcpConfigPathif no longer used)src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cssrc/ClaudeDo.Worker/Runner/WorktreeMaintenanceService.cs(optional — defensive startup prune)- DI registration in
src/ClaudeDo.Worker/Program.cs(injectGitService,WorkerConfig,IDbContextFactory<ClaudeDoDbContext>intoPlanningSessionManager)
Data flow on StartAsync
- Resolve
list.WorkingDir; hard-error ifnull, not a directory, or not a git repo (GitService.IsGitRepoAsync). - Resolve
HEADviaGitService.RevParseHeadAsync. - Resolve worktree strategy from
AppSettingsRepository.GetAsync(same resolution asWorktreeManager.CreateAsync):sibling→<parent-of-WorkingDir>\.claudedo-worktrees\planning\<taskId>central→<CentralWorktreeRoot>\planning\<taskId>Normalize withPath.GetFullPath.
- Branch name:
claudedo/planning/<taskId-stripped-of-dashes>. GitService.WorktreeAddAsync(list.WorkingDir, branchName, worktreePath, baseCommit, ct). On"already exists"failure, run the same self-heal pattern asWorktreeManager.CreateAsync(list worktrees for branch → force-remove stale → prune → delete branch → retry once).- 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.claudedir if missing).
- Write session artifacts in the session directory (unchanged from today):
system-prompt.md,initial-prompt.txt. The session-localmcp.jsonis no longer written — drop that write. - Return
PlanningSessionStartContextwithWorkingDir = worktreePathand a newWorktreePathfield (redundant withWorkingDirfor now, but explicit for cleanup). Also carryBranchNameso 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 onPlanningSessionStartContext). -dnow points at the worktree path (already handled byctx.WorkingDirchange).- Remove
--mcp-configand its path argument. - Keep
--allowedTools mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill—enableAllProjectMcpServersonly handles trust, not per-tool pre-approval. - Keep
--append-system-prompt-fileas the "single-value flag buffer" before the positional prompt (the existing arg-order concern is unchanged).
- Set
LaunchResumeAsync:- Same env-var setup.
- Same
-d <worktreePath>. - Remove
--mcp-config(the worktree's.mcp.jsonis discovered automatically). - Keep
--resume <ClaudeSessionId>.
Finalize / Discard
PlanningSessionManager.FinalizeAsync and DiscardAsync gain:
- Look up the worktree path + branch name (deterministic from
taskId→ reuse the same resolution code asStartAsync). GitService.WorktreeRemoveAsync(list.WorkingDir, worktreePath, force: true, ct)—--forcebecause claude may have created scratch files.GitService.BranchDeleteAsync(list.WorkingDir, branchName, force: true, ct).- 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:
- Verify the worktree directory exists; if not, hard-error ("planning session was discarded or lost — cannot resume").
- Return
PlanningSessionResumeContextwithWorkingDir = worktreePathand 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) withFileOptions.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.jsonwith${CLAUDEDO_PLANNING_TOKEN}literal, contains.claude/settings.local.jsonwithenableAllProjectMcpServers: 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>, thenStartAsyncmust succeed. - Non-git working dir:
StartAsyncthrows a specific error type. - Resume after Worker restart: seed session dir + token file, recreate
PlanningSessionManager,ResumeAsyncreturns 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
- Write
.mcp.jsoninto the user's working dir with backup/restore. Rejected — clobber risk, file-noise on crash, user's.gitignoremay 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). - User-scope registration via installer (
claude mcp add --scope user). Rejected — requires a static secret baked into the Worker, loses per-session isolation, everyclaudesession on the machine sees claudedo tools. - Keep
--mcp-configand 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/centralsetting 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.