From 9d45939595597d91b7f5870aecae6d7a2a2fb48c Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Tue, 28 Jul 2026 20:28:05 -0500 Subject: [PATCH] feat(worktree): session-rescue tool (sessions.ps1) + an inert EnterWorktree rule Ports the session-rescue work off the dormant salvage branch, where it had sat unmerged since 2026-07-24 while the failure it fixes stayed live. sessions.ps1 indexes sessions across every login on the box and rehomes a stranded one back to the primary's slug. It is read-only by default -- a bare run only lists -- and -Rehome is the sole destructive path, refusing a session that still looks live and honouring -WhatIf. The gate rule denying EnterWorktree is included but ships INERT: the live hook is a copy at ~/.claude/hooks, so nothing changes until install-gate.ps1 is re-run. That is deliberate. Rule 2 already denies a fan-out from the primary, so activating this one as well leaves a primary-resident session with no in-session path to a subagent at all -- it must be started in a worktree. That may well be the right trade, but it is a workflow change that deserves its own decision, and the rescue tool should not wait behind it. docs/WORKTREES.md records both sides so whoever runs install-gate.ps1 next sees the argument. Numbered rule 3 here, not 5 as on the source branch: this list has two entries, not four. Reconstructed rather than cherry-picked -- cherry-pick is refused by the worktree gate -- taking the four files whose base was byte-identical to main wholesale and applying the other two by anchor. Verified the tripwire can actually fail: pulling the matcher out of install-gate.ps1 reddens test_every_tool_the_gate_handles_is_registered_by_the_installer, and restoring it goes green. The source commit notes a rule once shipped dead, so the guard seemed worth exercising rather than trusting. Full suite: 9040 passed, 816 skipped, 1 failed. The failure -- test_anon_parity.py::test_golden_corpus_engine_output_equals_tee_output -- is PRE-EXISTING on main and unrelated: it reproduces identically on a clean baseline with no changes present, and in a subset run that collects neither of the test files touched here. It passes in isolation and fails only after other test_a* modules run, so it is a test-isolation defect. Reported separately. --- docs/WORKTREES.md | 41 ++++ scripts/hooks/worktree_gate.ps1 | 29 +++ scripts/worktree/install-gate.ps1 | 1 + scripts/worktree/sessions.ps1 | 369 ++++++++++++++++++++++++++++++ tests/test_install_gate_wiring.py | 1 + tests/test_worktree_gate.py | 49 ++++ 6 files changed, 490 insertions(+) create mode 100644 scripts/worktree/sessions.ps1 diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index 0756da95..2b58ecb5 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -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 # detail for one session +pwsh -NoProfile -File scripts\worktree\sessions.ps1 -Rehome -WhatIf # preview the move, touch nothing +pwsh -NoProfile -File scripts\worktree\sessions.ps1 -Rehome # 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 diff --git a/scripts/hooks/worktree_gate.ps1 b/scripts/hooks/worktree_gate.ps1 index 9dbd1605..122954b2 100644 --- a/scripts/hooks/worktree_gate.ps1 +++ b/scripts/hooks/worktree_gate.ps1 @@ -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 +"@ +} + # A worktree that git nests INSIDE the primary's path (.claude/worktrees/, 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) { diff --git a/scripts/worktree/install-gate.ps1 b/scripts/worktree/install-gate.ps1 index 1830ae5a..37901769 100644 --- a/scripts/worktree/install-gate.ps1 +++ b/scripts/worktree/install-gate.ps1 @@ -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 diff --git a/scripts/worktree/sessions.ps1 b/scripts/worktree/sessions.ps1 new file mode 100644 index 00000000..b633c95f --- /dev/null +++ b/scripts/worktree/sessions.ps1 @@ -0,0 +1,369 @@ +<# +.SYNOPSIS + Find (and rescue) Claude Code sessions for this repo across every login -- including the ones that + relocating into a worktree made invisible. + +.DESCRIPTION + Claude Code files a session's transcript at + + \projects\\.jsonl + + where the slug is derived from the session's CURRENT working directory. Relocate a live session into a + worktree and the whole transcript is re-filed under the WORKTREE's slug -- so the conversation drops out + of the session list of the window it was born in. Nothing is deleted; it is simply somewhere the window + no longer looks. Measured here: a 5,159-line transcript moved out and left a 103-byte title stub behind. + + The gate (scripts\hooks\worktree_gate.ps1) now DENIES the EnterWorktree tool outright (rule 4), so a + live session can no longer be relocated into a worktree in the first place. This script is the recovery + path for the sessions that already went missing before that, and the way to see them at all: + + -Rehome moves a transcript back to the slug of the PRIMARY checkout, which puts it back in that + window's session list, where you can resume it normally. + + Three logins are in play on this machine (~\.claude for the Desktop app, ~\.claude-account-{1,2,3} for + the CLI/VS Code subscriptions), and a session is only ever visible to the login that owns it. This + scans all of them, so "which login was that in?" stops being a question you have to answer by hand. + + Read-only unless you pass -Rehome. A bare invocation only ever LISTS -- it never moves anything. -Rehome + is the one destructive action, and it honours -WhatIf (preview the move without touching disk) and + refuses on a session that still looks live (see -MinIdleMinutes). + +.EXAMPLE + .\sessions.ps1 # every session for this repo, newest first + .\sessions.ps1 -Relocated # only the ones that moved (i.e. are missing from a window) + .\sessions.ps1 -Id 3858de6d # detail for one session (id prefix is enough) + .\sessions.ps1 -Rehome 3858de6d -WhatIf # preview the rehome without moving anything + .\sessions.ps1 -Rehome 3858de6d # put it back in the session list of the window it started in +#> +[CmdletBinding(SupportsShouldProcess)] +param( + # Only sessions whose transcript is filed somewhere other than where the session was born -- the ones + # that vanished from a window's list. + [switch]$Relocated, + # Show one session in full. A unique id prefix is enough. + [string]$Id, + # Move a session's transcript under the PRIMARY's slug, so it reappears in the main window's session + # list. Refuses on a session that still looks live (see -MinIdleMinutes). Honours -WhatIf. + [string]$Rehome, + # Rehome somewhere other than the primary (must be a directory you can actually open a window on). + [string]$To, + # A transcript touched more recently than this is assumed to belong to a RUNNING session; moving the + # file out from under its writer would corrupt it. Override with -Force at your own risk. + [int]$MinIdleMinutes = 10, + [switch]$Force, + [int]$Limit = 40 +) + +$ErrorActionPreference = "Stop" + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path + +# git prints forward slashes on Windows; transcripts record backslashes. Normalise before comparing. +function Get-NormPath([string]$Path) { + if (-not $Path) { return "" } + return ($Path -replace '/', '\').TrimEnd('\') +} + +$WorktreePaths = @( + & git -C $RepoRoot worktree list --porcelain 2>$null | + Select-String -Pattern '^worktree (.+)$' | + ForEach-Object { Get-NormPath $_.Matches[0].Groups[1].Value } +) + +# Always reason about the PRIMARY, even when run from a worktree: it is the first entry of `worktree list`. +$Primary = if ($WorktreePaths.Count -gt 0) { $WorktreePaths[0] } else { Get-NormPath $RepoRoot } + +# The one .git that every worktree of this repo shares. It is how we tell a worktree of OURS from a +# separate repo that merely sits next to it -- `MessageFoundry-public` (the mirror) and +# `messagefoundry-website` both slug to `...-MessageFoundry-*` and are otherwise indistinguishable from a +# worktree named `public` / `website`. +$OurGitDir = "" +try { + $g = & git -C $RepoRoot rev-parse --path-format=absolute --git-common-dir 2>$null + if ($LASTEXITCODE -eq 0 -and $g) { $OurGitDir = Get-NormPath $g } +} catch { $OurGitDir = "" } + +$live = @{} +foreach ($w in $WorktreePaths) { $live[$w.ToLowerInvariant()] = $true } + +# Does this recorded cwd belong to THIS repo? Three cases, and the third is the one that matters. +function Test-OurCheckout([string]$Cwd) { + $n = Get-NormPath $Cwd + if (-not $n) { return $true } # no cwd recorded -> trust the slug pre-filter + if ($live.ContainsKey($n.ToLowerInvariant())) { return $true } # a live worktree of ours + + if (Test-Path -LiteralPath $n -PathType Container) { + # Still on disk but not a worktree of ours -> a different repo wearing a similar name. Drop it. + if (-not $OurGitDir) { return $false } + try { + $g = & git -C $n rev-parse --path-format=absolute --git-common-dir 2>$null + if ($LASTEXITCODE -ne 0 -or -not $g) { return $false } + return ((Get-NormPath $g) -ieq $OurGitDir) + } catch { return $false } + } + + # The directory is GONE. This is a pruned worktree -- and a session stranded in a pruned worktree's slug + # is exactly what this script exists to find, so keep it. (A deleted sibling repo would also land here; + # that is a rare, harmless false positive, and the listing shows you the path.) + return $true +} + +# Claude Code's slug: every character that is not a letter or a digit becomes '-'. So +# C:\Code\MessageFoundry -> C--Code-MessageFoundry. Compared case-insensitively +# throughout: the drive letter's case varies between sessions ('c:\...' vs 'C:\...') and NTFS folds it +# anyway, so the two spellings are one directory. +function Get-Slug([string]$Path) { + if (-not $Path) { return "" } + return ($Path -replace '[^A-Za-z0-9]', '-') +} + +$PrimarySlug = Get-Slug $Primary + +# All logins on this box. The Desktop app uses the default ~\.claude; each CLI subscription launcher sets +# CLAUDE_CONFIG_DIR to its own ~\.claude-account-N. A session is only visible to the login that owns it. +$ConfigDirs = @( + Get-ChildItem -Path $env:USERPROFILE -Directory -Filter ".claude*" -Force -ErrorAction SilentlyContinue | + Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "projects") } | + ForEach-Object { $_.FullName } +) + +# Pull the few facts we need out of the head of a transcript. Transcripts run to tens of MB, so never read +# one whole: the birth cwd, branch and first prompt are all in the first handful of records, and reading +# 400 lines of a 14 MB file costs nothing. The first line is often a queue-operation/last-prompt/mode +# record with no cwd, so scan for the first record that actually carries cwd/gitBranch rather than trusting +# line 1 (verified against the real transcript layout on this box). +function Read-TranscriptHead([string]$File) { + $info = [ordered]@{ Cwd = ""; Branch = ""; Title = ""; Prompt = "" } + $reader = $null + try { + $reader = [System.IO.StreamReader]::new($File) + for ($n = 0; $n -lt 400 -and -not $reader.EndOfStream; $n++) { + $line = $reader.ReadLine() + if (-not $line) { continue } + try { $o = $line | ConvertFrom-Json } catch { continue } + + if (-not $info.Cwd -and $o.cwd) { $info.Cwd = [string]$o.cwd } + if (-not $info.Branch -and $o.gitBranch) { $info.Branch = [string]$o.gitBranch } + # A title the user set by hand beats the model's generated one. + if ($o.customTitle) { $info.Title = [string]$o.customTitle } + if (-not $info.Title -and $o.aiTitle) { $info.Title = [string]$o.aiTitle } + + # First real user turn -- skip sidechains (subagent traffic), which are not what you'd + # recognise the session by. + if (-not $info.Prompt -and $o.type -eq "user" -and -not $o.isSidechain) { + $c = $o.message.content + $text = if ($c -is [string]) { $c } else { ($c | Where-Object { $_.type -eq "text" } | Select-Object -First 1).text } + if ($text) { $info.Prompt = ([string]$text -replace '\s+', ' ').Trim() } + } + } + } catch { + # A half-written transcript from a live session is normal, not an error. Report what we got. + } finally { + if ($reader) { $reader.Dispose() } + } + return [pscustomobject]$info +} + +# --------------------------------------------------------------------------------------------- collect +$records = @() +foreach ($cfg in $ConfigDirs) { + $projects = Join-Path $cfg "projects" + + # Pre-filter by slug so we don't crack open every unrelated repo's transcripts, then CONFIRM against + # the cwd recorded inside the file. The prefix alone is not enough to decide: the sibling repo + # `messagefoundry-website` slugs to `...-MessageFoundry-website`, which is indistinguishable from a + # worktree named `website`. The recorded cwd settles it. + $slugDirs = @( + Get-ChildItem -LiteralPath $projects -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -eq $PrimarySlug -or $_.Name -like "$PrimarySlug-*" } + ) + + foreach ($dir in $slugDirs) { + foreach ($file in (Get-ChildItem -LiteralPath $dir.FullName -Filter "*.jsonl" -File -ErrorAction SilentlyContinue)) { + $head = Read-TranscriptHead $file.FullName + + # The confirming check: the slug prefix said "maybe", the recorded cwd says yes or no. + $cwd = Get-NormPath $head.Cwd + if (-not (Test-OurCheckout $cwd)) { continue } + + $bornSlug = if ($cwd) { Get-Slug $cwd } else { $dir.Name } + $gone = [bool]($cwd -and -not (Test-Path -LiteralPath $cwd -PathType Container)) + $records += [pscustomobject]@{ + Id = $file.BaseName + Login = Split-Path $cfg -Leaf + ConfigDir = $cfg + FiledIn = $dir.FullName + FiledSlug = $dir.Name + BornCwd = $cwd + BornSlug = $bornSlug + Branch = $head.Branch + Title = $head.Title + Prompt = $head.Prompt + Bytes = $file.Length + Last = $file.LastWriteTime + File = $file.FullName + Gone = $gone + # THE symptom: the transcript is not filed where the session was born, so the window it + # started in cannot see it. (Case-insensitive: the drive letter's case varies.) + Relocated = ($bornSlug -and ($dir.Name -ine $bornSlug)) + } + } + } +} + +# A relocated session leaves a title-only stub (one `custom-title` line, ~100 bytes) behind under its birth +# slug. Same id, so fold it into the real record rather than listing a phantom 103-byte session. +$byKey = $records | Group-Object { "$($_.ConfigDir)|$($_.Id)" } +$sessions = foreach ($g in $byKey) { + $real = $g.Group | Sort-Object Bytes -Descending | Select-Object -First 1 + $stub = $g.Group | Where-Object { $_.File -ne $real.File } + if (-not $real.Title) { $real.Title = ($stub | Where-Object Title | Select-Object -First 1).Title } + $real | Add-Member -NotePropertyName Stubs -NotePropertyValue @($stub.File) -PassThru +} +$sessions = @($sessions | Sort-Object Last -Descending) + +# ---------------------------------------------------------------------------------------------- rehome +if ($Rehome) { + $hit = @($sessions | Where-Object { $_.Id -like "$Rehome*" }) + if ($hit.Count -eq 0) { throw "No session matching '$Rehome'. Run without -Rehome to list them." } + if ($hit.Count -gt 1) { throw "'$Rehome' matches $($hit.Count) sessions. Use a longer id prefix." } + $s = $hit[0] + + # Moving a transcript out from under a RUNNING session's writer corrupts it. Recent writes are the only + # signal we have that the session is live, so treat them as one. Under -WhatIf we only PREVIEW, so the + # liveness guard is relaxed there -- nothing is moved, and you still see what the move would do. + $idle = (Get-Date) - $s.Last + if ($idle.TotalMinutes -lt $MinIdleMinutes -and -not $Force -and -not $WhatIfPreference) { + throw ("Session $($s.Id) was written $([int]$idle.TotalMinutes) min ago and may still be RUNNING; " + + "moving its transcript would corrupt it. Close that window, then retry (or -Force).") + } + + # Rehome to the PRIMARY, not to wherever the session happened to be born. + # + # "Put it back where it started" is the obvious rule and it is wrong. A session can be born in a + # worktree that no longer exists -- notably the empty ghost stubs the Desktop app's auto-worktree + # leaves behind -- and its transcript can already be sitting in the primary, VISIBLE. Sending that one + # "home" would file it under a directory nobody will ever open a window on, i.e. hide the very session + # we were asked to rescue. (Real case here: a 16 MB session born in a ghost stub, filed in the primary.) + # + # The primary is the one checkout that is always open, so it is the one safe destination. -To overrides + # when you specifically want the session to land in some worktree's window instead. + $destCwd = if ($To) { Get-NormPath $To } else { $Primary } + $destSlug = Get-Slug $destCwd + if ($To -and -not (Test-Path -LiteralPath $destCwd -PathType Container)) { + throw "-To '$destCwd' does not exist; a session filed under a directory you cannot open a window on is still lost." + } + + $destDir = Join-Path (Join-Path $s.ConfigDir "projects") $destSlug + $dest = Join-Path $destDir "$($s.Id).jsonl" + if ((Get-NormPath $dest) -ieq (Get-NormPath $s.File)) { + Write-Host "Session $($s.Id) is already filed at $destSlug. Nothing to do." -ForegroundColor Green + return + } + + # A live session now also has a sidecar directory next to its transcript -- + # \projects\\\ (subagents\, workflows\). Move it alongside the .jsonl so the + # rehome does not strand it under the old slug. (The session-env dir is keyed by session id, not slug, + # so it is unaffected by a rehome and left alone.) + $srcSidecar = Join-Path $s.FiledIn $s.Id + $destSidecar = Join-Path $destDir $s.Id + $haveSidecar = Test-Path -LiteralPath $srcSidecar -PathType Container + + # -WhatIf preview / confirmation. Nothing below this gate touches disk. + $action = "rehome $($s.FiledSlug) -> $destSlug$(if ($haveSidecar) { ' (+ sidecar dir)' })" + if (-not $PSCmdlet.ShouldProcess("session $($s.Id) [$($s.Login)]", $action)) { return } + + New-Item -ItemType Directory -Force -Path $destDir | Out-Null + + # The title stub already sitting at the destination IS the session's custom title. Keep it: back it up, + # then re-append its records to the end of the moved transcript (JSONL is a log -- later records win). + $stubLines = @() + if (Test-Path -LiteralPath $dest) { + Copy-Item -LiteralPath $dest -Destination "$dest.stub.bak" -Force + $stubLines = @(Get-Content -LiteralPath $dest -ErrorAction SilentlyContinue) + Remove-Item -LiteralPath $dest -Force + } + + Move-Item -LiteralPath $s.File -Destination $dest -Force + if ($stubLines.Count -gt 0) { Add-Content -LiteralPath $dest -Value $stubLines -Encoding utf8 } + + # Move the sidecar directory too, if one exists and is not already at the destination. + $movedSidecar = $false + if ($haveSidecar -and ((Get-NormPath $srcSidecar) -ine (Get-NormPath $destSidecar))) { + if (-not (Test-Path -LiteralPath $destSidecar)) { + Move-Item -LiteralPath $srcSidecar -Destination $destSidecar -Force + $movedSidecar = $true + } + } + + Write-Host "" + Write-Host "Rehomed session $($s.Id)" -ForegroundColor Green + Write-Host " from : $($s.FiledSlug)" + Write-Host " to : $destSlug ($destCwd)" + Write-Host " login: $($s.Login)" + if ($stubLines.Count -gt 0) { Write-Host " kept : the title stub's $($stubLines.Count) record(s) (backup: $dest.stub.bak)" } + if ($movedSidecar) { Write-Host " moved: the session's sidecar dir ($($s.Id)\)" } + Write-Host "" + Write-Host "It is back in that folder's session list. Open a Claude Code window on $destCwd and it will" + Write-Host "be there; or resume it directly:" + Write-Host " `$env:CLAUDE_CONFIG_DIR='$($s.ConfigDir)'; cd '$destCwd'; claude --resume $($s.Id)" + return +} + +# ---------------------------------------------------------------------------------------------- detail +if ($Id) { + $hit = @($sessions | Where-Object { $_.Id -like "$Id*" }) + if ($hit.Count -eq 0) { throw "No session matching '$Id'." } + foreach ($s in $hit) { + Write-Host "" + Write-Host "$($s.Id)" -ForegroundColor Cyan + Write-Host " title : $($s.Title)" + Write-Host " login : $($s.Login)" + Write-Host " born in : $($s.BornCwd)$(if ($s.Gone) { ' [DIRECTORY GONE - worktree was pruned]' }) (branch $($s.Branch))" + Write-Host " filed in : $($s.FiledIn)" + if ($s.Relocated -and $s.FiledSlug -ine $PrimarySlug) { + Write-Host " STATUS : RELOCATED -- the window it started in cannot see it." -ForegroundColor Yellow + Write-Host " Bring it back to the main window: .\sessions.ps1 -Rehome $($s.Id)" + } + Write-Host " size : $([math]::Round($s.Bytes / 1MB, 2)) MB, last written $($s.Last)" + Write-Host " opened : $($s.Prompt)" + Write-Host " resume : `$env:CLAUDE_CONFIG_DIR='$($s.ConfigDir)'; cd '$($s.BornCwd)'; claude --resume $($s.Id)" + } + return +} + +# ------------------------------------------------------------------------------------------------ list +$show = if ($Relocated) { @($sessions | Where-Object Relocated) } else { $sessions } +if ($show.Count -eq 0) { + Write-Host "No sessions found for $Primary across $($ConfigDirs.Count) login(s)." -ForegroundColor Yellow + return +} + +# Short, human label for where a transcript sits: the primary, or the worktree's own name. +function Get-Where([string]$Slug) { + if ($Slug -ieq $PrimarySlug) { return "primary" } + if ($Slug -like "$PrimarySlug--claude-worktrees-*") { return $Slug.Substring("$PrimarySlug--claude-worktrees-".Length) } + if ($Slug -like "$PrimarySlug-*") { return $Slug.Substring($PrimarySlug.Length + 1) } + return $Slug +} + +$show | Select-Object -First $Limit | ForEach-Object { + [pscustomobject]@{ + Last = $_.Last.ToString("MM-dd HH:mm") + Id = $_.Id.Substring(0, 8) + Login = $_.Login -replace '^\.claude-account-', 'acct-' -replace '^\.claude$', 'desktop' + FiledIn = Get-Where $_.FiledSlug + BornIn = Get-Where $_.BornSlug + MB = [math]::Round($_.Bytes / 1MB, 1) + Title = if ($_.Title) { $_.Title } else { $_.Prompt } + } +} | Format-Table -AutoSize + +$moved = @($sessions | Where-Object { $_.Relocated -and $_.FiledSlug -ine $PrimarySlug }).Count +if ($moved -gt 0 -and -not $Relocated) { + Write-Host "" + Write-Host "$moved session(s) were relocated out of the window they started in, and that window's" -ForegroundColor Yellow + Write-Host "session list can no longer see them. Nothing is lost -- they are just filed elsewhere." -ForegroundColor Yellow + Write-Host " list them : .\sessions.ps1 -Relocated" -ForegroundColor Yellow + Write-Host " bring one back to main: .\sessions.ps1 -Rehome " -ForegroundColor Yellow +} diff --git a/tests/test_install_gate_wiring.py b/tests/test_install_gate_wiring.py index 4fd384c1..709c481d 100644 --- a/tests/test_install_gate_wiring.py +++ b/tests/test_install_gate_wiring.py @@ -56,6 +56,7 @@ def test_the_gate_handles_the_tools_we_expect() -> None: "Workflow", "Bash", "PowerShell", + "EnterWorktree", } diff --git a/tests/test_worktree_gate.py b/tests/test_worktree_gate.py index b7eac1ed..f5c066b6 100644 --- a/tests/test_worktree_gate.py +++ b/tests/test_worktree_gate.py @@ -184,6 +184,55 @@ def test_dispatch_from_a_worktree_is_allowed(tmp_path: Path, repos_file: Path) - assert run_gate(payload, repos_file) is None +# --------------------------------------------------------------------------- rule 4: EnterWorktree + + +def enter_worktree(cwd: Path | str, name: str = "wt-1") -> dict[str, Any]: + return { + "session_id": "s-1", + "cwd": str(cwd), + "hook_event_name": "PreToolUse", + "tool_name": "EnterWorktree", + "tool_input": {"name": name}, + } + + +def test_enter_worktree_is_denied(primary: Path, repos_file: Path) -> None: + """Relocating a live session re-files its transcript and drops the chat from its window's list.""" + reason = assert_denied(run_gate(enter_worktree(cwd=primary), repos_file)) + assert "EnterWorktree" in reason + assert "sessions.ps1" in reason # the deny must point at the recovery path, not just say no + + +def test_enter_worktree_denied_even_from_a_worktree_cwd(tmp_path: Path, repos_file: Path) -> None: + """Rule 4 keys on the TOOL, not cwd -- relocation loses the chat wherever you start it.""" + assert_denied(run_gate(enter_worktree(cwd=tmp_path / "Repo-alerts"), repos_file)) + + +def test_exit_worktree_is_allowed(primary: Path, repos_file: Path) -> None: + """ExitWorktree is a safe keep; only EnterWorktree is denied.""" + payload = { + "session_id": "s-1", + "cwd": str(primary), + "tool_name": "ExitWorktree", + "tool_input": {}, + } + assert run_gate(payload, repos_file) is None + + +def test_enter_worktree_allowed_when_gate_is_off(primary: Path, empty_repos: Path) -> None: + """Kill switch wins: no allowlist -> even EnterWorktree passes.""" + assert run_gate(enter_worktree(cwd=primary), empty_repos) is None + + +def test_normal_edit_still_allowed_alongside_rule_4( + tmp_path: Path, primary: Path, repos_file: Path +) -> None: + """Adding rule 4 must not disturb the 29% case: a write into a sibling worktree stays allowed.""" + worktree = tmp_path / "Repo-alerts" / "src" / "app.py" + assert run_gate(edit(worktree, cwd=primary), repos_file) is None + + # --------------------------------------------------------------------------- rule 3: git tree-swaps