Skip to content

[BUG] post_story declarative hooks never run: cwd is the worktree that unit-merged just deleted #779

Description

@nidelson

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

  1. Install a declarative plugin with a [hooks.post_story] cmd and enable it in policy.toml.
  2. Run any story under [scm] isolation = "worktree".
  3. Let the story reach story-done and merge normally.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Real defect - workaround exists or blast radius is narrowarea:engineOrchestrator engine and run lifecyclebugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions