diff --git a/src/gpd/adapters/hermes.py b/src/gpd/adapters/hermes.py new file mode 100644 index 000000000..086ecbe97 --- /dev/null +++ b/src/gpd/adapters/hermes.py @@ -0,0 +1,468 @@ +"""Hermes Agent runtime adapter. + +Hermes Agent (Nous Research) is an OpenAI-compatible agent runtime with a +native MCP client and a skill system. GPD keeps the same split Codex uses: + +- Commands become SKILL.md files under the Hermes skills directory + (``%LOCALAPPDATA%\\Hermes\\skills\\`` on Windows; ``$HERMES_HOME/skills`` + otherwise) for local installs, and the shared user-level skills directory + for global installs. +- Agents install as Hermes skills too (Hermes has no separate agent-role + surface; skill files carry role instructions plus ``metadata.hermes`` + tool requirements). +- MCP servers are registered in Hermes ``config.yaml`` under + ``mcp_servers:`` (the native MCP client surface). + +Config directory: HERMES_HOME env var > %LOCALAPPDATA%\\Hermes +Skills directory: HERMES_SKILLS_DIR env var > /skills + +Hermes exposes the canonical GPD tool names directly (read_file, shell, +write_file, web_search, web_fetch, search_files, find_files, tool_search), +so the tool name map is intentionally small — only legacy/alias tokens +are remapped. + +The ``global_config_dir`` and ``resolve_global_config_dir`` hooks both route +through ``_hermes_global_config_dir()`` so the *installer* (which resolves the +target via ``resolve_global_config_dir``) and the *skill reader* (which uses +``skills_dir``) agree on the same Hermes config directory. On Windows that is +``%LOCALAPPDATA%\\Hermes``; elsewhere it is ``$HERMES_HOME`` or ``~/.hermes``. +This keeps installed GPD skills and the Hermes skills directory in one place. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +from gpd.adapters.base import RuntimeAdapter +from gpd.adapters.command_projection import ( + render_projected_command_shell_fences, + rewrite_projection_shell_bridge, +) +from gpd.adapters.install_utils import ( + compile_command_markdown_for_runtime, + compile_markdown_for_runtime, + install_gpd_content, + verify_installed, +) + +logger = logging.getLogger(__name__) + +_TOOL_NAME_MAP: dict[str, str] = { + # Canonical GPD names are already Hermes tool names; this map only + # remaps legacy/alias tokens that GPD uses in shared markdown. + "file_read": "read_file", + "file_write": "write_file", + "file_edit": "apply_patch", + "shell": "shell", + "search_files": "search_files", + "find_files": "find_files", + "web_search": "web_search", + "web_fetch": "web_fetch", + "notebook_edit": "notebook_edit", + "agent": "delegate_task", + "ask_user": "ask_user", + "todo_write": "todo_list", + "task": "delegate_task", + "slash_command": "tool_search", + "tool_search": "tool_search", +} + +# Hermes surfaces ``tool_search`` etc. as first-class; nothing is +# auto-discovered at runtime (the MCP servers are static in config.yaml). +_AUTO_DISCOVERED_TOOLS = frozenset() + +_GPD_MARKER = "" +_MANIFEST_HERMES_SKILLS_DIR_KEY = "hermes_skills_dir" + + +def _hermes_global_config_dir() -> Path: + """Resolve the Hermes global config directory. + + Precedence: HERMES_HOME -> %LOCALAPPDATA%\\Hermes (Windows) -> + ~/.hermes (POSIX). + """ + env_home = os.environ.get("HERMES_HOME") + if env_home: + return Path(env_home).expanduser().resolve() + if os.name == "nt": + local = os.environ.get("LOCALAPPDATA") + if local: + return Path(local) / "Hermes" + return Path.home() / ".hermes" + + +def _skill_dir_name(command_stem: str, prefix: str = "gpd") -> str: + """Map a GPD command stem to a Hermes skill directory name. + + Example: command ``plan-phase.md`` -> skill dir ``gpd-plan-phase``. + Nested paths (``sub/name.md``) keep their hierarchy: ``gpd-sub-name``. + """ + parts = Path(command_stem).with_suffix("").parts + flattened = "-".join(part for part in parts if part) + if not flattened: + flattened = "help" + return f"{prefix}-{flattened}" if not flattened.startswith(prefix) else flattened + + +def _inject_marker_after_frontmatter(content: str) -> str: + """Insert the GPD ownership marker after the YAML frontmatter. + + Codex does the same (marker lives between frontmatter and body) so the + runtime YAML parser sees the frontmatter first. + """ + if _GPD_MARKER in content: + return content + try: + from gpd.adapters.install_utils import ( + render_markdown_frontmatter, + split_markdown_frontmatter, + ) + except Exception: # pragma: no cover - defensive + return f"{_GPD_MARKER}\n{content}" + preamble, frontmatter, separator, body = split_markdown_frontmatter(content) + if not frontmatter: + return f"{_GPD_MARKER}\n{content}" + return render_markdown_frontmatter(preamble, frontmatter, separator, f"{_GPD_MARKER}\n{body}") + + +def _copy_commands_as_skills( + src_dir: Path, + skills_dir: Path, + prefix: str, + path_prefix: str, + *, + runtime: str, + install_scope: str | None, + src_root: Path, + explicit_target: bool, + adapter, + bridge_command: str | None = None, +) -> set[str]: + """Copy GPD command markdown into Hermes skill directories. + + Hermes expects: skills/gpd-plan-phase/SKILL.md + Source structure: commands/plan-phase.md -> gpd-plan-phase/SKILL.md + + Each command is projected through ``compile_command_markdown_for_runtime`` + (path placeholders, includes, command surface) and then through the + adapter's ``translate_shared_markdown`` so tool references in the body and + frontmatter (``file_read`` -> ``read_file``) are rewritten for this runtime + before writing. + """ + if not src_dir.exists(): + return set() + skills_dir.mkdir(parents=True, exist_ok=True) + generated: set[str] = set() + for cmd in sorted(src_dir.rglob("*.md")): + rel = cmd.relative_to(src_dir) + skill_name = _skill_dir_name(str(rel), prefix) + dest = skills_dir / skill_name / "SKILL.md" + dest.parent.mkdir(parents=True, exist_ok=True) + raw = cmd.read_text(encoding="utf-8") + content = compile_command_markdown_for_runtime( + raw, + runtime=runtime, + command_name=str(rel), + path_prefix=path_prefix, + install_scope=install_scope, + src_root=src_root, + explicit_target=explicit_target, + bridge_command=bridge_command, + ) + if bridge_command: + content = render_projected_command_shell_fences(content, bridge_command=bridge_command) + content = adapter.translate_shared_markdown(content, path_prefix, install_scope=install_scope) + dest.write_text(_inject_marker_after_frontmatter(content), encoding="utf-8") + generated.add(skill_name) + return generated + + +def _copy_agents_as_skills( + agents_src: Path, + skills_dir: Path, + prefix: str, + path_prefix: str, + *, + runtime: str, + install_scope: str | None, + src_root: Path, +) -> set[str]: + """Copy GPD agent role markdown into Hermes skill directories. + + Hermes has no separate agent-role surface: role briefs install as + skills named ``gpd-agent-``. + + Agent prompts go through ``compile_markdown_for_runtime`` with + ``protect_agent_prompt_body=True`` so ``$var``/``${VAR}`` shell examples in + the role brief body are not swallowed by Hermes prompt templating, and + placeholders/tool references are translated for this runtime. + """ + if not agents_src.exists(): + return set() + skills_dir.mkdir(parents=True, exist_ok=True) + generated: set[str] = set() + for agent in sorted(agents_src.rglob("*.md")): + skill_name = f"{prefix}-agent-{agent.stem}" + dest = skills_dir / skill_name / "SKILL.md" + dest.parent.mkdir(parents=True, exist_ok=True) + raw = agent.read_text(encoding="utf-8") + content = compile_markdown_for_runtime( + raw, + runtime=runtime, + path_prefix=path_prefix, + install_scope=install_scope, + src_root=src_root, + protect_agent_prompt_body=True, + ) + dest.write_text(_inject_marker_after_frontmatter(content), encoding="utf-8") + generated.add(skill_name) + return generated + + +class HermesAdapter(RuntimeAdapter): + """GPD runtime adapter for Hermes Agent.""" + + tool_name_map: dict[str, str] = _TOOL_NAME_MAP + auto_discovered_tools: frozenset[str] = _AUTO_DISCOVERED_TOOLS + strip_sub_tags_in_shared_markdown: bool = False + + def __init__(self) -> None: + super().__init__() + self._skills_dir: Path | None = None + self._generated_skill_dirs: set[str] = set() + self._install_explicit_target = False + + @property + def runtime_name(self) -> str: + return "hermes" + + @property + def global_config_dir(self) -> Path: + """Hermes global config directory (HERMES_HOME).""" + return _hermes_global_config_dir() + + def resolve_global_config_dir(self, *, home: Path | None = None) -> Path: + """Resolve the Hermes global config dir consistently everywhere. + + This overrides the catalog-backed default so the installer (which + calls ``resolve_target_dir`` -> ``resolve_global_config_dir``) and the + skill reader (``skills_dir``) agree on the same directory. Without it, + the catalog would target ``~/Hermes`` while skills read + ``%LOCALAPPDATA%\\Hermes``, splitting the install. + """ + del home # Hermes dir is not home-relative on Windows. + return _hermes_global_config_dir() + + @property + def skills_dir(self) -> Path: + """Hermes skills directory (where GPD skills get installed).""" + explicit = getattr(self, "_explicit_skills_dir", None) + if explicit is not None: + return explicit + env_skills = os.environ.get("HERMES_SKILLS_DIR") + if env_skills: + return Path(env_skills).expanduser().resolve() + return self.global_config_dir / "skills" + + @property + def config_file(self) -> Path: + """Hermes config.yaml (where MCP servers get registered).""" + return self.global_config_dir / "config.yaml" + + def local_skills_dir(self, target_dir: Path) -> Path: + """Workspace-local skills dir (project-scoped installs). + + ``target_dir`` is already the resolved Hermes config dir + (``/.hermes`` for local installs), so skills live + directly under it. + """ + return target_dir / "skills" + + # -- Install surface hooks --------------------------------------------- + + def install( + self, + gpd_root: Path, + target_dir: Path, + *, + is_global: bool = True, + skills_dir: Path | None = None, + explicit_target: bool = False, + ) -> dict[str, object]: + """Full GPD installation into a Hermes config directory. + + Mirrors the Codex adapter: an explicit *skills_dir* (used by tests and + project-scoped installs) wins; otherwise skills resolve to the global + skills directory (``skills_dir``) for global installs or to + ``/skills`` for local installs. + """ + self._install_explicit_target = explicit_target + self._explicit_skills_dir = skills_dir.resolve() if skills_dir is not None else None + try: + if skills_dir is not None: + self._skills_dir = self._explicit_skills_dir + elif is_global: + self._skills_dir = self.skills_dir + else: + self._skills_dir = self.local_skills_dir(target_dir) + self._generated_skill_dirs = set() + return super().install(gpd_root, target_dir, is_global=is_global, explicit_target=explicit_target) + finally: + self._skills_dir = None + self._explicit_skills_dir = None + self._install_explicit_target = False + + def _compute_path_prefix(self, target_dir: Path, is_global: bool) -> str: + if is_global or self._install_explicit_target: + return str(target_dir).replace("\\", "/") + "/" + return f"./{self.config_dir_name}/" + + def _install_commands(self, gpd_root: Path, target_dir: Path, path_prefix: str, failures: list[str]) -> int: + commands_src = gpd_root / "commands" + skills_dir = self._skills_dir + if skills_dir is None: + failures.append("command skills (skills_dir unset)") + return 0 + commands_exist = commands_src.exists() + generated = set() + if commands_exist: + generated = _copy_commands_as_skills( + commands_src, + skills_dir, + "gpd", + path_prefix, + runtime=self.runtime_name, + install_scope=self._current_install_scope_flag(), + src_root=gpd_root / "specs", + explicit_target=getattr(self, "_install_explicit_target", False), + adapter=self, + bridge_command=self.runtime_cli_bridge_command(target_dir), + ) + self._generated_skill_dirs = set(generated) + if not commands_exist or not generated: + failures.append("command skills") + return len(generated) + + def _install_content(self, gpd_root: Path, target_dir: Path, path_prefix: str, failures: list[str]) -> None: + """Install get-physics-done/ content via the shared installer. + + The shared ``install_gpd_content`` applies the adapter's + ``translate_shared_markdown`` transform to every installed reference, + so placeholders and tool names in spec content are correctly translated + for this runtime. On top of that, staged workflow bodies keep their + shell-executable ``gpd`` / ``gpd --raw`` lines in source form, so — + mirroring the Codex adapter — every fence is rewritten onto the + runtime CLI bridge command before writing. + """ + launcher = self.runtime_cli_bridge_command(target_dir) + + def _translate(content: str, prefix: str, install_scope: str | None = None) -> str: + translated = self.translate_shared_markdown(content, prefix, install_scope=install_scope) + return rewrite_projection_shell_bridge(translated, launcher) + + failures.extend( + install_gpd_content( + gpd_root / "specs", + target_dir, + path_prefix, + self.runtime_name, + install_scope=self._current_install_scope_flag(), + markdown_transform=_translate, + explicit_target=getattr(self, "_install_explicit_target", False), + ) + ) + + def _install_agents(self, gpd_root: Path, target_dir: Path, path_prefix: str, failures: list[str]) -> int: + agents_src = gpd_root / "agents" + skills_dir = self._skills_dir + if skills_dir is None: + failures.append("agents (skills_dir unset)") + return 0 + agents_exist = agents_src.exists() + generated = set() + if agents_exist: + generated = _copy_agents_as_skills( + agents_src, + skills_dir, + "gpd", + path_prefix, + runtime=self.runtime_name, + install_scope=self._current_install_scope_flag(), + src_root=gpd_root / "specs", + ) + self._generated_skill_dirs |= set(generated) + if not agents_exist or not generated: + failures.append("agents") + return len(generated) + + def _preflight_runtime_config(self, target_dir: Path, is_global: bool) -> None: + """Hermes config.yaml is only needed for MCP server registration; + a missing file is fine (Hermes creates defaults on first launch).""" + del target_dir, is_global + return None + + def missing_install_artifacts(self, target_dir: Path) -> tuple[str, ...]: + skills_dir = self._skills_dir + if skills_dir is None: + return ("command skills",) + return () if verify_installed(skills_dir) else ("command skills",) + + def project_markdown_surface( + self, + content: str, + *, + surface_kind: str, + path_prefix: str, + command_name: str | None = None, + bridge_command: str | None = None, + ) -> str: + if surface_kind != "command": + return super().project_markdown_surface( + content, + surface_kind=surface_kind, + path_prefix=path_prefix, + command_name=command_name, + bridge_command=bridge_command, + ) + base_surface = super().project_markdown_surface( + content, + surface_kind=surface_kind, + path_prefix=path_prefix, + command_name=command_name, + bridge_command=bridge_command, + ) + if bridge_command is None: + return base_surface + # Rewrite fenced shell ``gpd``/``gpd --raw`` invocations onto the runtime + # bridge so the projected command surface carries the pinned interpreter + # and (for checkout installs) the checkout Python, mirroring Codex. + return rewrite_projection_shell_bridge(base_surface, bridge_command) + + # -- Surface helpers --------------------------------------------------- + + def runtime_permissions_status(self, target_dir: Path, *, autonomy: str) -> dict[str, object]: + """Report that Hermes has no GPD-managed permission surface. + + Hermes does not expose an approval/permission sync contract the way + Claude Code / Codex do, so this reports an unsupported configured mode. + The override exists so the capability contract distinguishes this + adapter from the bare base implementation. + """ + return { + "runtime": self.runtime_name, + "desired_mode": "yolo" if autonomy == "yolo" else "default", + "configured_mode": "unsupported", + "config_aligned": autonomy != "yolo", + "requires_relaunch": False, + "managed_by_gpd": False, + "message": "Hermes Agent does not expose a GPD runtime-permissions sync surface.", + } + + def sync_runtime_permissions(self, target_dir: Path, *, autonomy: str) -> dict[str, object]: + status = self.runtime_permissions_status(target_dir, autonomy=autonomy) + return {**status, "changed": False, "sync_applied": False} + + +__all__ = ["HermesAdapter", "_hermes_global_config_dir"] \ No newline at end of file diff --git a/src/gpd/adapters/runtime_catalog.json b/src/gpd/adapters/runtime_catalog.json index 91d387bc9..d0c2b602c 100644 --- a/src/gpd/adapters/runtime_catalog.json +++ b/src/gpd/adapters/runtime_catalog.json @@ -244,5 +244,28 @@ "env_var": "COPILOT_CONFIG_DIR", "home_subpath": ".copilot" } + }, + { + "runtime_name": "hermes", + "display_name": "Hermes Agent", + "priority": 60, + "config_dir_name": ".hermes", + "install_flag": "--hermes", + "launch_command": "hermes", + "adapter_module": "gpd.adapters.hermes", + "adapter_class": "HermesAdapter", + "command_prefix": "/gpd:", + "native_include_support": false, + "activation_env_vars": ["HERMES_SESSION", "HERMES_HOME"], + "selection_flags": ["--hermes", "--hermes-agent"], + "selection_aliases": ["hermes", "hermes-agent"], + "manifest_file_prefixes": ["skills/"], + "manifest_metadata_list_policies": [ + {"key": "hermes_generated_skill_dirs", "value_kind": "path_segment", "item_prefix": "gpd-"} + ], + "validated_command_surface": "public_runtime_dollar_command", + "capabilities": {}, + "hook_payload": {}, + "global_config": {"strategy": "env_or_home", "env_var": "HERMES_HOME", "home_subpath": "Hermes"} } ] diff --git a/tests/adapters/projection_budget_support.py b/tests/adapters/projection_budget_support.py index 0c202ec37..ca686bf11 100644 --- a/tests/adapters/projection_budget_support.py +++ b/tests/adapters/projection_budget_support.py @@ -18,6 +18,7 @@ "copilot-cli": 6_700, "gemini": 7_400, "opencode": 6_900, + "hermes": 6_900, }, "execute-phase": { "claude-code": 3_426, @@ -25,6 +26,7 @@ "copilot-cli": 5_900, "gemini": 6_496, "opencode": 5_920, + "hermes": 5_920, }, "new-project": { "claude-code": 7_500, @@ -32,6 +34,7 @@ "copilot-cli": 9_000, "gemini": 9_800, "opencode": 9_200, + "hermes": 9_200, }, "write-paper": { "claude-code": 13_076, @@ -39,6 +42,7 @@ "copilot-cli": 15_200, "gemini": 12_692, "opencode": 15_578, + "hermes": 15_250, }, } STAGED_INIT_TARGET_COMMANDS = tuple(STAGED_INIT_COMMAND_PROJECTION_BUDGETS) @@ -50,18 +54,21 @@ "copilot-cli": {"chars": 8_000, "lines": 150}, "gemini": {"chars": 8_400, "lines": 150}, "opencode": {"chars": 7_950, "lines": 160}, + "hermes": {"chars": 7_950, "lines": 160}, }, "dimensional-analysis": { "codex": {"chars": 7_100, "lines": 145}, "copilot-cli": {"chars": 7_200, "lines": 145}, "gemini": {"chars": 8_000, "lines": 145}, "opencode": {"chars": 7_300, "lines": 160}, + "hermes": {"chars": 7_300, "lines": 160}, }, "review-knowledge": { "codex": {"chars": 9_800, "lines": 170}, "copilot-cli": {"chars": 10_800, "lines": 205}, "gemini": {"chars": 9_700, "lines": 165}, "opencode": {"chars": 11_200, "lines": 220}, + "hermes": {"chars": 11_200, "lines": 220}, }, } COMPACT_WORKFLOW_REFERENCE_TARGET_COMMANDS = tuple(COMPACT_WORKFLOW_REFERENCE_COMMAND_PROJECTION_BUDGETS) diff --git a/tests/adapters/test_hermes.py b/tests/adapters/test_hermes.py new file mode 100644 index 000000000..bb11e5bf4 --- /dev/null +++ b/tests/adapters/test_hermes.py @@ -0,0 +1,191 @@ +"""Tests for the Hermes Agent runtime adapter. + +Covers the two integration gaps the initial adapter had: + +1. **Global config resolution.** `global_config_dir`, `resolve_global_config_dir` + and `skills_dir` must all agree on the same Hermes config directory + (``HERMES_HOME`` -> ``%LOCALAPPDATA%\\Hermes`` on Windows). Previously the + installer (via ``resolve_target_dir``/catalog) and the skill reader + (``skills_dir``) diverged, splitting installed GPD content from the Hermes + skills directory. + +2. **Install-time projection.** Command and agent skills must be passed through + ``translate_shared_markdown`` so tool references (``file_read`` -> + ``read_file``) and ``{GPD_*}`` path placeholders are rewritten for this + runtime before writing. Previously skills were copied raw, leaving + ``file_read``/``file_write`` (which Hermes does not expose) in the body and + unresolved placeholders. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from gpd.adapters.hermes import HermesAdapter, _hermes_global_config_dir +from gpd.adapters.install_utils import ( + split_markdown_frontmatter, +) +from gpd.adapters.runtime_catalog import ( + resolve_global_config_dir, + get_runtime_descriptor, +) + +REPO_GPD_ROOT = Path(__file__).resolve().parents[2] / "src" / "gpd" + + +@pytest.fixture() +def adapter() -> HermesAdapter: + return HermesAdapter() + + +# --------------------------------------------------------------------------- +# 1. Config directory resolution stays consistent +# --------------------------------------------------------------------------- + + +def test_resolve_global_config_dir_matches_global_config_dir( + adapter: HermesAdapter, monkeypatch: pytest.MonkeyPatch +) -> None: + """The installer and the skill reader must resolve the same directory.""" + monkeypatch.delenv("HERMES_HOME", raising=False) + if os.name == "nt": + monkeypatch.setenv("LOCALAPPDATA", r"C:\fake\localappdata") + expected = Path(r"C:\fake\localappdata") / "Hermes" + else: + monkeypatch.setenv("HOME", "/fake/home") + expected = Path("/fake/home") / ".hermes" + + assert adapter.global_config_dir == expected + assert adapter.resolve_global_config_dir() == expected + assert adapter.skills_dir == expected / "skills" + + +def test_hermes_home_env_override_is_authoritative(adapter: HermesAdapter, monkeypatch: pytest.MonkeyPatch) -> None: + """``HERMES_HOME`` overrides the platform default everywhere.""" + monkeypatch.setenv("HERMES_HOME", "/custom/hermes") + expected = Path("/custom/hermes").expanduser().resolve() + assert adapter.global_config_dir == expected + assert adapter.resolve_global_config_dir() == expected + assert adapter.skills_dir == expected / "skills" + + +def test_hermes_skills_dir_env_override(adapter: HermesAdapter, monkeypatch: pytest.MonkeyPatch) -> None: + """``HERMES_SKILLS_DIR`` overrides the derived skills directory.""" + monkeypatch.setenv("HERMES_SKILLS_DIR", "/custom/skills") + assert adapter.skills_dir == Path("/custom/skills").expanduser().resolve() + + +# --------------------------------------------------------------------------- +# 2. Install-time projection rewrites tool names and placeholders +# --------------------------------------------------------------------------- + + +def test_translate_shared_markdown_maps_legacy_tool_names(adapter: HermesAdapter) -> None: + """Legacy tool names must map to Hermes-native names before writing.""" + content = "- Use `file_read` to read.\n- Use `file_write` to write.\n- `TODO_WRITE`?" + translated = adapter.translate_shared_markdown(content, "C:/fake/", install_scope="--global") + assert "file_read" not in translated + assert "read_file" in translated + assert "file_write" not in translated + assert "write_file" in translated + + +def test_translate_shared_markdown_resolves_path_placeholders(adapter: HermesAdapter) -> None: + """``{GPD_CONFIG_DIR}`` and friends collapse into concrete paths.""" + content = "config: {GPD_CONFIG_DIR}\nagents: {GPD_AGENTS_DIR}\n" + translated = adapter.translate_shared_markdown(content, "C:/fake/hermes/", install_scope="--global") + assert "{GPD_CONFIG_DIR}" not in translated + assert "{GPD_AGENTS_DIR}" not in translated + assert "C:/fake/hermes/" in translated + + +def _compile_one_skill(adapter: HermesAdapter, command_stem: str, tmp_path: Path) -> Path: + """Install a single command skill into a temp skills dir and return its path.""" + from gpd.adapters.hermes import _copy_commands_as_skills + + src = REPO_GPD_ROOT / "commands" + skills_dir = tmp_path / "skills" + generated = _copy_commands_as_skills( + src, + skills_dir, + "gpd", + str(tmp_path).replace("\\", "/") + "/", + runtime="hermes", + install_scope="--global", + src_root=REPO_GPD_ROOT / "specs", + explicit_target=False, + adapter=adapter, + ) + assert "gpd-plan-phase" in generated + return skills_dir / "gpd-plan-phase" / "SKILL.md" + + +def test_installed_command_skill_translates_tool_names(adapter: HermesAdapter, tmp_path: Path) -> None: + """A command installed as a skill must not contain legacy tool names.""" + skill_path = _compile_one_skill(adapter, "plan-phase", tmp_path) + text = skill_path.read_text(encoding="utf-8") + + # The marker should be present (ownership). + assert "" in text + + # Frontmatter must be intact and parseable as YAML. + _, frontmatter, _, _ = split_markdown_frontmatter(text) + assert "name:" in frontmatter + + # No unresolved path placeholders. + assert "{GPD_" not in text + + # No legacy tool names that Hermes does not expose. + assert "file_read" not in text + assert "file_write" not in text + + +def test_installed_agent_skill_has_no_unresolved_placeholders(adapter: HermesAdapter, tmp_path: Path) -> None: + """Agent skills install with their body protected and placeholders resolved.""" + from gpd.adapters.hermes import _copy_agents_as_skills + + agents_src = REPO_GPD_ROOT / "agents" + skills_dir = tmp_path / "skills" + generated = _copy_agents_as_skills( + agents_src, + skills_dir, + "gpd", + str(tmp_path).replace("\\", "/") + "/", + runtime="hermes", + install_scope="--global", + src_root=REPO_GPD_ROOT / "specs", + ) + assert generated + planner = skills_dir / "gpd-agent-gpd-planner" / "SKILL.md" + assert planner.exists() + text = planner.read_text(encoding="utf-8") + assert "{GPD_" not in text + assert text.strip() + + +def test_adapter_is_registered_in_catalog(adapter: HermesAdapter) -> None: + """Hermes is a first-class catalog runtime.""" + descriptor = get_runtime_descriptor("hermes") + assert descriptor is not None + assert descriptor.runtime_name == "hermes" + + +def test_hermes_config_resolution_beats_catalog_on_windows( + adapter: HermesAdapter, monkeypatch: pytest.MonkeyPatch +) -> None: + """On Windows the adapter's LOCALAPPDATA-based resolution must win over the + catalog's home-relative default (which would target ``~/Hermes``).""" + if os.name != "nt": + monkeypatch.skip("Windows-only behavior") + monkeypatch.delenv("HERMES_HOME", raising=False) + monkeypatch.setenv("LOCALAPPDATA", r"C:\real\localappdata") + + # The catalog defaults to the home subpath (~/Hermes); the adapter must + # correct it to %LOCALAPPDATA%\Hermes on Windows so installer and reader + # agree. + assert adapter.global_config_dir == Path(r"C:\real\localappdata") / "Hermes" + assert adapter.resolve_global_config_dir() == Path(r"C:\real\localappdata") / "Hermes" + assert adapter.skills_dir == Path(r"C:\real\localappdata") / "Hermes" / "skills" \ No newline at end of file diff --git a/tests/adapters/test_install_roundtrip.py b/tests/adapters/test_install_roundtrip.py index bfb5844ff..7061498ac 100644 --- a/tests/adapters/test_install_roundtrip.py +++ b/tests/adapters/test_install_roundtrip.py @@ -22,6 +22,7 @@ from gpd.adapters.codex import CodexAdapter from gpd.adapters.copilot_cli import CopilotCliAdapter from gpd.adapters.gemini import GeminiAdapter +from gpd.adapters.hermes import HermesAdapter from gpd.adapters.install_utils import ( convert_tool_references_in_body, expand_at_includes, @@ -301,6 +302,14 @@ def _install_real_repo_for_runtime(tmp_path: Path, runtime: str, source_root: Pa CopilotCliAdapter().install(source_root, target) return target + if runtime == "hermes": + target = tmp_path / ".hermes" + target.mkdir() + skills = tmp_path / "skills" + skills.mkdir(exist_ok=True) + HermesAdapter().install(source_root, target, is_global=False, skills_dir=skills) + return target + raise AssertionError(f"Unsupported runtime {runtime}") @@ -431,6 +440,9 @@ def _read_runtime_command_prompt(tmp_path: Path, target: Path, runtime: str, com if runtime in {"copilot-cli", "opencode"}: return (target / "command" / f"gpd-{command_name}.md").read_text(encoding="utf-8") + if runtime == "hermes": + return (tmp_path / "skills" / f"gpd-{command_name}" / "SKILL.md").read_text(encoding="utf-8") + raise AssertionError(f"Unsupported runtime {runtime}") @@ -462,6 +474,8 @@ def _installed_command_kind(runtime: str) -> str: return "copilot_flat_md" if runtime == "opencode": return "opencode_flat_md" + if runtime == "hermes": + return "hermes_skill" raise AssertionError(f"Unsupported runtime {runtime}") @@ -512,12 +526,17 @@ def _read_runtime_update_surface(tmp_path: Path, target: Path, runtime: str) -> if runtime in {"copilot-cli", "opencode"}: return (target / "command" / "gpd-update.md").read_text(encoding="utf-8") + if runtime == "hermes": + return (tmp_path / "skills" / "gpd-update" / "SKILL.md").read_text(encoding="utf-8") + raise AssertionError(f"Unsupported runtime {runtime}") def _read_runtime_agent_prompt(target: Path, runtime: str, agent_name: str) -> str: if runtime in {"claude-code", "codex", "copilot-cli", "gemini", "opencode"}: return (target / "agents" / f"{agent_name}.md").read_text(encoding="utf-8") + if runtime == "hermes": + return (target.parent / "skills" / f"gpd-agent-{agent_name}" / "SKILL.md").read_text(encoding="utf-8") raise AssertionError(f"Unsupported runtime {runtime}") diff --git a/tests/adapters/test_runtime_projected_prompt_parity.py b/tests/adapters/test_runtime_projected_prompt_parity.py index e53286d78..6fe8c29a3 100644 --- a/tests/adapters/test_runtime_projected_prompt_parity.py +++ b/tests/adapters/test_runtime_projected_prompt_parity.py @@ -92,18 +92,21 @@ "copilot-cli": {"chars": 5_100, "lines": 100}, "gemini": {"chars": 4_900, "lines": 90}, "opencode": {"chars": 5_200, "lines": 100}, + "hermes": {"chars": 5_200, "lines": 100}, }, "explain": { "codex": {"chars": 6_150, "lines": 130}, "copilot-cli": {"chars": 6_600, "lines": 145}, "gemini": {"chars": 6_950, "lines": 130}, "opencode": {"chars": 6_550, "lines": 145}, + "hermes": {"chars": 6_550, "lines": 145}, }, "list-phase-assumptions": { "codex": {"chars": 7_900, "lines": 155}, "copilot-cli": {"chars": 7_700, "lines": 160}, "gemini": {"chars": 7_400, "lines": 140}, "opencode": {"chars": 7_850, "lines": 160}, + "hermes": {"chars": 7_850, "lines": 160}, }, } COMPACT_WORKFLOW_REFERENCE_PROJECTION_BUDGETS = { @@ -117,6 +120,7 @@ "copilot-cli": 6_600, "gemini": 7_150, "opencode": 6_650, + "hermes": 6_650, }, "execute-phase": { "claude-code": 3_450, @@ -124,6 +128,7 @@ "copilot-cli": 5_800, "gemini": 6_400, "opencode": 5_850, + "hermes": 5_850, }, "new-project": { "claude-code": 7_300, @@ -131,6 +136,7 @@ "copilot-cli": 8_800, "gemini": 9_550, "opencode": 8_950, + "hermes": 8_950, }, "write-paper": { "claude-code": 12_950, @@ -138,6 +144,7 @@ "copilot-cli": 15_100, "gemini": 12_300, "opencode": 15_250, + "hermes": 15_250, }, } COMPACT_WORKFLOW_STALE_WRAPPER_PHRASES = { diff --git a/tests/adapters/test_runtime_projection_diagnostics_budget.py b/tests/adapters/test_runtime_projection_diagnostics_budget.py index 2fa40f605..03f0ac223 100644 --- a/tests/adapters/test_runtime_projection_diagnostics_budget.py +++ b/tests/adapters/test_runtime_projection_diagnostics_budget.py @@ -64,18 +64,21 @@ "copilot-cli": {"chars": 5_100, "lines": 100}, "gemini": {"chars": 4_900, "lines": 90}, "opencode": {"chars": 5_200, "lines": 100}, + "hermes": {"chars": 5_200, "lines": 100}, }, "explain": { "codex": {"chars": 6_150, "lines": 130}, "copilot-cli": {"chars": 6_470, "lines": 145}, "gemini": {"chars": 6_950, "lines": 130}, "opencode": {"chars": 6_550, "lines": 145}, + "hermes": {"chars": 6_550, "lines": 145}, }, "list-phase-assumptions": { "codex": {"chars": 7_900, "lines": 155}, "copilot-cli": {"chars": 7_700, "lines": 160}, "gemini": {"chars": 7_400, "lines": 140}, "opencode": {"chars": 7_850, "lines": 160}, + "hermes": {"chars": 7_850, "lines": 160}, }, } COMPACT_WORKFLOW_REFERENCE_PROJECTION_BUDGETS = { @@ -90,6 +93,7 @@ "copilot-cli": 6_600, "gemini": 7_150, "opencode": 6_650, + "hermes": 6_650, }, "execute-phase": { "claude-code": 3_450, @@ -97,6 +101,7 @@ "copilot-cli": 5_800, "gemini": 6_400, "opencode": 5_850, + "hermes": 5_850, }, "new-project": { "claude-code": 7_300, @@ -104,6 +109,7 @@ "copilot-cli": 8_800, "gemini": 9_550, "opencode": 8_950, + "hermes": 8_950, }, "write-paper": { "claude-code": 12_950, @@ -111,6 +117,7 @@ "copilot-cli": 15_100, "gemini": 12_300, "opencode": 15_250, + "hermes": 15_250, }, }