Description
A declarative plugin hook bound to post_story cannot run: plugins/bus.py launches it with the story worktree as its working directory, and by that point the worktree has already been deleted by unit-merged. subprocess.run fails with FileNotFoundError on the cwd= argument before the command's first byte executes.
# plugins/bus.py, _dispatch_declarative
cwd = ctx.worktree or ctx.repo_root or None
rc, output = self._runner(cmd, cwd=cwd, env=env, timeout=hook.timeout_sec)
ctx.worktree is truthy — it is a path string — so the or ctx.repo_root fallback never engages. The path just no longer exists on disk.
Since #383 this surfaces as a journalled plugin-hook-error rather than a crashed run, which is a real improvement. But the consequence is a hook that is structurally unable to fire at the one stage documented for post-review work, and the run still reports success. For a measurement plugin the result is silent data loss: the run says 1 done, and the story's closing metrics were never written.
The plugin cannot defend against this. cwd is applied by the kernel at fork/exec, before any shell exists, so no command form survives (verified below). Declaring a cwd key in [hooks.post_story] does not help either — plugins/manifest.py reads only cmd, timeout_sec, blocking and fail_closed, so the key is ignored in silence and looks like a fix while changing nothing.
Not a duplicate of #383 (strict decode of hook output, same function, different failure) or #343 (OSError untranslated in verify._run_git). Same genus, different call site — filing separately per the precedent #383 itself set.
Steps to reproduce
- Install a declarative plugin with a
[hooks.post_story] cmd and enable it in policy.toml.
- Run any story under
[scm] isolation = "worktree".
- Let the story reach
story-done and merge normally.
- Read the journal:
unit-merged lands, then plugin-hook-error for that plugin.
Mechanism, isolated from bmad-loop — a removed cwd defeats every workaround a plugin author could write:
import subprocess, tempfile, shutil
d = tempfile.mkdtemp(); shutil.rmtree(d) # the worktree, after unit-merged
for label, cmd in [
("cd $HOME && echo", 'cd "$HOME" && echo ok'),
("absolute path", '/bin/echo ok'),
("env -C", 'env -C "$HOME" echo ok'),
]:
try:
r = subprocess.run(cmd, shell=True, cwd=d, capture_output=True, text=True, timeout=10)
print(f"{label:20} -> rc={r.returncode} {r.stdout.strip()}")
except OSError as e:
print(f"{label:20} -> {type(e).__name__} errno={e.errno} (dies before the shell)")
cd $HOME && echo -> FileNotFoundError errno=2 (dies before the shell)
absolute path -> FileNotFoundError errno=2 (dies before the shell)
env -C -> FileNotFoundError errno=2 (dies before the shell)
Expected behavior
A post_story hook runs. The stage is documented as the seam for work that must happen after review and integration, so the working directory it inherits should be one that still exists at that point — the repo root, where the story's commits have just landed.
Concretely: cwd should fall back to repo_root when the worktree is gone, e.g. treat a non-existent ctx.worktree as absent rather than as a valid path. engine._emit("post_story", task) passing a task whose worktree_path is already deleted is the other end of the same seam, if the fix belongs there instead.
Actual behavior
The hook never executes. The journal records:
23:19:15 story-done
23:19:15 unit-merge-started
23:19:15 unit-merged <- worktree removed here
23:19:36 harvest-carried
23:19:36 plugin-hook-error <- 21s later
23:19:36 run-complete
{"kind": "plugin-hook-error", "plugin": "pulse", "stage": "post_story",
"error": "[Errno 2] No such file or directory: '<run-dir>/worktrees/<story-key>'"}
Run outcome: 1 done, 0 deferred, 0 escalated. Nothing in the summary indicates the hook was skipped.
Confirmed absent from disk after the run — the directory in the error message is gone, not a permissions or race artifact.
Which area is this for?
Plugins
bmad-loop Version
0.11.1
Which coding CLI are you using?
Claude (claude)
Operating System
macOS
Relevant log output
# bmad-loop diagnose --out diag.md (sanitized, trimmed to the relevant fields)
## Environment
- bmad-loop version: 0.11.1
- python: 3.12.12
- os: Darwin 25.6.0
- sys.platform: darwin
- multiplexer: TmuxMultiplexer
- tmux: tmux 3.7c
## Run (story)
- state: finished=True stopped=False paused=False
- tasks: 1
- phase histogram: done=1
- sessions by role: dev=1, review=1
- sessions by status: completed=2
### Journal
- entries: 25
- duration (s): 6357.88
- escalations / defers / plugin-errors: 0 / 0 / 0
- kind histogram: dev-decision=1, harvest-carried=1, plugin-hook-error=1,
plugins-active=1, review-result=1, run-complete=1, run-start=1, session-end=2,
session-start=2, spec-deferrals-harvested=2, stories-validated=1,
story-done=1, story-start=1, target-branch=1, token-budget-exceeded=1,
unit-merge-started=1, unit-merged=1, verify-command-result=3,
worktree-opened=1, worktree-seed-skipped=1
# Note: the summary line reads "escalations / defers / plugin-errors: 0 / 0 / 0"
# while the kind histogram on the next line reports plugin-hook-error=1. That
# counter appears not to include plugin-hook-error, which is a smaller separate
# reporting gap — happy to file it on its own if it is not already known.
Description
A declarative plugin hook bound to
post_storycannot run:plugins/bus.pylaunches it with the story worktree as its working directory, and by that point the worktree has already been deleted byunit-merged.subprocess.runfails withFileNotFoundErroron thecwd=argument before the command's first byte executes.ctx.worktreeis truthy — it is a path string — so theor ctx.repo_rootfallback never engages. The path just no longer exists on disk.Since #383 this surfaces as a journalled
plugin-hook-errorrather than a crashed run, which is a real improvement. But the consequence is a hook that is structurally unable to fire at the one stage documented for post-review work, and the run still reports success. For a measurement plugin the result is silent data loss: the run says1 done, and the story's closing metrics were never written.The plugin cannot defend against this.
cwdis applied by the kernel atfork/exec, before any shell exists, so no command form survives (verified below). Declaring acwdkey in[hooks.post_story]does not help either —plugins/manifest.pyreads onlycmd,timeout_sec,blockingandfail_closed, so the key is ignored in silence and looks like a fix while changing nothing.Not a duplicate of #383 (strict decode of hook output, same function, different failure) or #343 (
OSErroruntranslated inverify._run_git). Same genus, different call site — filing separately per the precedent #383 itself set.Steps to reproduce
[hooks.post_story]cmdand enable it inpolicy.toml.[scm] isolation = "worktree".story-doneand merge normally.unit-mergedlands, thenplugin-hook-errorfor that plugin.Mechanism, isolated from bmad-loop — a removed
cwddefeats every workaround a plugin author could write:Expected behavior
A
post_storyhook runs. The stage is documented as the seam for work that must happen after review and integration, so the working directory it inherits should be one that still exists at that point — the repo root, where the story's commits have just landed.Concretely:
cwdshould fall back torepo_rootwhen the worktree is gone, e.g. treat a non-existentctx.worktreeas absent rather than as a valid path.engine._emit("post_story", task)passing a task whoseworktree_pathis already deleted is the other end of the same seam, if the fix belongs there instead.Actual behavior
The hook never executes. The journal records:
{"kind": "plugin-hook-error", "plugin": "pulse", "stage": "post_story", "error": "[Errno 2] No such file or directory: '<run-dir>/worktrees/<story-key>'"}Run outcome:
1 done, 0 deferred, 0 escalated. Nothing in the summary indicates the hook was skipped.Confirmed absent from disk after the run — the directory in the error message is gone, not a permissions or race artifact.
Which area is this for?
Plugins
bmad-loop Version
0.11.1
Which coding CLI are you using?
Claude (claude)
Operating System
macOS
Relevant log output