Parcourir la source

Merge pull request #2025 from obra/hermes-harness-rebase

feat(hermes): Hermes Agent harness support — eval-verified pre_llm_call bootstrap
Drew Ritter il y a 1 mois
Parent
commit
9be44ebf40

+ 6 - 0
.gitignore

@@ -11,3 +11,9 @@ triage/
 # development (see CLAUDE.md / README.md). It is not part of the published
 # plugin, so the whole directory is ignored here.
 evals/
+
+# Python
+__pycache__/
+*.pyc
+*.pyo
+.pytest_cache/

+ 104 - 0
.hermes-plugin/__init__.py

@@ -0,0 +1,104 @@
+import os
+import re
+from pathlib import Path
+
+BOOTSTRAP_MARKER = "superpowers:using-superpowers bootstrap for hermes"
+
+
+def _skills_dir() -> str:
+    """Locate the stock skills/ tree for either supported install layout.
+
+    - git-clone install (`hermes plugins install obra/superpowers`): the plugin
+      dir is the repo root, so `.hermes-plugin/` and `skills/` are siblings and
+      this module resolves `../skills`.
+    - flattened install (plugin files copied to the plugin dir root): `skills/`
+      sits next to this module.
+
+    Raises loudly when neither matches — a bootstrap that silently skips is how
+    a broken install masquerades as a working one.
+    """
+    here = os.path.dirname(os.path.realpath(__file__))
+    candidates = (
+        os.path.realpath(os.path.join(here, "..", "skills")),
+        os.path.realpath(os.path.join(here, "skills")),
+    )
+    for cand in candidates:
+        if os.path.isfile(os.path.join(cand, "using-superpowers", "SKILL.md")):
+            return cand
+    raise RuntimeError(
+        "superpowers plugin: cannot find the skills/ tree "
+        f"(looked at {candidates}). Reinstall with "
+        "`hermes plugins install obra/superpowers`."
+    )
+
+
+def _strip_frontmatter(content: str) -> str:
+    match = re.match(r"^---\n[\s\S]*?\n---\n([\s\S]*)$", content)
+    return (match.group(1) if match else content).strip()
+
+
+def _build_bootstrap(skills_dir: str) -> str:
+    with open(
+        os.path.join(skills_dir, "using-superpowers", "SKILL.md"),
+        encoding="utf-8",
+    ) as f:
+        body = _strip_frontmatter(f.read())
+
+    tools_path = os.path.join(
+        skills_dir, "using-superpowers", "references", "hermes-tools.md"
+    )
+    with open(tools_path, encoding="utf-8") as f:
+        tool_mapping = f.read().strip()
+
+    return (
+        f"<EXTREMELY_IMPORTANT>\n"
+        f"{BOOTSTRAP_MARKER}\n\n"
+        f"You have superpowers.\n\n"
+        f"The using-superpowers skill content is included below and is already "
+        f"loaded for this Hermes session. Follow it now. "
+        f"Do not try to load using-superpowers again.\n\n"
+        f"{body}\n\n"
+        f"## Loading Superpowers Skills on Hermes\n\n"
+        f"Superpowers skills are registered with Hermes' native skill loader: "
+        f'invoke one with `skill_view("superpowers:skill-name")` '
+        f'(for example `skill_view("superpowers:brainstorming")`). '
+        f"If a namespaced lookup returns 'not found', read the skill file "
+        f"directly instead:\n"
+        f'`read_file("{skills_dir}/skill-name/SKILL.md")`\n\n'
+        f"The superpowers skills directory is: `{skills_dir}`\n\n"
+        f"{tool_mapping}\n"
+        f"</EXTREMELY_IMPORTANT>"
+    )
+
+
+def register(ctx):
+    skills_dir = _skills_dir()
+    bootstrap = _build_bootstrap(skills_dir)
+
+    # Register every stock skill with Hermes' native loader so skill_view can
+    # load them on demand. Standard markdown; no conversion (plugin guide).
+    # register_skill requires a pathlib.Path — a str raises AttributeError and
+    # hermes silently disables the whole plugin (verified 2026-07-23).
+    for name in sorted(os.listdir(skills_dir)):
+        skill_md = os.path.join(skills_dir, name, "SKILL.md")
+        if os.path.isfile(skill_md):
+            ctx.register_skill(name, Path(skill_md))
+
+    # pre_llm_call returning {"context": ...} is the documented injection path
+    # (on_session_start return values are ignored, and ctx.inject_message
+    # refuses from that hook — verified empirically 2026-07-23). The context is
+    # appended to the first turn's user message.
+    def pre_llm_call(
+        session_id=None,
+        user_message=None,
+        conversation_history=None,
+        is_first_turn=None,
+        model=None,
+        platform=None,
+        **kwargs,
+    ):
+        if is_first_turn:
+            return {"context": bootstrap}
+        return None
+
+    ctx.register_hook("pre_llm_call", pre_llm_call)

