Found while reviewing PR #406 (the skills seed gate). Not a regression from that PR — the copy loop is byte-identical on release/0.9.x.
What happens
provision_worktree's BASE_SKILLS copy loop (src/bmad_loop/install.py) skips a destination that already exists:
for skill in BASE_SKILLS:
dst = tree_dir / skill
if dst.exists():
continue
src = (repo_root / tree / skill).resolve()
if not src.is_relative_to(repo_root) or not src.is_dir():
continue
_copy_traversable(src, dst)
Path.exists() follows symlinks, so a dangling symlink answers False — but the path is still occupied, and the copy then raises.
Measured
Worktree carries a tracked dangling symlink at <tree>/bmad-review; the repo has a real dir there:
dst.exists() on dangling symlink -> False | is_symlink -> True
provision_worktree -> RAISED FileExistsError: [Errno 17] File exists: .../wt/.claude/skills/bmad-review
Why it matters
Provisioning has no failure vocabulary of its own — every containment violation in that loop is a bare continue, and the two seed-shortfall conditions report through the worktree-seed-skipped channel so the engine can decide escalate-vs-defer. An unhandled FileExistsError bypasses all of it and takes the run out through a path the state machine never sees.
The trigger is ordinary: a committed symlink whose target is only present on the machine that authored it (a shared BMad install that moved) is dangling in every other checkout, and git stores and checks out the dangling link happily.
Shape of a fix
Test dst.is_symlink() or dst.exists() rather than dst.exists() alone, and decide deliberately which side of the skip a dangling link belongs on. Note the same .exists() reasoning is used for MODULE_SKILLS one loop above.
Related: the seed-shortfall reporting in that loop is base_skills_seed_incomplete in the same file.
Found while reviewing PR #406 (the skills seed gate). Not a regression from that PR — the copy loop is byte-identical on
release/0.9.x.What happens
provision_worktree'sBASE_SKILLScopy loop (src/bmad_loop/install.py) skips a destination that already exists:Path.exists()follows symlinks, so a dangling symlink answersFalse— but the path is still occupied, and the copy then raises.Measured
Worktree carries a tracked dangling symlink at
<tree>/bmad-review; the repo has a real dir there:Why it matters
Provisioning has no failure vocabulary of its own — every containment violation in that loop is a bare
continue, and the two seed-shortfall conditions report through theworktree-seed-skippedchannel so the engine can decide escalate-vs-defer. An unhandledFileExistsErrorbypasses all of it and takes the run out through a path the state machine never sees.The trigger is ordinary: a committed symlink whose target is only present on the machine that authored it (a shared BMad install that moved) is dangling in every other checkout, and git stores and checks out the dangling link happily.
Shape of a fix
Test
dst.is_symlink() or dst.exists()rather thandst.exists()alone, and decide deliberately which side of the skip a dangling link belongs on. Note the same.exists()reasoning is used forMODULE_SKILLSone loop above.Related: the seed-shortfall reporting in that loop is
base_skills_seed_incompletein the same file.