Skip to content
Open
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
11 changes: 8 additions & 3 deletions src/bmad_loop/adapters/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,14 +283,15 @@ def build_command(self, spec: SessionSpec) -> str:

# --------------------------------------------------------------- adapter

def start_session(self, spec: SessionSpec) -> SessionHandle:
def _prepare_session(self, spec: SessionSpec) -> None:
task_dir = self.tasks_dir / spec.task_id
task_dir.mkdir(parents=True, exist_ok=True)
(task_dir / "prompt.txt").write_text(spec.prompt + "\n", encoding="utf-8")
# A re-armed/resumed run reuses task_ids; drop any prior cycle's result
# so a session that writes nothing can't be read as a stale completion.
(task_dir / "result.json").unlink(missing_ok=True)

def _launch_session(self, spec: SessionSpec, command: str) -> SessionHandle:
self._ensure_session(spec.cwd)
# Stamped before launch: hook events carry wall-clock ns, and
# wait_for_completion ignores anything older than this floor so a reused
Expand All @@ -300,8 +301,8 @@ def start_session(self, spec: SessionSpec) -> SessionHandle:
self.session_name,
spec.task_id[-40:],
spec.cwd,
{**self.profile.env, **spec.env},
self.build_command(spec),
self.interactive_env(spec),
command,
)
log_file = self.logs_dir / f"{spec.task_id}.log"
# pipe_pane tolerates the window having already died (a CLI that crashes on
Expand All @@ -310,6 +311,10 @@ def start_session(self, spec: SessionSpec) -> SessionHandle:
self.mux.pipe_pane(window_id, log_file)
return SessionHandle(task_id=spec.task_id, native_id=window_id, launched_ns=launched_ns)

def start_session(self, spec: SessionSpec) -> SessionHandle:
self._prepare_session(spec)
return self._launch_session(spec, self.build_command(spec))

