Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,16 @@ breaking changes may land in a minor release.

### Changed

- Register hooks through the installed `bmad-loop relay <Event>` command. Upgrading
invalidates Codex hook trust: Codex re-prompts at the next launch, and hooks silently
do not fire until the new commands are accepted. Re-run `bmad-loop init` to migrate
managed registrations. `validate` warns when a hook still points to another installation.
- Document the live-session removal guard's measured ceiling (#732): `delete`, `archive` and `clean` still remove a run directory when a listing omits a live session. Behavior unchanged; the psmux half is reported upstream (psmux/psmux#622), its retirement tracked in #754.

### Fixed

- Replace stale installed relay hooks when a project moves between Windows and POSIX.

- Report stale or unverifiable Codex hook trust in `validate` and `probe-adapter`
before a live probe launches; check both relay events against Codex's read-only
hook discovery for the operation's directory and executable (#461).
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,7 @@ partial — the tail after the last `-` (e.g. `a1b2`), shortened to any prefix t
> One subcommand is deliberately left out of the table: `bmad-loop relay <Event>` writes a single
> session event file from a coding-CLI hook payload on stdin. Its own help calls it "a hook target
> for machines, not a command to run by hand" — it takes no `--project`, and `bmad-loop init`
> currently registers the copied workspace relay (`.bmad-loop/bmad_loop_hook.py`) instead, so no
> installed hook reaches the console script today. Never invoke it yourself.
> registers this installed command with an absolute path. Never invoke it yourself.

## The TUI

Expand Down
6 changes: 3 additions & 3 deletions docs/FEATURES.md

Large diffs are not rendered by default.

