Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/WORKTREES.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,51 @@ registers it as a `PreToolUse` hook in the **user-scope** `~/.claude/settings.js
2. a **`Task`/`Agent`/`Workflow` dispatch made from the primary** — a subagent inherits the parent's cwd,
can't create a worktree for itself, and its blocked edits don't reliably surface back to the parent, so
a fan-out from the primary would *appear* to succeed while writing nothing.
3. an **`EnterWorktree` tool call**, which relocates a **live** session into a worktree — that re-files the
session's chat transcript under the worktree's slug, so the conversation drops out of the session list of
the window it was born in (nothing is deleted; the window just stops looking there). Open a **fresh**
session directly on the worktree instead. Any session already relocated and gone missing is recoverable —
see the recovery tool below. (Fail-open like the rest: an unrecognised payload never wedges the tool call.)

Reads are never gated: asking a question or planning in the primary stays frictionless. Only building is
blocked.

> **Rule 3 ships INERT — activating it is a deliberate, separate decision.** The live hook is a *copy* at
> `~/.claude/hooks/worktree_gate.ps1`; `install-gate.ps1` is what overwrites it. Merging this rule changes
> nothing until that script is re-run, which is why the code can land ahead of the call.
>
> Weigh it with rules 2 and 3 together before you activate. Rule 2 denies a fan-out **from** the primary and
> rule 3 denies relocating **into** a worktree, so with both live a primary-resident session has **no
> in-session path to a subagent at all** — it must be *started* in a worktree. That is the safe pattern, but
> it is a hard stop rather than a nudge, and it makes workflow-by-default impossible from the directory
> sessions naturally open in.
>
> The counter-case is that `EnterWorktree` → dispatch → `ExitWorktree keep` is genuinely safe: the transcript
> follows the cwd **both** ways, so a relocated session is only lost if it *ends* while still inside. Rule 3
> cannot know you will exit properly — but `sessions.ps1` below now makes that outcome **recoverable**, which
> is the thing that was missing when ten sessions were stranded and the rule was first designed. Ship the
> cure, then decide whether you still want the prohibition.

### Recovering a relocated session — `sessions.ps1`

If a session was relocated into a worktree before rule 3 existed (or by a plain terminal, which the gate
never governs) and vanished from its window's list, [`sessions.ps1`](../scripts/worktree/sessions.ps1) finds
and rescues it. It scans **every** login on the box (`~\.claude` plus each `~\.claude-account-*`) and reads
only the head of each transcript, so it is fast and read-only by default:

```powershell
pwsh -NoProfile -File scripts\worktree\sessions.ps1 # every session for this repo, newest first
pwsh -NoProfile -File scripts\worktree\sessions.ps1 -Relocated # only the ones that moved (missing from a window)
pwsh -NoProfile -File scripts\worktree\sessions.ps1 -Id <prefix> # detail for one session
pwsh -NoProfile -File scripts\worktree\sessions.ps1 -Rehome <prefix> -WhatIf # preview the move, touch nothing
pwsh -NoProfile -File scripts\worktree\sessions.ps1 -Rehome <prefix> # put it back in the primary's session list
```

`-Rehome` is the one destructive action: it moves the transcript (and its sidecar dir) back under the
**primary's** slug so it reappears in the main window's session list. A bare invocation only ever **lists** —
it never moves anything — and `-Rehome` refuses on a session that still looks live (written within
`-MinIdleMinutes`, default 10; override with `-Force`) and honours `-WhatIf` for a no-op preview.

**It keys on the write's target path, never on the session's cwd.** In that same 30-day window, **29% of
writes came from a session sitting in the primary but landed inside a sibling worktree by absolute
path** — already correct. A cwd-keyed gate would have denied every one of them. So a session may stay
Expand Down
29 changes: 29 additions & 0 deletions scripts/hooks/worktree_gate.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,35 @@ $tool = [string]$hook.tool_name
$cwd = Get-ComparablePath ([string]$hook.cwd) # canonicalised: allowlist comparison only
$cwdRaw = [string]$hook.cwd # original case: for `git -C` in rule 3b

# ---------------------------------------------------------------------------------------------------
# Rule 4 -- deny the EnterWorktree tool. Relocating a LIVE session into a worktree re-files its
# transcript under the worktree's slug, so the conversation drops out of the window it was born in
# (measured: a 5,159-line transcript moved out, leaving a 103-byte stub). Open a FRESH session in the
# worktree instead; scripts\worktree\sessions.ps1 -Rehome recovers any session already relocated.
#
# Keys on the TOOL, not the cwd: relocation loses the chat wherever you start it, so once the gate is
# on (roots non-empty, guarded above) EnterWorktree is denied unconditionally. ExitWorktree is a safe
# keep and must NOT be caught. Fail-open is preserved: any earlier parse error already exited 0, and
# only an exact tool match reaches Write-Deny.
#
# Expressed as `$tool -in @("EnterWorktree")` so tests/test_install_gate_wiring.py SEES this tool as
# handled and ENFORCES that install-gate.ps1 registers a matcher for it -- rule 3 shipped dead once by
# implementing a rule with no matcher, and that tripwire exists to prevent exactly this. The matcher is
# wired in install-gate.ps1 alongside this change; delete it there and the wiring test goes red.
# ---------------------------------------------------------------------------------------------------
if ($tool -in @("EnterWorktree")) {
Write-Deny @"
BLOCKED: EnterWorktree relocates this live session into a worktree, which re-files its chat transcript
under the worktree's slug and drops it from THIS window's session list (nothing is deleted -- it just
stops appearing where you started). Do not relocate a running session.

Instead:
* Open a NEW Claude Code window/session directly on the worktree and continue there.
* If a session has already been relocated and vanished, recover it:
pwsh -NoProfile -File $($roots[0].Display)\scripts\worktree\sessions.ps1 -Rehome <id-prefix>
"@
}

# A worktree that git nests INSIDE the primary's path (.claude/worktrees/<name>, the first-party
# mechanism) is a legitimate worktree even though its path starts with the primary's. Never gate it.
function Test-Governed([string]$Candidate) {
Expand Down
1 change: 1 addition & 0 deletions scripts/worktree/install-gate.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ $command = "pwsh -NoProfile -File `"$GateDst`""
$matchers = @(
"Write|Edit|MultiEdit|NotebookEdit" # rule 1 -- writes INTO the primary's tree
"Bash|PowerShell" # rules 3 + 3b -- git verbs that swap the primary / hijack a worktree
"EnterWorktree" # rule 4 -- relocating a live session (loses its transcript)
)
if (-not $NoDispatchGate) {
$matchers += "Task|Agent|Workflow" # rule 2 -- subagent dispatch FROM the primary
Expand Down
Loading
Loading