Description
WindowsProcessHost.shell_quote (process_host.py:228) quotes hook-registration arguments with subprocess.list2cmdline, i.e. for cmd.exe/CreateProcess. But hook commands are executed by the coding CLI's hook runner. Claude Code's hooks documentation states that in shell form the command string is passed to "sh -c on macOS and Linux, Git Bash on Windows, or PowerShell when Git Bash isn't installed" - a platform default, not inherited from the launching shell. So on a Windows host with Git Bash present (the usual case here, since bmad-loop requires git) the command goes through a POSIX shell. list2cmdline leaves a space-free path bare, and that shell then consumes every path separator as an escape:
registered: uv run --no-project python D:\PROGRAMMING\proj\.bmad-loop\bmad_loop_hook.py Stop
executed: uv run --no-project python D:PROGRAMMINGproj.bmad-loopbmad_loop_hook.py Stop
The mangled name is resolved against the session cwd. In the main checkout that misses; inside a worktree it names nothing at all.
The result is that no hook event is ever written — <state>/<run-id>/events/ stays empty for the life of the run — so the orchestrator never learns a session ended. Per signals.py's own docstring (quoted in #752): losing Stop events means every session stalls to session_timeout_min, "the loudest possible regression, delivered silently".
This is the cause side of the failure #752 describes from the resilience side. #752's incident was a relay that had never been registered (macOS); here the relay is registered correctly and still cannot execute. #494 is a different hook-relay defect (the event-write race), not this.
I know native Windows is not yet shipped. Filing because WindowsProcessHost is selected by the platform-default registry today (validate reports process host: WindowsProcessHost), so the seam is live, and the defect is a wrong assumption stated in the code rather than a missing feature.
Steps to reproduce
On native Windows (not WSL, Git Bash installed) with the claude adapter and scm.isolation = "worktree":
bmad-loop run --story <key>
- Observe
<state-dir>/<run-id>/events/ — it stays empty for the entire run.
- Every session runs to exactly
limits.session_timeout_min and is then killed; the journal shows session-end status=timeout or session-rescued-post-kill regardless of what the session actually did.
- In the CLI transcript the hook reports:
Stop hook error: "uv run --no-project python D:\...\.bmad-loop\bmad_loop_hook.py Stop" exited 2 with:
can't open file '...\worktrees\<story>\PROGRAMMINGCOURSEWORK_UNI.bmad-loopbmad_loop_hook.py':
[Errno 2] No such file or directory
Minimal reproduction of the quoting itself:
import subprocess
p = r"D:\PROGRAMMING\proj\.bmad-loop\bmad_loop_hook.py"
subprocess.list2cmdline([p]) # -> bare, separators intact
$ sh -c "echo D:\PROGRAMMING\proj\.bmad-loop\bmad_loop_hook.py"
D:PROGRAMMINGproj.bmad-loopbmad_loop_hook.py
Expected behavior
The registered relay command executes, writes its event file, and the orchestrator advances as soon as the session ends.
Actual behavior
The relay never executes. events/ is empty, every session burns its full session_timeout_min and is killed. With review.on_timeout = "retry" this then discards a committed, verify-green story and re-drives dev from scratch — in my run one story was re-implemented three times before I found the cause. Raising session_timeout_min makes it strictly worse, since it lengthens each blind wait.
Over one 9-hour run: zero events written, three sessions ending at exactly the 90-minute ceiling.
Suggested fix
Normalise separators before quoting:
def shell_quote(self, arg: str) -> str:
return subprocess.list2cmdline([arg.replace("\\", "/")])
Forward slashes are accepted by the Windows APIs and by Python, and are safe unquoted in both shells, so this stays correct if some CLI's runner uses cmd.exe. list2cmdline still double-quotes a path containing spaces, and both shells honour double quotes. shlex.quote also fixes the sh case but would break a cmd.exe runner, which seemed the wrong trade for a seam whose consumer varies by CLI — though if the real axis is the hook dialect rather than the OS, that is your call to make and may be the better shape.
There may be a cleaner fix than quoting at all. The same documentation describes an exec form: when args is set, "Claude Code resolves command as an executable on PATH and spawns it directly with args as the argument vector. There is no shell, so each args element is one argument exactly as written." Emitting the relay registration that way would remove shell quoting from this seam entirely rather than making it correct for more shells. It is a larger, Claude-dialect-specific change and I have not attempted it, but for a seam whose consumer varies per CLI it may be the better shape.
Scope: shell_quote has three callers (install.py:1064, probe.py:703, worktree_flow.py:1134), all building hook registrations. PosixProcessHost is untouched, so supported platforms are unaffected.
I have run this patch locally: bmad-loop validate is all-green, the generated command executes from a worktree cwd (exit 0), and run --dry-run / list / adapters behave normally. I could not run your test suite — it is not shipped in site-packages — so this is not offered as a validated PR.
Which area is this for?
CLI adapters & profiles
bmad-loop Version
0.11.1
Which coding CLI are you using?
Claude (claude)
Operating System
Windows
Note on AI assistance
This report was investigated and drafted with Claude Code (Claude Opus 5) while debugging a real run on my own project. Every command output, line reference, path and reproduction above was executed and verified on my machine rather than produced from the model's recollection; the root cause was found by inspecting the installed package and confirmed by executing the mangled and fixed commands through sh. Flagging it because your triage cost is lower if you know, and I would rather say so than not.
Description
WindowsProcessHost.shell_quote(process_host.py:228) quotes hook-registration arguments withsubprocess.list2cmdline, i.e. for cmd.exe/CreateProcess. But hook commands are executed by the coding CLI's hook runner. Claude Code's hooks documentation states that in shell form the command string is passed to "sh -con macOS and Linux, Git Bash on Windows, or PowerShell when Git Bash isn't installed" - a platform default, not inherited from the launching shell. So on a Windows host with Git Bash present (the usual case here, since bmad-loop requires git) the command goes through a POSIX shell.list2cmdlineleaves a space-free path bare, and that shell then consumes every path separator as an escape:The mangled name is resolved against the session cwd. In the main checkout that misses; inside a worktree it names nothing at all.
The result is that no hook event is ever written —
<state>/<run-id>/events/stays empty for the life of the run — so the orchestrator never learns a session ended. Persignals.py's own docstring (quoted in #752): losing Stop events means every session stalls tosession_timeout_min, "the loudest possible regression, delivered silently".This is the cause side of the failure #752 describes from the resilience side. #752's incident was a relay that had never been registered (macOS); here the relay is registered correctly and still cannot execute. #494 is a different hook-relay defect (the event-write race), not this.
I know native Windows is not yet shipped. Filing because
WindowsProcessHostis selected by the platform-default registry today (validatereportsprocess host: WindowsProcessHost), so the seam is live, and the defect is a wrong assumption stated in the code rather than a missing feature.Steps to reproduce
On native Windows (not WSL, Git Bash installed) with the
claudeadapter andscm.isolation = "worktree":bmad-loop run --story <key><state-dir>/<run-id>/events/— it stays empty for the entire run.limits.session_timeout_minand is then killed; the journal showssession-end status=timeoutorsession-rescued-post-killregardless of what the session actually did.Minimal reproduction of the quoting itself:
Expected behavior
The registered relay command executes, writes its event file, and the orchestrator advances as soon as the session ends.
Actual behavior
The relay never executes.
events/is empty, every session burns its fullsession_timeout_minand is killed. Withreview.on_timeout = "retry"this then discards a committed, verify-green story and re-drives dev from scratch — in my run one story was re-implemented three times before I found the cause. Raisingsession_timeout_minmakes it strictly worse, since it lengthens each blind wait.Over one 9-hour run: zero events written, three sessions ending at exactly the 90-minute ceiling.
Suggested fix
Normalise separators before quoting:
Forward slashes are accepted by the Windows APIs and by Python, and are safe unquoted in both shells, so this stays correct if some CLI's runner uses cmd.exe.
list2cmdlinestill double-quotes a path containing spaces, and both shells honour double quotes.shlex.quotealso fixes theshcase but would break a cmd.exe runner, which seemed the wrong trade for a seam whose consumer varies by CLI — though if the real axis is the hook dialect rather than the OS, that is your call to make and may be the better shape.There may be a cleaner fix than quoting at all. The same documentation describes an exec form: when
argsis set, "Claude Code resolvescommandas an executable onPATHand spawns it directly withargsas the argument vector. There is no shell, so eachargselement is one argument exactly as written." Emitting the relay registration that way would remove shell quoting from this seam entirely rather than making it correct for more shells. It is a larger, Claude-dialect-specific change and I have not attempted it, but for a seam whose consumer varies per CLI it may be the better shape.Scope:
shell_quotehas three callers (install.py:1064,probe.py:703,worktree_flow.py:1134), all building hook registrations.PosixProcessHostis untouched, so supported platforms are unaffected.I have run this patch locally:
bmad-loop validateis all-green, the generated command executes from a worktree cwd (exit 0), andrun --dry-run/list/adaptersbehave normally. I could not run your test suite — it is not shipped in site-packages — so this is not offered as a validated PR.Which area is this for?
CLI adapters & profiles
bmad-loop Version
0.11.1
Which coding CLI are you using?
Claude (claude)
Operating System
Windows
Note on AI assistance
This report was investigated and drafted with Claude Code (Claude Opus 5) while debugging a real run on my own project. Every command output, line reference, path and reproduction above was executed and verified on my machine rather than produced from the model's recollection; the root cause was found by inspecting the installed package and confirmed by executing the mangled and fixed commands through
sh. Flagging it because your triage cost is lower if you know, and I would rather say so than not.