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. Sibling of #416, same loop.
What happens
provision_worktree's BASE_SKILLS copy loop calls _copy_traversable(src, dst) with no IO error handling. A repo-side skill dir the process cannot read raises out of provisioning, part-way through the loop.
Measured
Repo skill dir chmod 000 on bmad-review-edge-case-hunter, everything else normal:
provision_worktree -> RAISED PermissionError: [Errno 13] Permission denied:
.../repo/.claude/skills/bmad-review-edge-case-hunter
worktree tree after the raise -> [bmad-build-auto, bmad-dev-auto, bmad-loop-resolve,
bmad-loop-setup, bmad-loop-sweep, bmad-review-adversarial-general,
bmad-review-edge-case-hunter]
BASE_SKILLS expected -> [bmad-build-auto, bmad-dev-auto, bmad-review,
bmad-review-adversarial-general, bmad-review-edge-case-hunter,
bmad-review-verification-gap]
Two distinct problems in that output:
- The loop aborts, so
bmad-review and bmad-review-verification-gap — later in iteration order, nothing wrong with either — are never copied.
- The victim is left half-copied.
bmad-review-edge-case-hunter exists in the worktree as a directory whose contents are incomplete. Every seed check in this area asks is_dir(), so a partial skill reads as present: base_skills_seed_incomplete cannot report it, and the run-start preflight already passed against the repo. That is the shape the renderer-scripts sentinel exists to catch for _bmad/scripts/ — a partial unit being worse than an absent one.
Why it matters
Same as #416: this escapes the worktree-seed-skipped channel entirely, so the engine never gets to choose escalate-vs-defer, and it is an environment fault — the seed reads the same repo for every story, so no repair session fixes it and no later story escapes it.
Unreadable skill trees are not exotic: a shared machine-wide BMad install owned by another user, or a root-owned install read by an unprivileged run, produces exactly this.
Shape of a fix
Decide the doctrine deliberately — this is the "observation degrades, repair raises" question. If provisioning is repair, the raise wants to be a typed fault the engine can journal and pause on rather than a bare OSError through the state machine; if it degrades, the per-skill failure wants to land in the skipped-seed channel and let the existing gate name it. Either way the half-copied destination should not be left where an is_dir() probe reads it as complete.
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. Sibling of #416, same loop.What happens
provision_worktree'sBASE_SKILLScopy loop calls_copy_traversable(src, dst)with no IO error handling. A repo-side skill dir the process cannot read raises out of provisioning, part-way through the loop.Measured
Repo skill dir
chmod 000onbmad-review-edge-case-hunter, everything else normal:Two distinct problems in that output:
bmad-reviewandbmad-review-verification-gap— later in iteration order, nothing wrong with either — are never copied.bmad-review-edge-case-hunterexists in the worktree as a directory whose contents are incomplete. Every seed check in this area asksis_dir(), so a partial skill reads as present:base_skills_seed_incompletecannot report it, and the run-start preflight already passed against the repo. That is the shape the renderer-scripts sentinel exists to catch for_bmad/scripts/— a partial unit being worse than an absent one.Why it matters
Same as #416: this escapes the
worktree-seed-skippedchannel entirely, so the engine never gets to choose escalate-vs-defer, and it is an environment fault — the seed reads the same repo for every story, so no repair session fixes it and no later story escapes it.Unreadable skill trees are not exotic: a shared machine-wide BMad install owned by another user, or a root-owned install read by an unprivileged run, produces exactly this.
Shape of a fix
Decide the doctrine deliberately — this is the "observation degrades, repair raises" question. If provisioning is repair, the raise wants to be a typed fault the engine can journal and pause on rather than a bare
OSErrorthrough the state machine; if it degrades, the per-skill failure wants to land in the skipped-seed channel and let the existing gate name it. Either way the half-copied destination should not be left where anis_dir()probe reads it as complete.