19 changes: 7 additions & 12 deletions docs/porting-to-a-new-os.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,17 +303,12 @@ register_process_host("windows", lambda platform: platform == "win32", WindowsPr

## Seam 3 — hook interpreter

`ProcessHost.hook_interpreter()` is the command prefix that `install` / `probe`
interpolate into the hook registrations they write (the script path and canonical
event are appended by the caller). It exists so hook registration never branches
on `sys.platform` at the call site:

- POSIX returns `"python3"` (the interpreter on PATH).
- `WindowsProcessHost` returns `"uv run --no-project python"` — Windows ships no
`python3` launcher, and `--no-project` resolves an interpreter without activating
a project venv (hooks fire detached).

A new OS overrides this on its `ProcessHost`; nothing else changes.
`ProcessHost.hook_interpreter()` supplies an absolute, host-quoted
`sys.executable` path for temporary Python hook scripts, such as the live probe
capture hook. Both POSIX and Windows use that interpreter directly. `init` and
worktree provisioning register the installed `bmad-loop relay <Event>` console
script by absolute path instead of invoking a Python script in the workspace.
A new OS overrides the quoting behavior on its `ProcessHost` as needed.

---

Expand Down Expand Up @@ -466,7 +461,7 @@ Concretely, a native-Windows port is:
its `register_multiplexer("psmux", …)`.
2. `WindowsProcessHost` — **already shipped** — needs only its registration, which
is **already present** in `_load_builtin_hosts`. Its `hook_interpreter()`
(`uv run --no-project python`) is in place too.
(an absolute, host-quoted interpreter path) is in place too.
3. A CI runner on Windows to exercise the above.

No edits to the adapters, `runs.py`, `tui/launch.py`, `probe.py`, `tui/data.py`,
Expand Down
16 changes: 11 additions & 5 deletions docs/setup-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,8 +360,8 @@ what each command touches. Make sure no run is still live (Editor open, session

### 2. Remove the orchestrator state

Delete the `.bmad-loop/` directory. This removes the hook relay script
(`.bmad-loop/bmad_loop_hook.py`), the `policy.toml` template, and all per-run state
Delete the `.bmad-loop/` directory. This removes the `policy.toml` template,
legacy relay copies from older installs, and all per-run state
(`runs/`, `cache/`, `archive/`) in one step:

```bash
Expand Down Expand Up @@ -400,14 +400,20 @@ that also cleans up a pre-0.7.0 install.)

`init` **merged** its Stop-hook registration into each CLI's existing hook config, so these
files must be **edited, not deleted** (they hold your own settings too). In each config below,
remove the hook entry whose `command` contains `bmad_loop_hook.py`:
remove the hook entry whose `command` ends in `bmad-loop relay <Event>` (or, on
older installs, is a Python or `uv run --no-project python` command whose script
path ends in `.bmad-loop/bmad_loop_hook.py` and whose event argument matches
that hook event):

- **claude** — `.claude/settings.json`
- **codex** — `.codex/hooks.json`
- **gemini** — `.gemini/settings.json`
- **copilot** — `.github/copilot/settings.json`
- **antigravity** — `.agents/hooks.json` (the `bmad-loop` hook group)

Edit only the registered CLIs. The `bmad_loop_hook.py` substring uniquely identifies the
entries to strip; leave every other hook in place.
Edit only the registered CLIs. Match the full relay command and event; leave every
other hook in place. The installed `bmad-loop` executable is shared with other projects,
so remove it only when uninstalling the package itself.

### 5. Drop the gitignore lines

Expand Down
135 changes: 54 additions & 81 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,7 @@ def cmd_validate(args: argparse.Namespace) -> int:
{"binary": tool, "path": resolved, "returncode": rc},
)

any_hooks_registered = False
registered_relay_paths: set[Path] = set()
for profile in profiles:
# Keyed on the adapter KIND, not on `hookless`. httpx is the bundled
# opencode family's optional extra — a fact about one adapter class, which
Expand Down Expand Up @@ -714,20 +714,40 @@ def cmd_validate(args: argparse.Namespace) -> int:
continue
hook_config = project / profile.hooks.config_path
hooks_ok = False
parsed: dict = {}
if hook_config.is_file():
try:
parsed = json.loads(hook_config.read_text(encoding="utf-8"))
hooks_ok = isinstance(parsed, dict) and relay_registered(
parsed, profile.hooks.dialect, profile.hooks.events
)
if isinstance(parsed, dict):
container = install.hook_event_container(parsed, profile.hooks.dialect)
malformed = [
event
for event in profile.hooks.events
if event in container and not isinstance(container[event], list)
]
if malformed:
hooks_ok = False
report.fail(
"hooks.config-parse",
f"{hook_config} has malformed handlers for {', '.join(malformed)}",
{"profile": profile.name, "config_path": str(hook_config)},
)
except json.JSONDecodeError:
report.fail(
"hooks.config-parse",
f"{hook_config} is not valid JSON",
{"profile": profile.name, "config_path": str(hook_config)},
)
if isinstance(parsed, dict):
registered_relay_paths.update(
install.registered_relay_paths(
parsed, profile.hooks.dialect, profile.hooks.events, project
)
)
if hooks_ok:
any_hooks_registered = True
report.ok(
"hooks.registered",
f"bmad-loop hooks registered for {profile.name}",
Expand Down Expand Up @@ -784,91 +804,50 @@ def cmd_validate(args: argparse.Namespace) -> int:
{"profile": profile.name, "project": str(project), "binary": profile.binary},
)

# #461: `hooks.registered` above is a substring match on the config JSON — it
# never touches the artifact the registered command points AT. A branch switch
# (or a deleted .bmad-loop/) leaves the registration green while every hook
# event is a silent no-op and the run stalls to session_timeout_min, so stat
# the relay itself. Outside the per-profile loop on purpose: the relay is one
# shared artifact, and per-profile reporting would print the same line N times.
# A distinct id, not a repurposed `hooks.registered` — the two answer different
# questions and an operator needs to see which one failed.
#
# COUPLING (#461 Phase 2): Phase 2 moves the relay to the installed console
# script — `bmad-loop relay <Event>` (cmd_relay / events.py), NOT the
# `<abs-python> -m bmad_loop.hookrelay` spelling this once anticipated — and
# retires HOOK_SCRIPT_REL. It must RETARGET this check to stat what the
# registration actually points at (the resolved `bmad-loop` executable), not
# drop it — the stall it guards against survives the move: an entry point that
# is gone or unreadable strands every hook event exactly like a missing script.
if any_hooks_registered:
relay = project / install.HOOK_SCRIPT_REL
# Existence is not enough: `is_file()` stays True for a mode-000 file, and
# the registered command is `<interpreter> <relay> <Event>`, which has to
# READ the script — an unreadable relay exits 2 ("can't open file") and the
# run stalls exactly as if the relay were gone, which is the blind spot
# this whole check exists to remove. `os.access` uses the REAL uid/gid,
# which is what the operator's own `bmad-loop` invocation runs as, and it
# stays correct under root (who can read a 000 file) where a mode-bit test
# would false-fail. On Windows `chmod` can only toggle the read-only flag,
# so this arm is POSIX-effective and never makes the Windows path stricter.
# Inspect the executable each managed registration actually names. A new
# installation in this process cannot repair an older path in a hook config.
# Compare with the command init would write now: an old executable can remain
# usable after switching installations, while still running an outdated relay.
expected_relay = None
if registered_relay_paths:
hook_profile = next(profile for profile in profiles if not profile.hookless)
try:
expected_relay = install.relay_executable(
install._hook_command(project, hook_profile, "Stop")
)
except ProfileError:
# No current executable to compare. The registered path still gets
# its own presence check below; do not call it stale by inference.
pass
for relay in sorted(registered_relay_paths):
if not relay.is_file():
Comment on lines +822 to 823

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect relays from a different installation

When init was run from one installation (for example, a repository .venv) and validate later runs from another while the old executable still exists, this check reports the old path as available without comparing it to the executable _hook_command() would currently register. Non-Codex profiles therefore pass validation while continuing to invoke an outdated relay indefinitely, potentially missing relay fixes or stalling if that executable is incompatible; the removed hooks.relay-stale check previously warned about this version skew. Compare each registered command with the current installation and report a stale-registration warning when they differ.

AGENTS.md reference: AGENTS.md:L78-L78

Useful? React with 👍 / 👎.

report.fail(
"hooks.relay-present",
f"hooks are registered but the relay script {relay} is missing — "
f"run `bmad-loop init`",
f"registered hook executable {relay} is missing — re-run `bmad-loop init`",
{"path": str(relay)},
)
elif not os.access(relay, os.R_OK):
# Deliberately NOT "run `bmad-loop init`": install_into writes this path
# with write_text(), which needs write access to the same file, so init
# raises PermissionError instead of repairing it. Sending the operator
# to a command that also fails is worse than saying nothing.
elif not os.access(
relay, os.R_OK if relay.name == "bmad_loop_hook.py" else os.R_OK | os.X_OK
):
report.fail(
"hooks.relay-present",
f"hooks are registered but the relay script {relay} is not readable — "
f"the registered hook command cannot run it, so every hook event "
f"no-ops. Restore read permission (`chmod u+r`) or delete it and "
f"re-run `bmad-loop init`",
f"registered hook executable {relay} is not usable — repair its permissions or re-run `bmad-loop init`",
{"path": str(relay)},
)
else:
report.ok(
"hooks.relay-present",
f"hook relay script present: {relay}",
{"path": str(relay)},
)

# #494 Phase 4: present-and-readable is not current. The relay is COPIED
# into the project by `init`, so an upgraded orchestrator routinely drives
# sessions through a relay written by an older wheel — and the #494 move
# is exactly the kind of change that skew hides: a pre-move relay writes
# its events to the in-tree `<run-dir>/events` while the operator believes
# the channel left the project tree, so a branch switch can still take the
# control plane away mid-run.
#
# A WARNING, never a problem, and validate's exit code must not move:
# Phase 3's fallback pair keeps a stale relay FUNCTIONAL (it writes the
# legacy directory, which SignalWatcher still polls), so the run completes
# — the operator is losing the property, not the loop. `passed` counts
# only problems, so `warn` is what says "degraded but working".
stale = install.hook_script_current(project)
if stale is False:
report.warn(
"hooks.relay-stale",
f"the installed hook relay {relay} differs from this bmad-loop's "
f"— it is from another version, or was edited. Events may still be "
f"written inside the project tree; run `bmad-loop init` to refresh it",
f"registered hook executable available: {relay}",
{"path": str(relay)},
)
elif stale is True:
report.ok(
"hooks.relay-stale",
f"hook relay script up to date: {relay}",
{"path": str(relay)},
)
# `None` (unreadable/undecodable on either side) reports nothing: the
# relay-present block above already spoke for the cases an operator can
# act on, and "I could not compare" is not a finding about their project.
if expected_relay is not None and relay != expected_relay:
report.warn(
"hooks.relay-stale",
f"registered hook executable {relay} differs from this "
f"installation's {expected_relay} — re-run `bmad-loop init` "
"to update the hook registration",
{"path": str(relay), "expected_path": str(expected_relay)},
)

# Adapter-kind validity is enforced against the LIVE registry, never a
# hardcoded set: a profile.adapter naming no registered kind is a config error
Expand Down Expand Up @@ -5312,14 +5291,8 @@ def cmd_init(args: argparse.Namespace) -> int:
def cmd_relay(args: argparse.Namespace) -> int:
"""``bmad-loop relay <Event>`` — the hook relay as an installed console script.

**Nothing points at it yet.** ``init`` still registers the copied workspace
relay (``install._hook_command`` emits ``<interpreter> <project>/.bmad-loop/
bmad_loop_hook.py <Event>``), so no installed hook reaches this handler today;
it is the target #461 Phase 2 retargets those registrations to, and that move
carries its own obligation — see the COUPLING note on ``hooks.relay-present``,
which must be retargeted rather than dropped in the same change. Said here
because a console script that exists and is documented reads as the live path,
and an operator debugging a lost Stop needs to know which relay actually ran.
``init`` registers the absolute entry point belonging to this installation.
``hooks.relay-present`` checks the path each registration actually names.

Total by contract, unlike every other handler: a coding CLI runs this INSIDE
the session whose completion it reports, and several of them surface a
Expand Down
13 changes: 9 additions & 4 deletions src/bmad_loop/codex_trust.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@
from dataclasses import dataclass
from pathlib import Path

from .adapters.profile import CLIProfile
from .install import _hook_command
from .adapters.profile import CLIProfile, ProfileError
from .install import _hook_command, _relay_command
from .process_host import ProcessHostError, get_process_host

_EVENTS = {"SessionStart": "sessionStart", "Stop": "stop"}
_RELAY_MARKER = "bmad_loop_hook.py"
_RELAY_MARKER = "bmad-loop"
_PROBE_MARKER = "bmad_loop_probe_hook.py"
_TIMEOUT_S = 5.0
_SAFE_BYPASS_ARG = "--dangerously-bypass-approvals-and-sandbox"
Expand Down Expand Up @@ -74,7 +74,10 @@ def _commands(
if not isinstance(hook, dict):
raise ValueError("malformed Codex hook entry")
command = hook.get("command")
if isinstance(command, str) and marker in command:
if not isinstance(command, str):
continue
is_relay = _relay_command(command) if marker == _RELAY_MARKER else marker in command
if is_relay:
# A SessionStart matcher can exclude startup even when
# Codex reports the command trusted and enabled. The
# installed relay has none; refuse customized matchers.
Expand Down Expand Up @@ -200,6 +203,8 @@ def project_hook_trust(
return TrustResult("unverifiable", "hook trust config is unreadable")
try:
commands = _commands(config, profile, project, marker)
except ProfileError as e:
return TrustResult("unverifiable", f"hook trust installed relay unavailable: {e}")
except ValueError:
return TrustResult("unverifiable", "hook trust config has malformed fields")
if commands is None:
Expand Down
Loading
Loading