From 92a161035182b5c256e263e1748e18c8ccdbdeba Mon Sep 17 00:00:00 2001 From: pbean Date: Tue, 21 Jul 2026 12:44:54 -0700 Subject: [PATCH] feat(relay): shared event-write primitive + `bmad-loop relay ` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event-write body was duplicated between data/bmad_loop_hook.py and PR #226's hermes_hooks.relay_event (identical env-gating, casing-tolerant field extraction, atomic events/--.json write). Factor the schema + write into one core primitive, bmad_loop.events.write_relay_event, and add a general `bmad-loop relay ` command over it that reads the hook payload from stdin. This is the general, in-tree counterpart to bmad_loop_hook.py for global/user-scoped-hook adapters: such an adapter (e.g. an out-of-tree Hermes package) points its native hook at `bmad-loop relay Stop` and needs zero per-adapter relay code. Not Hermes-specific. data/bmad_loop_hook.py keeps its own inlined copy on purpose — it is installed into target projects as a lone, stdlib-only file and run as a bare subprocess where the bmad_loop package need not be importable, so it cannot import the primitive. Its behavior is unchanged; a parity test in tests/test_relay_event.py pins the inlined copy and the primitive to the same schema across every payload dialect so they cannot drift. Event choices come from CANONICAL_EVENTS (never a hardcoded set). Tests cover the active-env write, the no-env no-op, the CLI stdin path, event-name gating, and standalone/primitive schema parity. --- src/bmad_loop/cli.py | 32 +++++ src/bmad_loop/data/bmad_loop_hook.py | 8 ++ src/bmad_loop/events.py | 93 ++++++++++++ tests/test_relay_event.py | 205 +++++++++++++++++++++++++++ 4 files changed, 338 insertions(+) create mode 100644 src/bmad_loop/events.py create mode 100644 tests/test_relay_event.py diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index e2dcba22..2800a0d5 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -2242,6 +2242,25 @@ def cmd_diagnose(args: argparse.Namespace) -> int: return 0 +def cmd_relay(args: argparse.Namespace) -> int: + """Write one canonical BMAD event from a coding-CLI hook payload on stdin. + + The general, in-tree counterpart to ``data/bmad_loop_hook.py`` for + global/user-scoped-hook adapters: such an adapter points its native hook at + ``bmad-loop relay ``, which reads the hook's JSON payload from stdin + and defers to :func:`bmad_loop.events.write_relay_event`. The write is + env-gated to the active task, so it is a no-op outside a bmad-loop session. + """ + from .events import write_relay_event + + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + payload = {} + write_relay_event(args.event, payload if isinstance(payload, dict) else {}, os.environ) + return 0 + + def cmd_init(args: argparse.Namespace) -> int: from .install import install_into @@ -2324,6 +2343,19 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser: "backend that only registers on the target machine)", ) + from .adapters.profile import CANONICAL_EVENTS + + relay_p = add( + "relay", + cmd_relay, + "write one canonical event from a coding-CLI hook payload read on stdin", + ) + relay_p.add_argument( + "event", + choices=sorted(CANONICAL_EVENTS), + help="canonical event name to record (the CLI hook maps its native event to this)", + ) + probe_p = add( "probe-adapter", cmd_probe, diff --git a/src/bmad_loop/data/bmad_loop_hook.py b/src/bmad_loop/data/bmad_loop_hook.py index e260ec61..031bd2fc 100644 --- a/src/bmad_loop/data/bmad_loop_hook.py +++ b/src/bmad_loop/data/bmad_loop_hook.py @@ -1,6 +1,14 @@ #!/usr/bin/env python3 """Coding-CLI hook relay for bmad-loop. Stdlib only. +The event schema and its atomic write are ALSO owned by +``bmad_loop.events.write_relay_event`` (which the ``bmad-loop relay`` CLI uses). +This script keeps its own inlined copy on purpose: it is installed into a target +project as a lone file (``.bmad-loop/bmad_loop_hook.py``) and run as a bare +subprocess where the ``bmad_loop`` package need not be importable, so it can only +depend on the stdlib. ``tests/test_relay_event.py`` pins this copy and the +primitive to the same schema — keep the two in sync when either changes. + Each CLI's hook config registers this script under its native event names (Claude/Codex: SessionStart/Stop/..., Gemini: AfterAgent for Stop, Copilot: agentStop for Stop) but always passes the CANONICAL event name as argv[1] — the diff --git a/src/bmad_loop/events.py b/src/bmad_loop/events.py new file mode 100644 index 00000000..e3f75919 --- /dev/null +++ b/src/bmad_loop/events.py @@ -0,0 +1,93 @@ +"""Canonical BMAD relay event: the schema plus its env-gated atomic write. + +The orchestrator ingests each coding-CLI completion signal as one JSON file +under ``/events/`` (read back by :mod:`bmad_loop.signals`). This module +owns that event schema and the gated, atomic write. Two callers share the shape: + +* the ``bmad-loop relay `` CLI command (:func:`bmad_loop.cli.cmd_relay`) — + the in-tree, general counterpart to ``data/bmad_loop_hook.py`` for + global/user-scoped-hook adapters. Such an adapter points its native hook at + ``bmad-loop relay `` and needs **zero** per-adapter relay code. +* conceptually, ``data/bmad_loop_hook.py``. That script CANNOT import this module: + it is installed into a target project as a lone, stdlib-only file + (``.bmad-loop/bmad_loop_hook.py``) and run as a bare subprocess by the coding + CLI, where the ``bmad_loop`` package need not be importable. It therefore keeps + a byte-for-byte inlined copy of :func:`build_event` / :func:`_first_workspace`; + ``tests/test_relay_event.py`` pins the two implementations to the same schema so + they cannot drift. + +The write is gated on ``BMAD_LOOP_RUN_DIR`` + ``BMAD_LOOP_TASK_ID`` (set on the +tmux window the orchestrator spawns), so a normal interactive session — where a +user runs the same hook — is a silent no-op. +""" + +from __future__ import annotations + +import json +import os +import time +from collections.abc import Mapping +from pathlib import Path +from typing import Any + + +def _first_workspace(payload: Mapping[str, Any]) -> str | None: + """agy carries no ``cwd`` — it sends ``workspacePaths``, a list of roots.""" + paths = payload.get("workspacePaths") + if isinstance(paths, list) and paths and isinstance(paths[0], str): + return paths[0] + return None + + +def build_event( + event_name: str, payload: Mapping[str, Any], *, ts: int, task_id: str +) -> dict[str, Any]: + """The canonical relay-event dict. + + ``event_name`` is always the CANONICAL event name — every CLI's hook config + maps its native event onto one of :data:`bmad_loop.adapters.profile.CANONICAL_EVENTS` + before the relay ever sees it. Payload key casing varies by CLI: snake_case + (claude/codex), ``conversation_id`` (cursor), or camelCase (copilot's + ``sessionId``/``transcriptPath``, agy's ``conversationId``/``transcriptPath`` — + protojson encoding); each field extraction tries every spelling. + """ + return { + "ts": ts, + "event": event_name, + "task_id": task_id, + "session_id": ( + payload.get("session_id") + or payload.get("conversation_id") + or payload.get("sessionId") + or payload.get("conversationId") + ), + "transcript_path": payload.get("transcript_path") or payload.get("transcriptPath"), + "cwd": payload.get("cwd") or _first_workspace(payload), + } + + +def write_relay_event( + event_name: str, + payload: Mapping[str, Any], + environ: Mapping[str, str], +) -> Path | None: + """Atomically write one canonical event — only from an active BMAD session. + + Returns the written file's path, or ``None`` when ``environ`` is not an active + bmad-loop session (``BMAD_LOOP_RUN_DIR`` / ``BMAD_LOOP_TASK_ID`` unset), so a + normal interactive session running the same hook is unaffected. + """ + run_dir = environ.get("BMAD_LOOP_RUN_DIR") + task_id = environ.get("BMAD_LOOP_TASK_ID") + if not run_dir or not task_id: + return None + + ts = time.time_ns() + event = build_event(event_name, payload, ts=ts, task_id=task_id) + events_dir = Path(run_dir) / "events" + events_dir.mkdir(parents=True, exist_ok=True) + final = events_dir / f"{ts}-{task_id}-{event_name}.json" + tmp = Path(f"{final}.tmp") + tmp.write_text(json.dumps(event), encoding="utf-8") + os.replace(tmp, final) + return final diff --git a/tests/test_relay_event.py b/tests/test_relay_event.py new file mode 100644 index 00000000..d7705ca5 --- /dev/null +++ b/tests/test_relay_event.py @@ -0,0 +1,205 @@ +"""The shared relay-event primitive, the `bmad-loop relay` CLI, and their +schema parity with the standalone ``data/bmad_loop_hook.py`` script. + +The primitive (:func:`bmad_loop.events.write_relay_event`) is the general, +in-tree counterpart to the standalone hook script for global/user-scoped-hook +adapters. The two share one event schema but cannot share code — the script is +installed as a lone stdlib-only file — so a parity test pins them together. +""" + +from __future__ import annotations + +import io +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from bmad_loop import cli +from bmad_loop.adapters.profile import CANONICAL_EVENTS +from bmad_loop.events import build_event, write_relay_event + +HOOK_SCRIPT = Path(__file__).parent.parent / "src" / "bmad_loop" / "data" / "bmad_loop_hook.py" + + +def _sole_event(run_dir: Path) -> dict: + files = list((run_dir / "events").glob("*.json")) + assert len(files) == 1, files + return json.loads(files[0].read_text(encoding="utf-8")) + + +# --- the primitive --------------------------------------------------------- + + +def test_write_noops_without_env(tmp_path): + """No BMAD env vars -> None, and not even the events dir is created.""" + assert write_relay_event("Stop", {"session_id": "s-1"}, {}) is None + assert not (tmp_path / "events").exists() + + +def test_write_noops_with_only_run_dir(tmp_path): + """Both env vars are required; run_dir alone must not write.""" + environ = {"BMAD_LOOP_RUN_DIR": str(tmp_path)} + assert write_relay_event("Stop", {"session_id": "s-1"}, environ) is None + assert not (tmp_path / "events").exists() + + +def test_write_event_for_active_env(tmp_path): + environ = {"BMAD_LOOP_RUN_DIR": str(tmp_path), "BMAD_LOOP_TASK_ID": "1-1-a-dev-1"} + payload = { + "session_id": "abc-123", + "transcript_path": "/home/u/.claude/projects/x/abc-123.jsonl", + "cwd": "/proj", + } + + written = write_relay_event("Stop", payload, environ) + + assert written is not None + assert written.name.endswith("-1-1-a-dev-1-Stop.json") + assert written.exists() + # atomic tmp+rename leaves nothing behind + assert not list((tmp_path / "events").glob("*.tmp")) + + event = _sole_event(tmp_path) + assert event["event"] == "Stop" + assert event["task_id"] == "1-1-a-dev-1" + assert event["session_id"] == "abc-123" + assert event["transcript_path"].endswith("abc-123.jsonl") + assert event["cwd"] == "/proj" + assert isinstance(event["ts"], int) + + +def test_write_camelcase_and_workspace_fallbacks(tmp_path): + """agy-style protojson: conversationId + workspacePaths for cwd.""" + environ = {"BMAD_LOOP_RUN_DIR": str(tmp_path), "BMAD_LOOP_TASK_ID": "t1"} + payload = { + "conversationId": "agy-3", + "transcriptPath": "/ws/.gemini/antigravity-cli/transcript.jsonl", + "workspacePaths": ["/ws"], + } + + write_relay_event("Stop", payload, environ) + + event = _sole_event(tmp_path) + assert event["session_id"] == "agy-3" + assert event["transcript_path"].endswith("transcript.jsonl") + assert event["cwd"] == "/ws" + + +def test_build_event_degrades_unusable_workspace_paths(): + """An empty/odd workspacePaths degrades to None, never IndexError.""" + event = build_event("Stop", {"workspacePaths": []}, ts=7, task_id="t1") + assert event["cwd"] is None + event = build_event("Stop", {"workspacePaths": [123]}, ts=7, task_id="t1") + assert event["cwd"] is None + + +# --- the CLI path ---------------------------------------------------------- + + +def test_cli_reads_payload_from_stdin(tmp_path, monkeypatch): + monkeypatch.setenv("BMAD_LOOP_RUN_DIR", str(tmp_path)) + monkeypatch.setenv("BMAD_LOOP_TASK_ID", "todo-1-dev-1") + monkeypatch.setattr("sys.stdin", io.StringIO('{"session_id": "s-1", "cwd": "/project"}')) + + assert cli.main(["relay", "Stop"]) == 0 + + event = _sole_event(tmp_path) + assert event["event"] == "Stop" + assert event["task_id"] == "todo-1-dev-1" + assert event["session_id"] == "s-1" + assert event["cwd"] == "/project" + + +def test_cli_noops_without_env(tmp_path, monkeypatch): + monkeypatch.delenv("BMAD_LOOP_RUN_DIR", raising=False) + monkeypatch.delenv("BMAD_LOOP_TASK_ID", raising=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("sys.stdin", io.StringIO('{"session_id": "s-1"}')) + + assert cli.main(["relay", "Stop"]) == 0 + assert not (tmp_path / "events").exists() + + +def test_cli_tolerates_garbage_stdin(tmp_path, monkeypatch): + monkeypatch.setenv("BMAD_LOOP_RUN_DIR", str(tmp_path)) + monkeypatch.setenv("BMAD_LOOP_TASK_ID", "t1") + monkeypatch.setattr("sys.stdin", io.StringIO("not json at all")) + + assert cli.main(["relay", "SessionEnd"]) == 0 + assert _sole_event(tmp_path)["session_id"] is None + + +def test_cli_rejects_non_canonical_event(monkeypatch): + """`event` choices come from CANONICAL_EVENTS — an unknown name is a parse error.""" + monkeypatch.setattr("sys.stdin", io.StringIO("{}")) + with pytest.raises(SystemExit) as exc: + cli.main(["relay", "Bogus"]) + assert exc.value.code == 2 + + +def test_cli_accepts_every_canonical_event(tmp_path, monkeypatch): + monkeypatch.setenv("BMAD_LOOP_RUN_DIR", str(tmp_path)) + for i, name in enumerate(sorted(CANONICAL_EVENTS)): + monkeypatch.setenv("BMAD_LOOP_TASK_ID", f"t{i}") + monkeypatch.setattr("sys.stdin", io.StringIO("{}")) + assert cli.main(["relay", name]) == 0 + written = {json.loads(p.read_text())["event"] for p in (tmp_path / "events").glob("*.json")} + assert written == CANONICAL_EVENTS + + +# --- schema parity with the standalone script ------------------------------ + + +def _run_standalone(event: str, env: dict, payload) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(HOOK_SCRIPT), event], + input=json.dumps(payload) if payload is not None else "", + env={"PATH": "/usr/bin:/bin", **env}, + capture_output=True, + text=True, + timeout=10, + ) + + +@pytest.mark.parametrize( + "payload", + [ + {"session_id": "abc", "transcript_path": "/t/abc.jsonl", "cwd": "/proj"}, + {"conversation_id": "conv-9"}, + {"conversationId": "agy", "transcriptPath": "/ws/t.jsonl", "workspacePaths": ["/ws"]}, + {"sessionId": "cop", "transcriptPath": "/c/events.jsonl"}, + {}, + ], +) +def test_standalone_and_primitive_share_one_schema(tmp_path, payload): + """The lone stdlib script and the primitive must emit identical events. + + They cannot share code (the script is installed standalone), so this pins the + inlined copy in bmad_loop_hook.py to bmad_loop.events on every payload dialect. + """ + script_dir = tmp_path / "script" + prim_dir = tmp_path / "prim" + + proc = _run_standalone( + "Stop", + {"BMAD_LOOP_RUN_DIR": str(script_dir), "BMAD_LOOP_TASK_ID": "t1"}, + payload, + ) + assert proc.returncode == 0, proc.stderr + + write_relay_event( + "Stop", + payload, + {"BMAD_LOOP_RUN_DIR": str(prim_dir), "BMAD_LOOP_TASK_ID": "t1"}, + ) + + from_script = _sole_event(script_dir) + from_prim = _sole_event(prim_dir) + # ts is a wall-clock nanosecond stamp; the rest of the schema must match exactly. + from_script.pop("ts") + from_prim.pop("ts") + assert from_script == from_prim + assert set(from_prim) == {"event", "task_id", "session_id", "transcript_path", "cwd"}