Found while closing out PR #406. That PR made this fault reported (#415's worktree_seed_undelivered gate, extended by baa2bac's config_paths arm); it did not make it refused. Filing the refusal half separately rather than leaving it in a phase log.
What
Every write in provision_worktree is guarded — the skills merge asks _occupied and a containment test, both explicit seed loops split raw from dst and refuse to write through a link — except the per-CLI hook-registration loop (install.py:2128-2147):
for profile in profiles:
if profile.hookless:
continue
config_path = worktree / profile.hooks.config_path
config_path.parent.mkdir(parents=True, exist_ok=True)
config: dict = {}
if config_path.is_file():
...
config, changed = merge_hooks(config, registrations, profile.hooks.dialect)
if changed:
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
No containment guard, no _occupied, no is_symlink. If the checkout carries .claude/settings.json (or .gemini/settings.json, .github/copilot/settings.json, .agents/hooks.json) as a symlink pointing outside the worktree — a very ordinary dotfiles arrangement — then:
config_path.is_file() follows the link and reads the outside file, so the merge starts from a config the worktree does not own; and
write_text follows it too, so the hook registrations are written into the user's real dotfile, outside the worktree, rather than into the isolated checkout.
A dangling link is the other half: is_file() is False, and the write then lands at the link's target.
Why it matters
The registrations baked in are worktree-specific — they name an absolute relay path (repo_root / HOOK_SCRIPT_REL) for this run. Writing them through a link mutates shared user config with per-run content, and the worktree that was supposed to receive them does not have them, so the Stop hook never fires for that unit.
worktree_seed_undelivered's config_paths arm now reports the three shapes (source escapes the repo, destination is a symlink, destination escapes the worktree) through worktree-seed-dropped — so the operator finds out. Nothing stops the write.
Shape of a fix
Give this loop the same treatment the seed loops got in baa2bac: split raw = worktree / profile.hooks.config_path from dst = raw.resolve(), and refuse when dst != raw (or when dst escapes the worktree). The decision worth making deliberately is whether a refusal here should be silent-and-reported, like a dropped seed, or should escalate — a worktree with no Stop hook does not fail loudly, it just never reports completion, which is the failure mode the completion-signal invariant exists to prevent.
Note _worktree_local_exclude also writes without these guards (install.py:1314/:1321); that one targets the git common dir by design and is deliberately out of scope.
Found while closing out PR #406. That PR made this fault reported (#415's
worktree_seed_undeliveredgate, extended bybaa2bac'sconfig_pathsarm); it did not make it refused. Filing the refusal half separately rather than leaving it in a phase log.What
Every write in
provision_worktreeis guarded — the skills merge asks_occupiedand a containment test, both explicit seed loops splitrawfromdstand refuse to write through a link — except the per-CLI hook-registration loop (install.py:2128-2147):No containment guard, no
_occupied, nois_symlink. If the checkout carries.claude/settings.json(or.gemini/settings.json,.github/copilot/settings.json,.agents/hooks.json) as a symlink pointing outside the worktree — a very ordinary dotfiles arrangement — then:config_path.is_file()follows the link and reads the outside file, so the merge starts from a config the worktree does not own; andwrite_textfollows it too, so the hook registrations are written into the user's real dotfile, outside the worktree, rather than into the isolated checkout.A dangling link is the other half:
is_file()is False, and the write then lands at the link's target.Why it matters
The registrations baked in are worktree-specific — they name an absolute relay path (
repo_root / HOOK_SCRIPT_REL) for this run. Writing them through a link mutates shared user config with per-run content, and the worktree that was supposed to receive them does not have them, so the Stop hook never fires for that unit.worktree_seed_undelivered'sconfig_pathsarm now reports the three shapes (source escapes the repo, destination is a symlink, destination escapes the worktree) throughworktree-seed-dropped— so the operator finds out. Nothing stops the write.Shape of a fix
Give this loop the same treatment the seed loops got in
baa2bac: splitraw = worktree / profile.hooks.config_pathfromdst = raw.resolve(), and refuse whendst != raw(or whendstescapes the worktree). The decision worth making deliberately is whether a refusal here should be silent-and-reported, like a dropped seed, or should escalate — a worktree with no Stop hook does not fail loudly, it just never reports completion, which is the failure mode the completion-signal invariant exists to prevent.Note
_worktree_local_excludealso writes without these guards (install.py:1314/:1321); that one targets the git common dir by design and is deliberately out of scope.