def wait_for_completion(self, handle: SessionHandle, spec: SessionSpec) -> SessionResult:
deadline = time.monotonic() + spec.timeout_s
# Wall-clock co-bound (#157): a host suspend freezes time.monotonic(),
Expand Down
53 changes: 53 additions & 0 deletions src/bmad_loop/adapters/hermes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Interactive Hermes Agent adapter for attachable BMAD Loop tmux sessions."""

from __future__ import annotations

import time
from typing import Any
from ..bmadconfig import ProjectPaths
from .base import SessionHandle, SessionSpec
from .generic import GenericAdapter, _DevSynthesisMixin


class HermesStartupError(RuntimeError):
"""Hermes exited before the adapter could deliver the initial prompt."""


class HermesAdapter(GenericAdapter):
"""Launch normal Hermes CLI sessions and inject prompts after startup."""

STARTUP_GRACE_S = 2.0

def interactive_argv(self, spec: SessionSpec) -> list[str]:
extra = self.extra_args
if extra is None:
extra = self.profile.bypass_args
argv = [self.binary, *self.profile.launch_args, *extra]
if spec.model:
argv += [self.profile.model_flag, spec.model]
return argv

def interactive_env(self, spec: SessionSpec) -> dict[str, str]:
env = super().interactive_env(spec)
# Hermes discovers project-local skills through this explicit directory.
# Respect a caller override for custom shared skill layouts.
env.setdefault("HERMES_PROJECT_SKILLS", str((spec.cwd / self.profile.skill_tree).resolve()))
return env

def start_session(self, spec: SessionSpec) -> SessionHandle:
self._prepare_session(spec)
handle = self._launch_session(spec, self.build_command(spec))
time.sleep(self.STARTUP_GRACE_S)
if not self._window_alive(handle):
raise HermesStartupError(f"{spec.task_id}: pane exited during Hermes startup")
self.send_text(handle, self.profile.render_prompt(spec.prompt))
return handle


class HermesDevAdapter(_DevSynthesisMixin, HermesAdapter):
"""Hermes dev/review adapter that synthesizes bmad-dev-auto results from specs."""

def __init__(self, *args: Any, paths: ProjectPaths, **kwargs: Any):
super().__init__(*args, **kwargs)
self.paths = paths
self._configure_dev_knobs()
26 changes: 25 additions & 1 deletion src/bmad_loop/adapters/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,15 @@
"gemini-settings-json",
"copilot-settings-json",
"antigravity-hooks-json",
"hermes-config-yaml",
# hookless: the adapter observes completion itself (HTTP/SSE transport) —
# no hook config is ever written, so config_path/events must stay empty.
"none",
}
CANONICAL_EVENTS = {"SessionStart", "Stop", "SessionEnd", "PreCompact"}
USER_PROFILES_REL = Path(".bmad-loop") / "profiles"
ADAPTER_KINDS = {"generic", "hermes"}
HOOK_SCOPES = {"project", "user"}

# legacy adapter names from older policy.toml files, plus friendly short names
ALIASES = {"claude-code-tmux": "claude", "opencode": "opencode-http"}
Expand All @@ -54,6 +57,8 @@ class CLIProfile:
name: str
binary: str
hooks: HookSpec
adapter: str = "generic"
hook_scope: str = "project"
# project-relative tree this CLI reads skills from, e.g. ".claude/skills"
# (claude) or ".agents/skills" (codex/gemini); `bmad-loop init` installs the
# bundled bmad-loop-* skills here.
Expand Down Expand Up @@ -117,6 +122,17 @@ def fail(msg: str) -> ProfileError:
if not name or not binary:
raise fail("'name' and 'binary' are required")

adapter = str(doc.get("adapter", "generic"))
if adapter not in ADAPTER_KINDS:
raise fail(f"adapter must be one of {sorted(ADAPTER_KINDS)}: got {adapter!r}")
hook_scope = str(doc.get("hook_scope", "project"))
if hook_scope not in HOOK_SCOPES:
raise fail(f"hook_scope must be one of {sorted(HOOK_SCOPES)}: got {hook_scope!r}")
if adapter == "hermes" and hook_scope != "user":
raise fail('adapter = "hermes" requires hook_scope = "user"')
if hook_scope == "user" and adapter != "hermes":
raise fail('hook_scope = "user" requires adapter = "hermes"')

hooks_d = doc.get("hooks")
if not isinstance(hooks_d, dict):
raise fail("missing [hooks] table")
Expand All @@ -132,7 +148,13 @@ def fail(msg: str) -> ProfileError:
events: dict[str, str] = {}
else:
config_path = str(hooks_d.get("config_path", ""))
if not config_path or is_absolute_path(config_path) or has_parent_ref(config_path):
if hook_scope == "user":
if dialect != "hermes-config-yaml" or config_path != "config.yaml":
raise fail(
'user-scoped hooks require dialect = "hermes-config-yaml" '
'and config_path = "config.yaml"'
)
elif not config_path or is_absolute_path(config_path) or has_parent_ref(config_path):
raise fail("hooks.config_path must be a project-relative path")
events_d = hooks_d.get("events")
if not isinstance(events_d, dict) or not events_d:
Expand Down Expand Up @@ -170,6 +192,8 @@ def fail(msg: str) -> ProfileError:
name=name,
binary=binary,
hooks=HookSpec(dialect=dialect, config_path=config_path, events=events),
adapter=adapter,
hook_scope=hook_scope,
skill_tree=skill_tree,
prompt_template=str(doc.get("prompt_template", "{prompt}")),
launch_args=tuple(str(a) for a in doc.get("launch_args", ())),
Expand Down
59 changes: 36 additions & 23 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
verify,
)
from .adapters.base import CodingCLIAdapter
from .adapters.profile import CANONICAL_EVENTS
from .checks import Finding, ValidationReport
from .hermes_hooks import relay_event

# The --json document builders live in documents.py (the library-level projection
# layer a non-CLI frontend imports). The schema constants are re-exported rather
Expand Down Expand Up @@ -199,11 +201,20 @@ def _make_adapters(project: Path, run_dir: Path, policy) -> dict[str, CodingCLIA
stop_without_result_nudges=cfg.stop_without_result_nudges,
mux=mux,
)
by_cfg[key] = (
GenericDevAdapter(**common, paths=paths)
if synthesizes
else GenericAdapter(**common)
)
if profile.adapter == "hermes":
from .adapters.hermes import HermesAdapter, HermesDevAdapter

by_cfg[key] = (
HermesDevAdapter(**common, paths=paths)
if synthesizes
else HermesAdapter(**common)
)
else:
by_cfg[key] = (
GenericDevAdapter(**common, paths=paths)
if synthesizes
else GenericAdapter(**common)
)
adapters[role] = by_cfg[key]
return adapters

Expand Down Expand Up @@ -339,8 +350,6 @@ def _platform_preflight() -> list[Finding]:


def cmd_validate(args: argparse.Namespace) -> int:
from .install import relay_registered

project = _project(args)
report = ValidationReport()

Expand Down Expand Up @@ -449,20 +458,12 @@ def cmd_validate(args: argparse.Namespace) -> int:
{"profile": profile.name},
)
continue
hook_config = project / profile.hooks.config_path
hooks_ok = False
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
)
except json.JSONDecodeError:
report.fail(
"hooks.config-parse",
f"{hook_config} is not valid JSON",
{"profile": profile.name, "config_path": str(hook_config)},
)
hook_config = (
install.hermes_config_path(os.environ)
if profile.hook_scope == "user"
else project / profile.hooks.config_path
)
hooks_ok = install.hooks_registered(project, profile)
if hooks_ok:
report.ok(
"hooks.registered",
Expand Down Expand Up @@ -2254,6 +2255,16 @@ def cmd_init(args: argparse.Namespace) -> int:
return install_into(project, clis=clis, skills=args.skills, force_skills=args.force_skills)


def cmd_relay(args: argparse.Namespace) -> int:
"""Receive a Hermes post-turn hook payload and emit a canonical BMAD event."""
try:
payload = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
payload = {}
relay_event(args.event, payload if isinstance(payload, dict) else {}, os.environ)
return 0


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="bmad-loop",
Expand All @@ -2275,7 +2286,7 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser:
"--cli",
action="append",
metavar="PROFILE",
help="CLI profile(s) to register hooks for (claude | codex | gemini | copilot | "
help="CLI profile(s) to register hooks for (claude | codex | gemini | copilot | hermes | "
"antigravity | opencode-http (alias: opencode) | custom; "
"repeatable; default: profiles referenced by .bmad-loop/policy.toml, or claude)",
)
Expand All @@ -2290,6 +2301,8 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser:
action="store_true",
help="overwrite bmad-loop-* skill dirs that already exist (default: skip them)",
)
relay_p = add("relay", cmd_relay, "write one canonical event from a coding-CLI hook payload")
relay_p.add_argument("event", choices=sorted(CANONICAL_EVENTS))
validate_p = add("validate", cmd_validate, "preflight checks; exit non-zero on failure")
validate_p.add_argument(
"--spec",
Expand Down Expand Up @@ -2331,7 +2344,7 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser:
)
probe_p.add_argument(
"cli",
help="CLI profile name (claude | codex | gemini | copilot | antigravity | custom; "
help="CLI profile name (claude | codex | gemini | copilot | hermes | antigravity | custom; "
"opencode-http is HTTP-driven — nothing to probe)",
)
probe_p.add_argument(
Expand Down
17 changes: 17 additions & 0 deletions src/bmad_loop/data/profiles/hermes.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Hermes Agent uses its normal interactive REPL in an attachable tmux pane.
# The initial BMAD prompt is injected after startup rather than passed as argv.
name = "hermes"
binary = "hermes"
adapter = "hermes"
hook_scope = "user"
prompt_template = "Use the ${skill} skill now: {args}"
launch_args = ["--cli", "--accept-hooks", "--yolo"]
model_flag = "--model"
usage_parser = "none"
skill_tree = ".agents/skills"
first_run_note = "run `hermes --cli` once and approve the bmad-loop relay hook; spawned sessions pass --accept-hooks"

[hooks]
dialect = "hermes-config-yaml"
config_path = "config.yaml"
events = { post_llm_call = "Stop" }
104 changes: 104 additions & 0 deletions src/bmad_loop/hermes_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Hermes user-config hooks and the package-owned BMAD event relay."""

