Skip to content

Commit 4d22796

Browse files
committed
fix(worktree): re-register the relay over a seeded hook config
A worktree seeded with the main repo's .claude/settings.json inherits its $CLAUDE_PROJECT_DIR-relative relay command, which resolves inside the worktree, where no .bmad-loop/ relay exists. merge_hooks will not replace an already-registered relay, so the broken command survived and the session emitted no hook events at all — the run stalled until the session clock ran out. Add install.strip_relay_hooks (the inverse of merge_hooks, built on hook_event_container) and call it from provision_worktree before re-registering, so the absolute main-relay command is authoritative. Probe-capture hooks are deliberately left alone. Claude dialect in practice: codex/gemini expose no $CLAUDE_PROJECT_DIR equivalent and already bake an absolute path. Claude-Session: https://claude.ai/code/session_01E5h8J8sY6uL6pkjTn1BGVc
1 parent d53ba63 commit 4d22796

3 files changed

Lines changed: 96 additions & 2 deletions

File tree

src/bmad_loop/install.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,38 @@ def _managed_hook_in_handlers(handlers) -> bool:
614614
return RELAY_MARKER in dumped or PROBE_MARKER in dumped
615615

616616

617+
def strip_relay_hooks(config: dict, dialect: str) -> bool:
618+
"""Drop every relay registration from a parsed hook config. True if any went.
619+
620+
The inverse of :func:`merge_hooks`, for the one caller that needs its own
621+
registration to be authoritative rather than additive: a worktree seeded with
622+
the main repo's hook config (``provision_worktree``). That config already
623+
carries a relay command written for the main repo — `$CLAUDE_PROJECT_DIR`-relative
624+
for the claude dialect, which resolves inside the worktree, where no relay
625+
exists. `merge_hooks` will not replace it, since `_managed_hook_in_handlers`
626+
reports the event as already registered, so the stale command has to go first.
627+
628+
Only RELAY_MARKER handlers are removed. A probe-capture hook is a deliberate,
629+
temporary registration that no worktree seeding produces, and is left alone.
630+
Empty event lists are dropped; an empty container is left in place for
631+
`merge_hooks` to refill.
632+
"""
633+
container = hook_event_container(config, dialect)
634+
removed = False
635+
for native_event in list(container):
636+
handlers = container.get(native_event)
637+
if not isinstance(handlers, list):
638+
continue
639+
kept = [h for h in handlers if RELAY_MARKER not in json.dumps(h)]
640+
if len(kept) != len(handlers):
641+
removed = True
642+
if kept:
643+
container[native_event] = kept
644+
else:
645+
del container[native_event]
646+
return removed
647+
648+
617649
def relay_registered(config: dict, dialect: str, events: Iterable[str]) -> bool:
618650
"""True if the bmad-loop relay is registered for any of `events`."""
619651
container = hook_event_container(config, dialect)

src/bmad_loop/worktree_flow.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
_worktree_local_exclude,
3636
merge_hooks,
3737
resolve_review_layers,
38+
strip_relay_hooks,
3839
)
3940
from .model import Phase
4041
from .process_host import get_process_host
@@ -270,8 +271,15 @@ def provision_worktree(
270271
native: f"{interp} {host.shell_quote(str(relay))} {canonical}"
271272
for native, canonical in profile.hooks.events.items()
272273
}
273-
config, changed = merge_hooks(config, registrations, profile.hooks.dialect)
274-
if changed:
274+
# A seeded config_path (.claude/settings.json is both a seeded file and the
275+
# hook config) arrives carrying the MAIN repo's relay command, which for the
276+
# claude dialect is $CLAUDE_PROJECT_DIR-relative and resolves to a path that
277+
# does not exist inside the worktree. merge_hooks will not replace an
278+
# already-registered relay, so strip it first and let this registration —
279+
# baked to the main repo's relay, absolute — be authoritative.
280+
stripped = strip_relay_hooks(config, profile.hooks.dialect)
281+
config, merged = merge_hooks(config, registrations, profile.hooks.dialect)
282+
if stripped or merged:
275283
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
276284

277285
# Shield exactly the paths we wrote (skill trees + hook configs + seeded

tests/test_install.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
DEV_BASE_SKILLS,
1414
MODULE_SKILLS,
1515
_copy_traversable,
16+
_register_hooks,
1617
install_into,
1718
merge_hooks,
1819
missing_base_skills,
1920
provision_worktree,
21+
strip_relay_hooks,
2022
)
2123

2224

@@ -405,6 +407,58 @@ def test_provision_worktree_lays_down_skills_and_hook(tmp_path):
405407
assert not (wt / ".bmad-loop").exists()
406408

407409

410+
def test_provision_worktree_rewrites_seeded_relative_hook_to_absolute(tmp_path):
411+
"""The main repo's .claude/settings.json carries a $CLAUDE_PROJECT_DIR-relative
412+
relay command. Seeded into a worktree, that variable resolves to the worktree,
413+
where no .bmad-loop/ relay exists — the hook fails, no Stop signal ever fires and
414+
the run stalls. The registration must overwrite the seeded command, which
415+
merge_hooks alone will not do (it treats the event as already registered)."""
416+
wt, repo = tmp_path / "wt", tmp_path / "repo"
417+
repo.mkdir()
418+
claude = get_profile("claude")
419+
assert _register_hooks(repo, claude) == 0
420+
main_settings = json.loads((repo / claude.hooks.config_path).read_text())
421+
assert "$CLAUDE_PROJECT_DIR" in main_settings["hooks"]["Stop"][0]["hooks"][0]["command"]
422+
423+
provision_worktree(wt, [claude], repo, seed_files=[claude.hooks.config_path])
424+
425+
stop = json.loads((wt / claude.hooks.config_path).read_text())["hooks"]["Stop"]
426+
assert len(stop) == 1 # replaced, not appended alongside
427+
cmd = stop[0]["hooks"][0]["command"]
428+
assert str(repo / ".bmad-loop" / "bmad_loop_hook.py") in cmd
429+
assert "$CLAUDE_PROJECT_DIR" not in cmd
430+
431+
432+
def test_strip_relay_hooks_leaves_foreign_handlers(tmp_path):
433+
"""Only bmad relay handlers go. A project's own hooks share the event list and
434+
must survive, and an event that held nothing else is dropped rather than left
435+
as an empty list."""
436+
config = {
437+
"hooks": {
438+
"Stop": [
439+
{
440+
"matcher": "",
441+
"hooks": [{"type": "command", "command": "python bmad_loop_hook.py Stop"}],
442+
},
443+
{"matcher": "", "hooks": [{"type": "command", "command": "make lint"}]},
444+
],
445+
"SessionStart": [
446+
{
447+
"matcher": "",
448+
"hooks": [{"type": "command", "command": "python bmad_loop_hook.py start"}],
449+
}
450+
],
451+
}
452+
}
453+
assert strip_relay_hooks(config, "claude-settings-json") is True
454+
assert config["hooks"]["Stop"] == [
455+
{"matcher": "", "hooks": [{"type": "command", "command": "make lint"}]}
456+
]
457+
assert "SessionStart" not in config["hooks"]
458+
# idempotent: nothing left to remove
459+
assert strip_relay_hooks(config, "claude-settings-json") is False
460+
461+
408462
def test_provision_worktree_covers_multiple_profiles(tmp_path):
409463
"""Dev=claude + review=codex provisions both skill trees (.claude/skills and
410464
.agents/skills) and both hook configs."""

0 commit comments

Comments
 (0)