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

173 lines
11 KiB
Markdown

# 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
```json
{
"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
```json
{
"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,Skill``enableAllProjectMcpServers` 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.