Summary
D:\d — the exact stray artifact docs/conventions/windows-path-emit/README.md exists to eliminate — was created for the third time today, at 2026-08-16 16:46:30 EDT, by an ordinary agent lane doing ordinary work. Not a sabotage fixture, not an external tool.
2841 landed the convention, the helper (scripts/emit-windows-path.sh), and the detector (scripts/check-drive-root-litter.sh). All three are sound. None is a preventive control at the point of use. This recurrence is the evidence that documentation plus an advisory post-hoc detector does not hold
And the trigger turns out not to be the path form. That is the most important thing in this issue, so it leads.
The trigger is an exported environment variable, not a path literal
The creating lane ran, in one command string:
unset GIT_DIR GIT_WORK_TREE; export MSYS_NO_PATHCONV=1; cd /d/worktrees/ccp-silent-revert-calib
...
git worktree add --detach /d/worktrees/ccp-measure2 origin/fix/2837-2833-silent-revert-intent
The same lane had already run the identical /d/worktrees/... argument form and it landed correctly at D:/worktrees/ccp-measure. The only difference is export MSYS_NO_PATHCONV=1, set earlier in the same command string — and set for an unrelated reason: to stop MSYS mangling a <rev>:<path> argument containing a colon (mechanism B below). That export then silently suppressed translation of a path argument in a later, unrelated command in the same shell.
A guard that greps for /d/-style path literals would not have caught this. The offending command is textually identical in its path argument to the one that worked. Any guard keyed purely on path form has a false negative on the actual incident — which is the same false-green shape this entire body of work exists to remove.
The discriminator, proven four ways in one measurement
git rev-parse --sq-quote <path> prints exactly what git.exe received in argv, and creates nothing:
$ bash -c 'git rev-parse --sq-quote /d/probe'
'D:/probe' <- baseline: MSYS translated it. SAFE.
$ bash -c 'MSYS_NO_PATHCONV=1; git rev-parse --sq-quote /d/probe'
'D:/probe' <- bare assignment, NOT exported: no effect. SAFE.
$ bash -c 'export MSYS_NO_PATHCONV=1; git rev-parse --sq-quote /d/probe'
'/d/probe' <- EXPORTED: untranslated. THE DEFECT.
$ bash -c "export MSYS2_ARG_CONV_EXCL='*'; git rev-parse --sq-quote /d/probe"
'/d/probe' <- same, other variable.
$ bash -c 'MSYS_NO_PATHCONV=1 git rev-parse --sq-quote /d/first; git rev-parse --sq-quote /d/second'
'/d/first' <- per-command PREFIX: suppressed where intended...
'D:/second' <- ...and the blast radius stops there. SAFE.
So the predicate is exact and narrow: export of MSYS_NO_PATHCONV or MSYS2_ARG_CONV_EXCL. The bare assignment does nothing (the MSYS runtime reads the environment, and an unexported shell variable is not in it). The per-command prefix does exactly what the author intended and nothing more. Only the exported form makes the blast radius the rest of the command string — and, in this incident, a git worktree add seven segments later.
Correction worth recording: advising MSYS_NO_PATHCONV=1 for <rev>:<path> git arguments is only safe as a per-command prefix. As export it is actively dangerous. The better answer avoids the question: use Windows-native paths (D:/...) for every path argument.
The recurrence, re-verified before filing
git worktree list --porcelain in D:/repos/github.com/melodic-software/claude-code-plugins:
worktree D:/d/worktrees/ccp-measure2
HEAD 39ecf30887fde9caebda297c22be171a95acc287
detached
D:\d\worktrees\ccp-measure2\.git:
gitdir: D:/repos/github.com/melodic-software/claude-code-plugins/.git/worktrees/ccp-measure2
A genuine registered linked worktree, not loose litter. Read-only stat — nothing under D:\d was modified, moved, or removed; that cleanup is assigned to another lane:
D:/d | mtime=2026-08-16 16:46:30.982056300 -0400 | ctime=2026-08-16 16:46:30.982056300 -0400
D:/d/worktrees | mtime=2026-08-16 16:46:30.982056300 -0400 | ctime=2026-08-16 16:46:30.982056300 -0400
D:/d/worktrees/ccp-measure2 | mtime=2026-08-16 16:46:31.389891800 -0400 | ctime=2026-08-16 16:46:31.389891800 -0400
mtime == ctime on D:\d and D:\d\worktrees means the whole phantom chain was created in that one call.
Recurrence count: three, on three dates
| # |
Date |
Origin |
Provenance of this row |
| 1 |
2026-08-14 (removed ~12:09) |
pre-existing residue removed during a /disk-hygiene:clean --execute D: run |
cited from #2834 / melodic-software/dotfiles#486 — not re-measured here; the tree has been recycled twice since and the evidence is no longer on disk |
| 2 |
2026-08-15 04:46:04 |
recreated by the untracked _vfy/sabotage.sh harness — an MSYS /d/... literal handed to a Windows-native Python |
cited from #2834 — not re-measured here, same reason |
| 3 |
2026-08-16 16:46:30 |
recreated by a live agent lane creating a worktree — no fixture, no external tool |
measured directly today, output above |
Rows 1 and 2 are quoted from #2834's evidence rather than re-derived, and labelled as such. Row 3 is what changes the argument: the first two are readable as harness-authoring hygiene. The third is the normal path.
Mechanism A — Win32 resolves a leading / against the current drive
The underlying rule, once translation is suppressed. Reproduced by execution twice, independently — a second agent re-derived it from scratch with no sight of the first's reasoning. Nothing was created at any real drive root: subst mapped throwaway virtual drives onto scratch subdirectories, and every mapping and directory was removed with removal verified by listing.
The exact incident command shape. PowerShell, throwaway repo at P:\repo (P: = subst onto a scratch dir):
git -C P:\repo worktree add --detach /d/worktrees/ccp-reproA
produced P:\d\worktrees\ccp-reproA, registered in git worktree list --porcelain. Same shape as D:\d\worktrees\ccp-measure2. No PreToolUse hook blocked it.
Drive-dependence — the same argument, two different absolute paths:
PowerShell, Set-Location P:\ ; git init /d/worktrees/probe
-> Initialized empty Git repository in .../driveP/d/worktrees/probe/.git/ (= P:\d\worktrees\probe)
PowerShell, Set-Location Q:\ ; git init /d/worktrees/probe
-> Initialized empty Git repository in .../driveQ/d/worktrees/probe/.git/ (= Q:\d\worktrees\probe)
Byte-identical argument, two different absolute paths. It is drive-root relative, not cwd-relative: the independent verifier ran git worktree add /y/probe with cwd X:\repo and got X:\y\probe, not X:\repo\y\probe.
The exposed surface is any spawner without MSYS argv rewriting — the PowerShell tool, cmd, CI runners, Node/Python subprocess, agent harnesses — or a Bash shell where rewriting has been switched off, which is what happened here.
Footnote on one measurement that looks contradictory and is not: [System.IO.Path]::GetFullPath('/d/worktrees/x') returns C:\d\... from a PowerShell session whose Set-Location is P:\, because Set-Location moves the PowerShell provider location without moving the process's Win32 current directory that System.IO.Path reads. The verifier controlled for it via [Environment]::CurrentDirectory plus cmd /c cd and got X:\d\... / Y:\d\.... Native children such as git.exe inherit the provider location, which is why the git legs show drive-dependence directly.
One further measured hazard in the same family: a non-drive absolute POSIX path handed to a native program gets the MSYS installation root prefixed — cmd //c echo /docs/foo → C:/Program Files/Git/docs/foo.
Mechanism B — MSYS rewrites an argument as a colon-separated PATH list
This is what the lane was trying to work around when it reached for the export. Distinct mechanism, same family, and conditional, which is what makes it get misdiagnosed as a bad revision rather than a shell bug.
$ git show origin/main:.github/workflows/ci.yml
fatal: ambiguous argument 'origin\main;.github\workflows\ci.yml': unknown revision or path not in the working tree.
$ MSYS_NO_PATHCONV=1 git show origin/main:.github/workflows/ci.yml
name: ci
: became ;, / became \. In the same shell, git show origin/main:scripts/check-drive-root-litter.sh did not mangle. The condition was derived empirically, by probing cmd //c echo "<arg>":
| argument |
result |
converted? |
origin/main:.github/workflows/ci.yml |
origin\main;.github\workflows\ci.yml |
yes |
origin/main:.claude/settings.json |
origin\main;.claude\settings.json |
yes |
origin/main:.a |
origin\main;.a |
yes |
origin/main:.github |
origin\main;.github |
yes |
origin/main:scripts/foo.sh |
unchanged |
no |
origin/main:docs/foo.md |
unchanged |
no |
origin/main:./scripts/foo.sh |
unchanged |
no |
origin/main:../x |
unchanged |
no |
HEAD:.github/x |
unchanged |
no |
aaa:.bbb |
unchanged |
no |
/aaa:/bbb |
"C:\Program Files\Git\aaa;C:\Program Files\Git\bbb" |
yes (root-prefixed) |
Empirical rule: A:B is treated as a PATH list when A contains a / and B begins with a dot-name (. followed by a character that is not / or .). HEAD:.github/x is safe; origin/main:.github/x is not. Since .github/, .claude/, and .chezmoi* are exactly the directories this repo's agents read most, the exposure is not marginal.
Mechanism B fails loudly and creates no litter. It is a convention item, not a candidate for a blocking control. It matters here because the workaround for B is what caused A.
The gap, precisely
The convention is documented, the helper is shipped, the detector is unit-tested, and there is no preventive control at the point of use. Two guards sit adjacent to this class and neither fired:
1. plugins/guardrails/hooks/block-windows-drive-tmp.sh — PreToolUse on Bash|PowerShell. Same platform, same failure mode, same intervention point, and it explicitly recognises the MSYS drive form: has_drive_root_tmp matches /tmp, /<drive>/tmp, and <drive>:/tmp. But every branch requires the literal path component tmp, and it keys on write-shaped path arguments — neither of which is present in this incident. It is structurally blind both to the general /<letter>/... shape and to the environment-variable trigger. Its /tmp coverage does not generalise.
2. plugins/source-control/hooks/worktree-add-containment-gate.sh — PreToolUse specifically on git worktree add. It did not fire for two independent reasons, both by design: its matcher is Bash only and its header declares the PowerShell tool out of scope; and even on Bash it blocks only targets landing inside a git repository, while D:\d\worktrees\ccp-measure2 is outside every checkout and so passes silently — correctly, under #2611's rule that a non-nesting target must not draw an advisory.
The detector, by contrast, works — and that is now the whole argument. A bare invocation of scripts/check-drive-root-litter.sh from origin/main (measured by a separate lane with its own blind verifier; both agreed) finds today's residue and nothing else:
check-drive-root-litter.sh: drive-root litter found (1):
/d/d (D:\d\)
EXIT=1
Sole machine-wide hit, correctly — C:\ has no single-letter directories and only C and D are mounted. Unit suite 14/14, exit 0. Its own fixture litter/d/d is byte-for-byte the shape of the real residue. A bare no-argument invocation is the live host scan; DRIVE_ROOT_LITTER_MOUNT_ROOT defaults to / and the override is never auto-detected.
So ADR-0003's recorded decision is narrower than "the live scan does not exist": the capability ships and is an operator command today; what it is not is a required CI lane (ci.yml: "Pointing the live scan at a runner's drive roots is deliberately NOT wired: it would put an unquantified false-positive tail on the required aggregate, which docs/adr/0003 rules out until a guard has measured precision"). That decision is respected here and this issue does not propose flipping it.
Which sharpens the framing considerably. This is not "the convention shipped as documentation". The detection layer exists, is verified against real residue, and is structurally incapable of firing before the damage. The prevention layer is absent. In this incident the detector was never run; the litter was found by a human noticing a worktree path.
One known limit worth a sentence: the detector's inner loop iterates only mounted drive letters as candidate names, so it structurally cannot see multi-character drive-root litter. Catching that would need an allowlist-based check with a different false-positive budget — noted as a boundary, not proposed as work.
A shipped defect in the detector's own remediation advice
Verified directly against the current origin/main text. The footer ends:
* remove the phantom tree once you have confirmed it holds nothing else.
git grep over the whole script for worktree / deregist returns no mention. But today's single real hit, D:\d\worktrees\ccp-measure2, is a live registered git worktree — its .git file reads gitdir: D:/repos/.../.git/worktrees/ccp-measure2 and it is present in git worktree list. Following the advice with an rm would leave a dangling registry entry in the parent repo, and the obvious follow-up (git worktree prune) is itself hazardous on a machine currently carrying 69 registered worktrees across live lanes.
The advice is correct for the case the script was written against (a phantom tree written by a Python make_archive call) and unsafe for the case it actually found first. The fix is small: have the remediation text check for a registered worktree and deregister before removing. Included in the PR for this issue, since scripts/check-drive-root-litter.sh is unowned by any other lane.
Measured false-positive data
ADR-0003 requires the number. Corpus: 16,919 tool_input.command strings extracted from 701 local Claude Code transcript JSONL files (388 MB) under ~/.claude/projects/, split by tool, plus 710 lines of PowerShell ConsoleHost_history.txt.
The path-shape matcher — measured and rejected
(^|[^[:alnum:]_./\-])/[a-z]/, the matcher the obvious proposal reaches for:
| corpus |
n |
hits |
firing rate |
| Bash tool |
14,234 |
6,506 |
45.7 % |
| PowerShell tool |
2,685 |
16 |
0.60 % |
| PowerShell console history |
710 |
2 |
0.28 % |
Even narrowed to git worktree add commands specifically, 111 of 137 carry an /x/-form path — an 81 % firing rate on the one subcommand, essentially all of it safe, because in an ordinary Bash shell MSYS translates the argument correctly.
So the path-shape matcher is over-broad (45.7 % of Bash commands; /dev/, /c/Users/..., regex character classes, URLs, sed expressions) and under-broad (it misses this incident, whose path argument is identical to the one that worked). Both halves disqualify it.
The export-form matcher — the one that fits the defect
export of MSYS_NO_PATHCONV or MSYS2_ARG_CONV_EXCL, on the Bash surface, Windows hosts only:
| measure |
count |
rate |
| Bash commands in corpus |
14,234 |
— |
| export-form of a conversion suppressor (would fire) |
46 |
0.32 % |
| ...of those, also carrying an MSYS-form path in the same command (realized risk, not just latent) |
19 |
0.13 % |
| per-command-prefix form — the safe idiom, correctly not fired on |
193 |
— |
| PowerShell tool / console history hits |
0 / 0 |
0 % |
Two things make this a good matcher rather than a lucky one. It separates the dangerous idiom from the safe one 4:1 in the corpus (46 blocked vs 193 left alone) on a distinction that is not heuristic — the four-way probe above proves the exported form leaks and the prefix form does not. And the hits are not synthetic: they span four distinct lanes (ccp-measure2, ccp-silent-revert-calib, ccp-silent-revert-fixture, ccp-verify-2843) and two repositories, including the lane that produced this incident.
Precision claim, stated exactly. Every one of the 46 is a genuine instance of the anti-pattern: the exported form always has unbounded blast radius over the rest of the command string, and the remedy (per-command prefix, or a Windows-native path) is always available and always correct. So precision against "is this the anti-pattern" is 46/46. Nineteen additionally carried an MSYS-form path in the same command, i.e. realized rather than merely latent risk. What this measurement cannot claim is a per-fire count of "would have produced litter" — that depends on which later command consumed a path, which is not statically decidable. The honest form of the claim is: 0.32 % firing rate, a remedy that is one keystroke, and a corpus in which the safe idiom is used 4× more often and never fires.
Two further honesty notes. The corpus is one machine's — the deployment surface, but not a fleet. And it now contains today's investigation; the reproduction commands are in it. They do not inflate the export-form counts (the reproductions used the per-command-prefix form and are among the 193 correctly not fired on), but they do inflate any count keyed on a bare mention of the variables, which is why no such count is used above.
Options, with costs — not a settled proposal
Option choice is partly a machine-config decision, so this presents the trade rather than picking for you.
(a) PreToolUse hook keyed on a /[a-z]/ path shape in Bash commands. — Measured and rejected. 45.7 % firing rate, and a false negative on the actual incident. This is the option a reasonable person proposes first, and the data kills it twice over.
(b) PreToolUse hook keyed on the export form of a conversion suppressor, Bash matcher, Windows hosts only. 0.32 % firing, 4:1 separation from the safe idiom, catches the real trigger. Cost: one more hook in the guardrails PreToolUse chain (latency; one more thing that can fail closed); it blocks a form some authors reach for deliberately when a command has many <rev>:<path> arguments, though the per-command prefix serves that case; and it must declare its residual gaps rather than hide them — a suppressor exported by a script the command invokes, declare -x/set -a spellings, and any exposed spawner outside the Bash tool (CI runners, subprocess) are all invisible to it.
(c) Extend block-windows-drive-tmp.sh in place. Cheapest diff, most literal reading of reuse-or-replace. Cost: it is a path-shape and write-target guard; this is an environment-variable guard on a different surface with a different exclusion set. Folding them together makes both harder to reason about and risks regressing a guard that currently works. A sibling hook with reciprocal header cross-references is the better reading — not a silent second way, an openly-scoped second way.
(d) Extend check-drive-root-litter.sh into an opt-in live scan invoked by a session hook. Cost: post-hoc. It reports litter after a run has already measured the wrong thing, which #2834 established is the expensive half of the damage, and it cannot see a phantom path written and then removed. Worth doing as well, never instead; and per ADR-0003 it stays advisory, not required.
(e) Convention / instruction change only. Zero implementation cost. Cost: this issue exists because that was tried and the artifact returned 24 hours later, produced by a lane that was not writing a harness at all.
(f) A scripts/check-shell-portability.sh lint class for the exported-suppressor idiom in tracked scripts — complementary, catching it at authoring time where a hook cannot see. Deliberately not in this issue's PR: a parallel lane currently owns that dispatcher (adding a class for #2840). Routing it as a follow-up avoids two lanes editing one file.
Recommendation: (b), with (c) as reciprocal cross-reference, and (d) and (f) as follow-ups. Default-on is claimed under ADR-0003's measured-precision rule on the numbers above, not under the seeded-defect exemption — the 46 fires are unseeded, real, and span four lanes.
No machine-scope (~/.claude) change is required or proposed. guardrails is already enabled in this repo's .claude/settings.json, so a hook added to the plugin is live with no edit under ~. Turning it off is the plugin's own /plugin configure kill switch, not a settings edit. Nothing here should be applied to ~/.claude by an agent — paths under ~ may be chezmoi-managed, and source→disk apply is a deliberate human action.
Related
Deliberately not touched
D:\d\worktrees\ccp-measure2 is a live registered worktree in active use by another lane; its cleanup is assigned elsewhere. Nothing under D:\d was modified, moved, or removed during this investigation, and git worktree prune was not run. Every reproduction artifact was confined to subst-mapped scratch subdirectories and removed, with removal verified by listing.
Summary
D:\d— the exact stray artifactdocs/conventions/windows-path-emit/README.mdexists to eliminate — was created for the third time today, at 2026-08-16 16:46:30 EDT, by an ordinary agent lane doing ordinary work. Not a sabotage fixture, not an external tool.2841 landed the convention, the helper (
scripts/emit-windows-path.sh), and the detector (scripts/check-drive-root-litter.sh). All three are sound. None is a preventive control at the point of use. This recurrence is the evidence that documentation plus an advisory post-hoc detector does not holdAnd the trigger turns out not to be the path form. That is the most important thing in this issue, so it leads.
The trigger is an exported environment variable, not a path literal
The creating lane ran, in one command string:
The same lane had already run the identical
/d/worktrees/...argument form and it landed correctly atD:/worktrees/ccp-measure. The only difference isexport MSYS_NO_PATHCONV=1, set earlier in the same command string — and set for an unrelated reason: to stop MSYS mangling a<rev>:<path>argument containing a colon (mechanism B below). That export then silently suppressed translation of a path argument in a later, unrelated command in the same shell.A guard that greps for
/d/-style path literals would not have caught this. The offending command is textually identical in its path argument to the one that worked. Any guard keyed purely on path form has a false negative on the actual incident — which is the same false-green shape this entire body of work exists to remove.The discriminator, proven four ways in one measurement
git rev-parse --sq-quote <path>prints exactly whatgit.exereceived in argv, and creates nothing:So the predicate is exact and narrow:
exportofMSYS_NO_PATHCONVorMSYS2_ARG_CONV_EXCL. The bare assignment does nothing (the MSYS runtime reads the environment, and an unexported shell variable is not in it). The per-command prefix does exactly what the author intended and nothing more. Only the exported form makes the blast radius the rest of the command string — and, in this incident, agit worktree addseven segments later.Correction worth recording: advising
MSYS_NO_PATHCONV=1for<rev>:<path>git arguments is only safe as a per-command prefix. Asexportit is actively dangerous. The better answer avoids the question: use Windows-native paths (D:/...) for every path argument.The recurrence, re-verified before filing
git worktree list --porcelaininD:/repos/github.com/melodic-software/claude-code-plugins:D:\d\worktrees\ccp-measure2\.git:A genuine registered linked worktree, not loose litter. Read-only
stat— nothing underD:\dwas modified, moved, or removed; that cleanup is assigned to another lane:mtime == ctimeonD:\dandD:\d\worktreesmeans the whole phantom chain was created in that one call.Recurrence count: three, on three dates
/disk-hygiene:clean --execute D:run_vfy/sabotage.shharness — an MSYS/d/...literal handed to a Windows-native PythonRows 1 and 2 are quoted from #2834's evidence rather than re-derived, and labelled as such. Row 3 is what changes the argument: the first two are readable as harness-authoring hygiene. The third is the normal path.
Mechanism A — Win32 resolves a leading
/against the current driveThe underlying rule, once translation is suppressed. Reproduced by execution twice, independently — a second agent re-derived it from scratch with no sight of the first's reasoning. Nothing was created at any real drive root:
substmapped throwaway virtual drives onto scratch subdirectories, and every mapping and directory was removed with removal verified by listing.The exact incident command shape. PowerShell, throwaway repo at
P:\repo(P:=substonto a scratch dir):produced
P:\d\worktrees\ccp-reproA, registered ingit worktree list --porcelain. Same shape asD:\d\worktrees\ccp-measure2. No PreToolUse hook blocked it.Drive-dependence — the same argument, two different absolute paths:
Byte-identical argument, two different absolute paths. It is drive-root relative, not cwd-relative: the independent verifier ran
git worktree add /y/probewith cwdX:\repoand gotX:\y\probe, notX:\repo\y\probe.The exposed surface is any spawner without MSYS argv rewriting — the PowerShell tool,
cmd, CI runners, Node/Pythonsubprocess, agent harnesses — or a Bash shell where rewriting has been switched off, which is what happened here.Footnote on one measurement that looks contradictory and is not:
[System.IO.Path]::GetFullPath('/d/worktrees/x')returnsC:\d\...from a PowerShell session whoseSet-LocationisP:\, becauseSet-Locationmoves the PowerShell provider location without moving the process's Win32 current directory thatSystem.IO.Pathreads. The verifier controlled for it via[Environment]::CurrentDirectorypluscmd /c cdand gotX:\d\.../Y:\d\.... Native children such asgit.exeinherit the provider location, which is why thegitlegs show drive-dependence directly.One further measured hazard in the same family: a non-drive absolute POSIX path handed to a native program gets the MSYS installation root prefixed —
cmd //c echo /docs/foo→C:/Program Files/Git/docs/foo.Mechanism B — MSYS rewrites an argument as a colon-separated PATH list
This is what the lane was trying to work around when it reached for the export. Distinct mechanism, same family, and conditional, which is what makes it get misdiagnosed as a bad revision rather than a shell bug.
:became;,/became\. In the same shell,git show origin/main:scripts/check-drive-root-litter.shdid not mangle. The condition was derived empirically, by probingcmd //c echo "<arg>":origin/main:.github/workflows/ci.ymlorigin\main;.github\workflows\ci.ymlorigin/main:.claude/settings.jsonorigin\main;.claude\settings.jsonorigin/main:.aorigin\main;.aorigin/main:.githuborigin\main;.githuborigin/main:scripts/foo.shorigin/main:docs/foo.mdorigin/main:./scripts/foo.shorigin/main:../xHEAD:.github/xaaa:.bbb/aaa:/bbb"C:\Program Files\Git\aaa;C:\Program Files\Git\bbb"Empirical rule:
A:Bis treated as a PATH list whenAcontains a/andBbegins with a dot-name (.followed by a character that is not/or.).HEAD:.github/xis safe;origin/main:.github/xis not. Since.github/,.claude/, and.chezmoi*are exactly the directories this repo's agents read most, the exposure is not marginal.Mechanism B fails loudly and creates no litter. It is a convention item, not a candidate for a blocking control. It matters here because the workaround for B is what caused A.
The gap, precisely
The convention is documented, the helper is shipped, the detector is unit-tested, and there is no preventive control at the point of use. Two guards sit adjacent to this class and neither fired:
1.
plugins/guardrails/hooks/block-windows-drive-tmp.sh— PreToolUse onBash|PowerShell. Same platform, same failure mode, same intervention point, and it explicitly recognises the MSYS drive form:has_drive_root_tmpmatches/tmp,/<drive>/tmp, and<drive>:/tmp. But every branch requires the literal path componenttmp, and it keys on write-shaped path arguments — neither of which is present in this incident. It is structurally blind both to the general/<letter>/...shape and to the environment-variable trigger. Its/tmpcoverage does not generalise.2.
plugins/source-control/hooks/worktree-add-containment-gate.sh— PreToolUse specifically ongit worktree add. It did not fire for two independent reasons, both by design: its matcher isBashonly and its header declares the PowerShell tool out of scope; and even on Bash it blocks only targets landing inside a git repository, whileD:\d\worktrees\ccp-measure2is outside every checkout and so passes silently — correctly, under #2611's rule that a non-nesting target must not draw an advisory.The detector, by contrast, works — and that is now the whole argument. A bare invocation of
scripts/check-drive-root-litter.shfromorigin/main(measured by a separate lane with its own blind verifier; both agreed) finds today's residue and nothing else:Sole machine-wide hit, correctly —
C:\has no single-letter directories and onlyCandDare mounted. Unit suite 14/14, exit 0. Its own fixturelitter/d/dis byte-for-byte the shape of the real residue. A bare no-argument invocation is the live host scan;DRIVE_ROOT_LITTER_MOUNT_ROOTdefaults to/and the override is never auto-detected.So ADR-0003's recorded decision is narrower than "the live scan does not exist": the capability ships and is an operator command today; what it is not is a required CI lane (
ci.yml: "Pointing the live scan at a runner's drive roots is deliberately NOT wired: it would put an unquantified false-positive tail on the required aggregate, which docs/adr/0003 rules out until a guard has measured precision"). That decision is respected here and this issue does not propose flipping it.Which sharpens the framing considerably. This is not "the convention shipped as documentation". The detection layer exists, is verified against real residue, and is structurally incapable of firing before the damage. The prevention layer is absent. In this incident the detector was never run; the litter was found by a human noticing a worktree path.
One known limit worth a sentence: the detector's inner loop iterates only mounted drive letters as candidate names, so it structurally cannot see multi-character drive-root litter. Catching that would need an allowlist-based check with a different false-positive budget — noted as a boundary, not proposed as work.
A shipped defect in the detector's own remediation advice
Verified directly against the current
origin/maintext. The footer ends:git grepover the whole script forworktree/deregistreturns no mention. But today's single real hit,D:\d\worktrees\ccp-measure2, is a live registered git worktree — its.gitfile readsgitdir: D:/repos/.../.git/worktrees/ccp-measure2and it is present ingit worktree list. Following the advice with anrmwould leave a dangling registry entry in the parent repo, and the obvious follow-up (git worktree prune) is itself hazardous on a machine currently carrying 69 registered worktrees across live lanes.The advice is correct for the case the script was written against (a phantom tree written by a Python
make_archivecall) and unsafe for the case it actually found first. The fix is small: have the remediation text check for a registered worktree and deregister before removing. Included in the PR for this issue, sincescripts/check-drive-root-litter.shis unowned by any other lane.Measured false-positive data
ADR-0003 requires the number. Corpus: 16,919
tool_input.commandstrings extracted from 701 local Claude Code transcript JSONL files (388 MB) under~/.claude/projects/, split by tool, plus 710 lines of PowerShellConsoleHost_history.txt.The path-shape matcher — measured and rejected
(^|[^[:alnum:]_./\-])/[a-z]/, the matcher the obvious proposal reaches for:Even narrowed to
git worktree addcommands specifically, 111 of 137 carry an/x/-form path — an 81 % firing rate on the one subcommand, essentially all of it safe, because in an ordinary Bash shell MSYS translates the argument correctly.So the path-shape matcher is over-broad (45.7 % of Bash commands;
/dev/,/c/Users/..., regex character classes, URLs, sed expressions) and under-broad (it misses this incident, whose path argument is identical to the one that worked). Both halves disqualify it.The export-form matcher — the one that fits the defect
exportofMSYS_NO_PATHCONVorMSYS2_ARG_CONV_EXCL, on the Bash surface, Windows hosts only:Two things make this a good matcher rather than a lucky one. It separates the dangerous idiom from the safe one 4:1 in the corpus (46 blocked vs 193 left alone) on a distinction that is not heuristic — the four-way probe above proves the exported form leaks and the prefix form does not. And the hits are not synthetic: they span four distinct lanes (
ccp-measure2,ccp-silent-revert-calib,ccp-silent-revert-fixture,ccp-verify-2843) and two repositories, including the lane that produced this incident.Precision claim, stated exactly. Every one of the 46 is a genuine instance of the anti-pattern: the exported form always has unbounded blast radius over the rest of the command string, and the remedy (per-command prefix, or a Windows-native path) is always available and always correct. So precision against "is this the anti-pattern" is 46/46. Nineteen additionally carried an MSYS-form path in the same command, i.e. realized rather than merely latent risk. What this measurement cannot claim is a per-fire count of "would have produced litter" — that depends on which later command consumed a path, which is not statically decidable. The honest form of the claim is: 0.32 % firing rate, a remedy that is one keystroke, and a corpus in which the safe idiom is used 4× more often and never fires.
Two further honesty notes. The corpus is one machine's — the deployment surface, but not a fleet. And it now contains today's investigation; the reproduction commands are in it. They do not inflate the export-form counts (the reproductions used the per-command-prefix form and are among the 193 correctly not fired on), but they do inflate any count keyed on a bare mention of the variables, which is why no such count is used above.
Options, with costs — not a settled proposal
Option choice is partly a machine-config decision, so this presents the trade rather than picking for you.
(a) PreToolUse hook keyed on a
/[a-z]/path shape in Bash commands. — Measured and rejected. 45.7 % firing rate, and a false negative on the actual incident. This is the option a reasonable person proposes first, and the data kills it twice over.(b) PreToolUse hook keyed on the
exportform of a conversion suppressor, Bash matcher, Windows hosts only. 0.32 % firing, 4:1 separation from the safe idiom, catches the real trigger. Cost: one more hook in theguardrailsPreToolUse chain (latency; one more thing that can fail closed); it blocks a form some authors reach for deliberately when a command has many<rev>:<path>arguments, though the per-command prefix serves that case; and it must declare its residual gaps rather than hide them — a suppressor exported by a script the command invokes,declare -x/set -aspellings, and any exposed spawner outside the Bash tool (CI runners,subprocess) are all invisible to it.(c) Extend
block-windows-drive-tmp.shin place. Cheapest diff, most literal reading of reuse-or-replace. Cost: it is a path-shape and write-target guard; this is an environment-variable guard on a different surface with a different exclusion set. Folding them together makes both harder to reason about and risks regressing a guard that currently works. A sibling hook with reciprocal header cross-references is the better reading — not a silent second way, an openly-scoped second way.(d) Extend
check-drive-root-litter.shinto an opt-in live scan invoked by a session hook. Cost: post-hoc. It reports litter after a run has already measured the wrong thing, which #2834 established is the expensive half of the damage, and it cannot see a phantom path written and then removed. Worth doing as well, never instead; and per ADR-0003 it stays advisory, not required.(e) Convention / instruction change only. Zero implementation cost. Cost: this issue exists because that was tried and the artifact returned 24 hours later, produced by a lane that was not writing a harness at all.
(f) A
scripts/check-shell-portability.shlint class for the exported-suppressor idiom in tracked scripts — complementary, catching it at authoring time where a hook cannot see. Deliberately not in this issue's PR: a parallel lane currently owns that dispatcher (adding a class for #2840). Routing it as a follow-up avoids two lanes editing one file.Recommendation: (b), with (c) as reciprocal cross-reference, and (d) and (f) as follow-ups. Default-on is claimed under ADR-0003's measured-precision rule on the numbers above, not under the seeded-defect exemption — the 46 fires are unseeded, real, and span four lanes.
No machine-scope (
~/.claude) change is required or proposed.guardrailsis already enabled in this repo's.claude/settings.json, so a hook added to the plugin is live with no edit under~. Turning it off is the plugin's own/plugin configurekill switch, not a settings edit. Nothing here should be applied to~/.claudeby an agent — paths under~may be chezmoi-managed, and source→disk apply is a deliberate human action.Related
C:\tmplitter; producedblock-windows-drive-tmp.sh, the closest prior art.worktree-add-containment-gate.sh, the second guard that did not fire, and the source of the "no generic advisory on everygit worktree add" rule this proposal honours.docs/conventions/windows-path-emit/README.md— the convention this recurrence tests.docs/adr/0003-verification-guards-earn-default-on-by-measured-precision.md— the doctrine the measurements are reported against.Deliberately not touched
D:\d\worktrees\ccp-measure2is a live registered worktree in active use by another lane; its cleanup is assigned elsewhere. Nothing underD:\dwas modified, moved, or removed during this investigation, andgit worktree prunewas not run. Every reproduction artifact was confined tosubst-mapped scratch subdirectories and removed, with removal verified by listing.