Skip to content
Draft
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
32 changes: 32 additions & 0 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Event>``, 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

Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/bmad_loop/data/bmad_loop_hook.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
93 changes: 93 additions & 0 deletions src/bmad_loop/events.py
Original file line number Diff line number Diff line change
@@ -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 ``<run_dir>/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 <Event>`` 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 <Event>`` 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
205 changes: 205 additions & 0 deletions tests/test_relay_event.py
Original file line number Diff line number Diff line change
@@ -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"}