+ 6 - 0
.hermes-plugin/plugin.yaml

@@ -0,0 +1,6 @@
+name: superpowers
+version: 6.2.0
+description: Superpowers skills and workflow bootstrap for Hermes Agent
+author: obra
+provides_hooks:
+  - pre_llm_call

+ 13 - 1
README.md

@@ -30,7 +30,7 @@ Superpowers is a complete software development methodology for your coding agent
 
 ## Quickstart
 
-Give your agent Superpowers: [Claude Code](#claude-code), [Antigravity](#antigravity), [Codex App](#codex-app), [Codex CLI](#codex-cli), [Cursor](#cursor), [Factory Droid](#factory-droid), [Gemini CLI](#gemini-cli), [GitHub Copilot CLI](#github-copilot-cli), [Kimi Code](#kimi-code), [OpenCode](#opencode), [Pi](#pi).
+Give your agent Superpowers: [Claude Code](#claude-code), [Antigravity](#antigravity), [Codex App](#codex-app), [Codex CLI](#codex-cli), [Cursor](#cursor), [Factory Droid](#factory-droid), [Gemini CLI](#gemini-cli), [GitHub Copilot CLI](#github-copilot-cli), [Hermes Agent](#hermes-agent), [Kimi Code](#kimi-code), [OpenCode](#opencode), [Pi](#pi).
 
 ## How it works
 
@@ -218,6 +218,18 @@ pi -e /path/to/superpowers
 
 The Pi package loads the Superpowers skills and a small extension that injects the `using-superpowers` bootstrap at session startup and again after compaction. Pi has native skills, so no compatibility `Skill` tool is required. Subagent and task-list tools remain optional Pi companion packages.
 
+### Hermes Agent
+
+Install Superpowers as a Hermes plugin from this repository:
+
+```bash
+hermes plugins install obra/superpowers --enable
+```
+
+Restart any active Hermes sessions after installing. Note: Hermes has no
+post-compaction hook, so a very long session that compacts over its first
+turn loses the bootstrap — start a fresh session if skills stop triggering.
+
 ## The Basic Workflow
 
 1. **brainstorming** - Activates before writing code. Refines rough ideas through questions, explores alternatives, presents design in sections for validation. Saves design document.

+ 1 - 0
skills/using-superpowers/SKILL.md

@@ -56,6 +56,7 @@ If your harness appears here, read its reference file for special instructions:
 - Codex: `references/codex-tools.md`
 - Pi: `references/pi-tools.md`
 - Antigravity: `references/antigravity-tools.md`
+- Hermes Agent: `references/hermes-tools.md`
 
 ## User Instructions
 

+ 56 - 0
skills/using-superpowers/references/hermes-tools.md

@@ -0,0 +1,56 @@
+# Hermes Agent Tool Mapping
+
+Skills speak in actions ("dispatch a subagent", "create a todo", "read a file"). On Hermes Agent these resolve to the tools below.
+
+## Tools
+
+| Action skills request | Hermes tool |
+|---|---|
+| Read a file | `read_file` |
+| Create a new file | `write_file` |
+| Edit a file (targeted patch) | `patch` |
+| Run a shell command | `terminal` |
+| Search file contents | `search_files` |
+| Find files by name | `terminal` with `find` |
+| Fetch a URL / read a webpage | `web_extract(urls=[...])` |
+| Search the web | `web_search(query=...)` |
+| Dispatch a subagent | `delegate_task(goal=..., context=..., toolsets=[...], role="leaf")` |
+| Task tracking | `todo` tool |
+| Invoke a skill | `skill_view("skill-name")` |
+
+## Instructions file
+
+When a skill mentions "your instructions file," on Hermes Agent this is **`AGENTS.md`** in the project directory, or **`SOUL.md`** globally at `~/.hermes/SOUL.md`.
+
+## Invoking a skill
+
+Hermes Agent has a `skills` toolset with `skill_view` and `skills_list` tools.
+To invoke a superpowers skill, use:
+
+```
+skill_view("brainstorming")
+skill_view("test-driven-development")
+```
+
+If `skill_view` cannot find a superpowers skill (it may not appear in the catalog
+until the plugin fully registers it), fall back to reading the SKILL.md directly:
+
+```
+read_file(path="~/.hermes/plugins/superpowers/skills/<skill-name>/SKILL.md")
+```
+
+This fallback is the same mechanism used by other harnesses without native skill loading.
+
+## Subagent dispatch
+
+Use `delegate_task` to spawn isolated subagents for parallel or sequential workstreams:
+
+```
+delegate_task(goal="...", context="...", toolsets=[...], role="leaf")
+```
+
+If `delegate_task` is unavailable, do the work inline rather than inventing tool calls.
+
+## Task tracking
+
+Use the `todo` tool for task tracking within a session. For multi-agent task boards, use `hermes kanban` CLI if available. Treat older `TodoWrite` references as the task-tracking action.

+ 0 - 0
tests/hermes/__init__.py


+ 30 - 0
tests/hermes/conftest.py

@@ -0,0 +1,30 @@
+from pathlib import Path
+
+import pytest
+from unittest.mock import MagicMock
+
+
+@pytest.fixture
+def mock_ctx():
+    ctx = MagicMock()
+    ctx._hooks = {}
+    ctx._skills = {}
+
+    def register_hook(event, fn):
+        ctx._hooks[event] = fn
+
+    def register_skill(name, path):
+        # Mimic hermes' real register_skill, which calls path.exists() and
+        # therefore breaks on a str (the bug that silently disabled the whole
+        # plugin, found 2026-07-23). Keeping that fidelity here means a
+        # regression to str paths fails these tests instead of failing
+        # silently inside hermes.
+        if not isinstance(path, Path):
+            raise AttributeError(
+                f"register_skill requires a pathlib.Path, got {type(path).__name__}"
+            )
+        ctx._skills[name] = path
+
+    ctx.register_hook.side_effect = register_hook
+    ctx.register_skill.side_effect = register_skill
+    return ctx

+ 98 - 0
tests/hermes/test_bootstrap.py

@@ -0,0 +1,98 @@
+import importlib
+import os
+import sys
+
+import pytest
+
+sys.path.insert(0, os.path.abspath(
+    os.path.join(os.path.dirname(__file__), "../../.hermes-plugin")
+))
+
+BOOTSTRAP_MARKER = "superpowers:using-superpowers bootstrap for hermes"
+
+# Hermes spills injected context over 10,000 chars to a file, which breaks
+# inline injection semantics. The bootstrap must stay under it with margin.
+HERMES_CONTEXT_SPILL_LIMIT = 10_000
+
+
+def _load():
+    if "__init__" in sys.modules:
+        del sys.modules["__init__"]
+    return importlib.import_module("__init__")
+
+
+def _bootstrap():
+    m = _load()
+    return m._build_bootstrap(m._skills_dir())
+
+
+class TestStripFrontmatter:
+    def test_strips_yaml_block(self):
+        m = _load()
+        content = "---\nname: foo\ndescription: bar\n---\n# Body\nContent here"
+        assert m._strip_frontmatter(content) == "# Body\nContent here"
+
+    def test_no_frontmatter_returns_trimmed_content(self):
+        m = _load()
+        content = "# No frontmatter\nJust content"
+        assert m._strip_frontmatter(content) == "# No frontmatter\nJust content"
+
+    def test_strips_surrounding_whitespace_from_body(self):
+        m = _load()
+        content = "---\nname: foo\n---\n\n\n# Body\n\n"
+        assert m._strip_frontmatter(content) == "# Body"
+
+
+class TestSkillsDirResolution:
+    def test_repo_layout_resolves(self):
+        # The repo checkout IS the git-clone layout: .hermes-plugin/ and
+        # skills/ are siblings, so resolution must succeed from here.
+        m = _load()
+        skills = m._skills_dir()
+        assert os.path.isfile(
+            os.path.join(skills, "using-superpowers", "SKILL.md")
+        )
+
+
+class TestBootstrapContent:
+    def test_marker_and_wrapper(self):
+        content = _bootstrap()
+        assert BOOTSTRAP_MARKER in content
+        assert content.startswith("<EXTREMELY_IMPORTANT>")
+        assert content.rstrip().endswith("</EXTREMELY_IMPORTANT>")
+
+    def test_contains_using_superpowers_body(self):
+        content = _bootstrap()
+        # A distinctive line from the skill body proves the real SKILL.md was
+        # embedded, not a stub.
+        assert "You have superpowers" in content
+        assert "## The Rule" in content
+
+    def test_frontmatter_stripped(self):
+        content = _bootstrap()
+        assert "---\nname:" not in content
+
+    def test_tool_mapping_sourced_from_reference_file(self):
+        m = _load()
+        content = _bootstrap()
+        ref = os.path.join(
+            m._skills_dir(), "using-superpowers", "references", "hermes-tools.md"
+        )
+        with open(ref, encoding="utf-8") as f:
+            ref_text = f.read().strip()
+        # The mapping is included verbatim from the reference file — the
+        # single source, not a drift-prone inline copy.
+        assert ref_text in content
+        assert "read_file" in content
+
+    def test_skill_view_guidance_present(self):
+        content = _bootstrap()
+        assert 'skill_view("superpowers:brainstorming")' in content
+
+    def test_under_hermes_context_spill_limit(self):
+        content = _bootstrap()
+        assert len(content) < HERMES_CONTEXT_SPILL_LIMIT, (
+            f"bootstrap is {len(content)} chars; hermes spills injected "
+            f"context over {HERMES_CONTEXT_SPILL_LIMIT} to a file, which "
+            "breaks inline injection"
+        )

+ 142 - 0
tests/hermes/test_plugin.py

@@ -0,0 +1,142 @@
+import importlib
+import importlib.util
+import os
+import shutil
+import sys
+from pathlib import Path
+
+import pytest
+
+# Point at the plugin directory
+_PLUGIN_DIR = os.path.abspath(
+    os.path.join(os.path.dirname(__file__), "../../.hermes-plugin")
+)
+sys.path.insert(0, _PLUGIN_DIR)
+
+BOOTSTRAP_MARKER = "superpowers:using-superpowers bootstrap for hermes"
+
+
+def _load_plugin():
+    """Re-import plugin module fresh."""
+    if "__init__" in sys.modules:
+        del sys.modules["__init__"]
+    return importlib.import_module("__init__")
+
+
+def _fire_pre_llm(ctx, **kwargs):
+    hook = ctx._hooks["pre_llm_call"]
+    defaults = {
+        "session_id": "s1",
+        "user_message": "hi",
+        "conversation_history": [],
+        "is_first_turn": False,
+        "model": "test-model",
+        "platform": "cli",
+    }
+    defaults.update(kwargs)
+    return hook(**defaults)
+
+
+class TestPluginRegistration:
+    def test_register_attaches_only_pre_llm_call_hook(self, mock_ctx):
+        plugin = _load_plugin()
+        plugin.register(mock_ctx)
+        assert list(mock_ctx._hooks.keys()) == ["pre_llm_call"]
+
+    def test_register_registers_every_stock_skill_as_path(self, mock_ctx):
+        plugin = _load_plugin()
+        plugin.register(mock_ctx)
+        # The conftest mock raises on non-Path (mirroring hermes' real
+        # register_skill), so reaching these asserts proves every
+        # registration passed a pathlib.Path.
+        assert "using-superpowers" in mock_ctx._skills
+        assert "brainstorming" in mock_ctx._skills
+        for name, path in mock_ctx._skills.items():
+            assert isinstance(path, Path)
+            assert path.name == "SKILL.md"
+            assert path.parent.name == name
+            assert path.is_file()
+
+    def test_registered_skills_match_skill_directories(self, mock_ctx):
+        plugin = _load_plugin()
+        plugin.register(mock_ctx)
+        skills_root = plugin._skills_dir()
+        expected = {
+            entry
+            for entry in os.listdir(skills_root)
+            if os.path.isfile(os.path.join(skills_root, entry, "SKILL.md"))
+        }
+        assert set(mock_ctx._skills.keys()) == expected
+
+
+class TestBootstrapInjection:
+    def test_first_turn_returns_bootstrap_context(self, mock_ctx):
+        plugin = _load_plugin()
+        plugin.register(mock_ctx)
+        result = _fire_pre_llm(mock_ctx, is_first_turn=True)
+        assert isinstance(result, dict)
+        content = result["context"]
+        assert BOOTSTRAP_MARKER in content
+        assert content.startswith("<EXTREMELY_IMPORTANT>")
+        assert content.rstrip().endswith("</EXTREMELY_IMPORTANT>")
+
+    def test_later_turns_return_none(self, mock_ctx):
+        plugin = _load_plugin()
+        plugin.register(mock_ctx)
+        assert _fire_pre_llm(mock_ctx, is_first_turn=False) is None
+        assert _fire_pre_llm(mock_ctx, is_first_turn=None) is None
+
+    def test_hook_tolerates_future_kwargs(self, mock_ctx):
+        plugin = _load_plugin()
+        plugin.register(mock_ctx)
+        result = _fire_pre_llm(
+            mock_ctx, is_first_turn=True, telemetry_schema_version=3
+        )
+        assert BOOTSTRAP_MARKER in result["context"]
+
+
+class TestLayoutResolution:
+    def _stage(self, tmp_path, layout):
+        """Copy the plugin module + a minimal skills tree in the given layout."""
+        src_skills = Path(_PLUGIN_DIR).parent / "skills"
+        if layout == "clone":
+            plugdir = tmp_path / "superpowers" / ".hermes-plugin"
+        else:  # flat: module at the plugin dir root, skills nested inside it
+            plugdir = tmp_path / "superpowers"
+        skills = tmp_path / "superpowers" / "skills"
+        plugdir.mkdir(parents=True, exist_ok=True)
+        shutil.copy(Path(_PLUGIN_DIR) / "__init__.py", plugdir / "__init__.py")
+        for skill in ("using-superpowers", "brainstorming"):
+            shutil.copytree(src_skills / skill, skills / skill)
+        return plugdir
+
+    def _load_from(self, plugdir):
+        spec = importlib.util.spec_from_file_location(
+            f"hermes_plugin_test_{plugdir.parent.name}_{plugdir.name}",
+            plugdir / "__init__.py",
+        )
+        mod = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(mod)
+        return mod
+
+    def test_clone_layout_resolves_sibling_skills(self, tmp_path, mock_ctx):
+        # git-clone install: .hermes-plugin/ and skills/ are siblings.
+        plugdir = self._stage(tmp_path, "clone")
+        mod = self._load_from(plugdir)
+        mod.register(mock_ctx)
+        assert "using-superpowers" in mock_ctx._skills
+
+    def test_flat_layout_resolves_nested_skills(self, tmp_path, mock_ctx):
+        # flattened install: module at the plugin dir root, skills/ inside it.
+        plugdir = self._stage(tmp_path, "flat")
+        mod = self._load_from(plugdir)
+        mod.register(mock_ctx)
+        assert "using-superpowers" in mock_ctx._skills
+
+    def test_missing_skills_raises_loudly(self, tmp_path, mock_ctx):
+        plugdir = tmp_path / "superpowers"
+        plugdir.mkdir(parents=True)
+        shutil.copy(Path(_PLUGIN_DIR) / "__init__.py", plugdir / "__init__.py")
+        mod = self._load_from(plugdir)
+        with pytest.raises(RuntimeError, match="cannot find the skills"):
+            mod.register(mock_ctx)