from __future__ import annotations

import copy
import json
import os
import time
from collections.abc import Mapping
from pathlib import Path
from typing import Any

HERMES_RELAY_COMMAND = "bmad-loop relay Stop"
HERMES_RELAY_HOOK = {"command": HERMES_RELAY_COMMAND, "timeout": 10}


def hermes_config_path(environ: Mapping[str, str]) -> Path:
"""Return the active Hermes config without reading credentials or config content."""
home = environ.get("HERMES_HOME", "~/.hermes")
return Path(home).expanduser() / "config.yaml"


def _copy_hook_entries(config: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
updated = copy.deepcopy(config)
hooks = updated.get("hooks")
if hooks is None:
hooks = {}
updated["hooks"] = hooks
if not isinstance(hooks, dict):
raise ValueError("Hermes config hooks must be a mapping")
entries = hooks.get("post_llm_call")
if entries is None:
entries = []
hooks["post_llm_call"] = entries
if not isinstance(entries, list) or not all(isinstance(entry, dict) for entry in entries):
raise ValueError("Hermes hooks.post_llm_call must be a list of mappings")
return updated, entries


def merge_hermes_stop_hook(config: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""Add the managed relay once while preserving all unrelated Hermes hooks."""
updated, entries = _copy_hook_entries(config)
if any(entry.get("command") == HERMES_RELAY_COMMAND for entry in entries):
return updated, False
entries.append(copy.deepcopy(HERMES_RELAY_HOOK))
return updated, True


def remove_hermes_stop_hook(config: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""Remove only the managed relay, pruning empty maps left by that removal."""
updated = copy.deepcopy(config)
hooks = updated.get("hooks")
if not isinstance(hooks, dict):
return updated, False
entries = hooks.get("post_llm_call")
if not isinstance(entries, list):
return updated, False
kept = [entry for entry in entries if not (isinstance(entry, dict) and entry.get("command") == HERMES_RELAY_COMMAND)]
if len(kept) == len(entries):
return updated, False
if kept:
hooks["post_llm_call"] = kept
else:
hooks.pop("post_llm_call", None)
if not hooks:
updated.pop("hooks", None)
return updated, True


def _first_workspace(payload: Mapping[str, Any]) -> str | None:
paths = payload.get("workspacePaths")
if isinstance(paths, list) and paths and isinstance(paths[0], str):
return paths[0]
return None


def relay_event(event_name: str, payload: Mapping[str, Any], environ: Mapping[str, str]) -> bool:
"""Atomically write a canonical BMAD event only from an active BMAD session."""
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 False

ts = time.time_ns()
event = {
"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),
}
events_dir = Path(run_dir) / "events"
events_dir.mkdir(parents=True, exist_ok=True)
final = events_dir / f"{ts}-{task_id}-{event_name}.json"
temporary = final.with_suffix(".json.tmp")
temporary.write_text(json.dumps(event), encoding="utf-8")
os.replace(temporary, final)
return True
Loading