From 7b177613c03a00042e67422dce8fc7814ae248c3 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 23 Jul 2026 12:18:13 -0700 Subject: [PATCH] feat(hermes): Hermes Agent harness support, rebased to a Hermes-only diff Rebase of PR #1922 onto current dev: the ~14 files of v6.1.0-era codex/release drift are dropped, the porting-guide edits (stale against the post-prune rewrite, no Hermes content) are dropped, and the Hermes surface is kept intact: .hermes-plugin/ (on_session_start bootstrap injection), tests/hermes/ (20 tests, passing), docs/README.hermes.md, references/hermes-tools.md, the Platform Adaptation row, README section, and Python ignores. Known open items from review, unchanged by this rebase: the injection mechanism uses ctx.inject_message from on_session_start, which the official plugin guide does not document (pre_llm_call returning {"context": ...} is the sanctioned path), skills are not registered via ctx.register_skill, and the acceptance transcript predates the fix. Co-authored-by: kumarabd --- .gitignore | 6 + .hermes-plugin/INSTALL.md | 30 +++++ .hermes-plugin/__init__.py | 101 +++++++++++++++++ .hermes-plugin/plugin.yaml | 6 + README.md | 14 ++- docs/README.hermes.md | 29 +++++ skills/using-superpowers/SKILL.md | 1 + .../references/hermes-tools.md | 56 ++++++++++ tests/hermes/__init__.py | 0 tests/hermes/conftest.py | 19 ++++ tests/hermes/test_bootstrap.py | 81 ++++++++++++++ tests/hermes/test_plugin.py | 105 ++++++++++++++++++ 12 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 .hermes-plugin/INSTALL.md create mode 100644 .hermes-plugin/__init__.py create mode 100644 .hermes-plugin/plugin.yaml create mode 100644 docs/README.hermes.md create mode 100644 skills/using-superpowers/references/hermes-tools.md create mode 100644 tests/hermes/__init__.py create mode 100644 tests/hermes/conftest.py create mode 100644 tests/hermes/test_bootstrap.py create mode 100644 tests/hermes/test_plugin.py diff --git a/.gitignore b/.gitignore index 21186495..efa95832 100644 --- a/.gitignore +++ b/.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/ diff --git a/.hermes-plugin/INSTALL.md b/.hermes-plugin/INSTALL.md new file mode 100644 index 00000000..b08ef5b1 --- /dev/null +++ b/.hermes-plugin/INSTALL.md @@ -0,0 +1,30 @@ +# Hermes Agent — Superpowers Plugin + +## Install + +```bash +hermes plugins install obra/superpowers --enable +``` + +Restart any active Hermes sessions after installing. + +## Smoke check + +Start a new session and send: +> What are your superpowers? + +The model should describe brainstorming, TDD, debugging, and planning skills. +If it doesn't, the bootstrap isn't loading — reinstall and restart. + +## Acceptance test + +Send in a fresh session: +> Let's make a react todo list + +The `brainstorming` skill must trigger and run its flow before any code is written. + +## Uninstall + +```bash +hermes plugins remove superpowers +``` diff --git a/.hermes-plugin/__init__.py b/.hermes-plugin/__init__.py new file mode 100644 index 00000000..663d7b42 --- /dev/null +++ b/.hermes-plugin/__init__.py @@ -0,0 +1,101 @@ +import os +import re +from typing import Optional + +BOOTSTRAP_MARKER = "superpowers:using-superpowers bootstrap for hermes" + +# Resolved once at import — avoids repeated path work on every session-start. +# hermes plugins install does a full git clone, so .hermes-plugin/__init__.py +# and skills/ end up at the same level in ~/.hermes/plugins/superpowers/. +_SKILLS_DIR: str = os.path.realpath( + os.path.join(os.path.dirname(__file__), "..", "skills") +) + +# Module-level cache: +# None = not yet assembled +# False = SKILL.md missing (skip injection silently) +# str = assembled bootstrap content +_bootstrap_cache = None + +_last_session_id = None + + +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 _hermes_tool_mapping() -> str: + # Tool names confirmed empirically in Task 1. + return """\ +## Hermes tool mapping + +When skills request actions, use these Hermes equivalents: + +| Action | 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")` — this is the native skill-loading mechanism on Hermes | +""" + + +def _get_bootstrap() -> Optional[str]: + global _bootstrap_cache + if _bootstrap_cache is not None: + return None if _bootstrap_cache is False else _bootstrap_cache + + skill_path = os.path.join(_SKILLS_DIR, "using-superpowers", "SKILL.md") + if not os.path.exists(skill_path): + _bootstrap_cache = False + return None + + with open(skill_path, encoding="utf-8") as f: + raw = f.read() + + body = _strip_frontmatter(raw) + _bootstrap_cache = ( + f"\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 NOT in Hermes' built-in skill registry — " + f"`skill_view` will return 'not found' for them. " + f"When using-superpowers tells you to invoke a skill, use this two-step:\n\n" + f"1. Try `skill_view(\"skill-name\")` first (it works for Hermes' own skills).\n" + f"2. If that returns 'not found', immediately use `read_file` instead:\n" + f" `read_file(\"{_SKILLS_DIR}/skill-name/SKILL.md\")`\n\n" + f"Example — to load the brainstorming skill:\n" + f" `read_file(\"{_SKILLS_DIR}/brainstorming/SKILL.md\")`\n\n" + f"The superpowers skills directory is: `{_SKILLS_DIR}`\n\n" + f"{_hermes_tool_mapping()}\n" + f"" + ) + return _bootstrap_cache + + +def register(ctx): + def on_session_start(**kwargs): + global _last_session_id + session_id = kwargs.get("session_id") + if session_id is not None and session_id == _last_session_id: + return + bootstrap = _get_bootstrap() + if bootstrap is None: + return + ctx.inject_message(bootstrap, role="user") + _last_session_id = session_id + + ctx.register_hook("on_session_start", on_session_start) diff --git a/.hermes-plugin/plugin.yaml b/.hermes-plugin/plugin.yaml new file mode 100644 index 00000000..d8aa14be --- /dev/null +++ b/.hermes-plugin/plugin.yaml @@ -0,0 +1,6 @@ +name: superpowers +version: 6.0.3 +description: Superpowers skills and workflow bootstrap for Hermes Agent +author: obra +provides_hooks: + - on_session_start diff --git a/README.md b/README.md index bb398c6b..8a781e64 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ If this sounds like someone you know, definitely send them our way. ## 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 @@ -199,6 +199,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. + +Detailed docs: [docs/README.hermes.md](docs/README.hermes.md) + ## 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. diff --git a/docs/README.hermes.md b/docs/README.hermes.md new file mode 100644 index 00000000..0ad692c5 --- /dev/null +++ b/docs/README.hermes.md @@ -0,0 +1,29 @@ +# Hermes Agent + +Superpowers supports Hermes Agent via an in-process Python plugin (Shape B). + +## Install + +```bash +hermes plugins install obra/superpowers --enable +``` + +## What you get + +All Superpowers skills auto-trigger in Hermes sessions: +brainstorming before feature work, systematic-debugging on bugs, +test-driven-development for implementation, writing-plans before +touching code, and all other skills in `skills/`. + +## How it works + +The plugin registers an `on_session_start` hook with the Hermes plugin API. +At the start of each session, the hook injects the `using-superpowers` bootstrap +as a user-role message via `ctx.inject_message(role="user")`. A session-id guard +prevents double-injection if the hook fires more than once per session. + +Skills are loaded on demand during the session using `skill_view("skill-name")`. + +## Verifying + +See `.hermes-plugin/INSTALL.md` for the smoke check and acceptance test. diff --git a/skills/using-superpowers/SKILL.md b/skills/using-superpowers/SKILL.md index 8a08873b..7ab2eb67 100644 --- a/skills/using-superpowers/SKILL.md +++ b/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 diff --git a/skills/using-superpowers/references/hermes-tools.md b/skills/using-superpowers/references/hermes-tools.md new file mode 100644 index 00000000..0f1fa57b --- /dev/null +++ b/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.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. diff --git a/tests/hermes/__init__.py b/tests/hermes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hermes/conftest.py b/tests/hermes/conftest.py new file mode 100644 index 00000000..a4c85197 --- /dev/null +++ b/tests/hermes/conftest.py @@ -0,0 +1,19 @@ +import pytest +from unittest.mock import MagicMock + + +@pytest.fixture +def mock_ctx(): + ctx = MagicMock() + ctx._hooks = {} + ctx._injected = [] + + def register_hook(event, fn): + ctx._hooks[event] = fn + + def inject_message(content, role="user"): + ctx._injected.append({"content": content, "role": role}) + + ctx.register_hook.side_effect = register_hook + ctx.inject_message.side_effect = inject_message + return ctx diff --git a/tests/hermes/test_bootstrap.py b/tests/hermes/test_bootstrap.py new file mode 100644 index 00000000..a8e8f282 --- /dev/null +++ b/tests/hermes/test_bootstrap.py @@ -0,0 +1,81 @@ +import os +import sys +import importlib +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" + + +def _load(): + if "__init__" in sys.modules: + del sys.modules["__init__"] + return importlib.import_module("__init__") + + +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 TestGetBootstrap: + def test_returns_none_when_skill_file_missing(self, tmp_path): + m = _load() + m._bootstrap_cache = None + m._SKILLS_DIR = str(tmp_path / "nonexistent") + assert m._get_bootstrap() is None + + def test_caches_false_on_missing_file(self, tmp_path): + m = _load() + m._bootstrap_cache = None + m._SKILLS_DIR = str(tmp_path / "nonexistent") + m._get_bootstrap() + assert m._bootstrap_cache is False + + def test_returns_string_with_real_skill(self): + m = _load() + m._bootstrap_cache = None + result = m._get_bootstrap() + assert result is not None + assert isinstance(result, str) + + def test_same_object_returned_on_second_call(self): + m = _load() + m._bootstrap_cache = None + r1 = m._get_bootstrap() + r2 = m._get_bootstrap() + assert r1 is r2 + + def test_contains_marker(self): + m = _load() + m._bootstrap_cache = None + result = m._get_bootstrap() + assert BOOTSTRAP_MARKER in result + + def test_contains_extremely_important_wrapper(self): + m = _load() + m._bootstrap_cache = None + result = m._get_bootstrap() + assert result.startswith("") + assert result.rstrip().endswith("") + + def test_frontmatter_absent_from_output(self): + m = _load() + m._bootstrap_cache = None + result = m._get_bootstrap() + assert "---\nname:" not in result diff --git a/tests/hermes/test_plugin.py b/tests/hermes/test_plugin.py new file mode 100644 index 00000000..e9317a41 --- /dev/null +++ b/tests/hermes/test_plugin.py @@ -0,0 +1,105 @@ +import os +import sys +import importlib +import pytest + +# Point at the plugin directory +_PLUGIN_DIR = os.path.join(os.path.dirname(__file__), "../../.hermes-plugin") +sys.path.insert(0, os.path.abspath(_PLUGIN_DIR)) + +BOOTSTRAP_MARKER = "superpowers:using-superpowers bootstrap for hermes" + + +def _load_plugin(): + """Re-import plugin module fresh (clears module-level cache).""" + if "__init__" in sys.modules: + del sys.modules["__init__"] + return importlib.import_module("__init__") + + +class TestPluginRegistration: + def test_register_attaches_session_start_hook(self, mock_ctx): + plugin = _load_plugin() + plugin.register(mock_ctx) + mock_ctx.register_hook.assert_called_once() + event_name = mock_ctx.register_hook.call_args[0][0] + assert event_name == "on_session_start" + + +class TestBootstrapInjection: + def test_first_session_start_injects_bootstrap(self, mock_ctx): + plugin = _load_plugin() + plugin.register(mock_ctx) + handler = mock_ctx._hooks["on_session_start"] + handler(session_id="sess-1", model="test-model", platform="test") + assert len(mock_ctx._injected) == 1 + assert BOOTSTRAP_MARKER in mock_ctx._injected[0]["content"] + + def test_injection_uses_user_role(self, mock_ctx): + plugin = _load_plugin() + plugin.register(mock_ctx) + handler = mock_ctx._hooks["on_session_start"] + handler(session_id="sess-1", model="test-model", platform="test") + assert mock_ctx._injected[0]["role"] == "user" + + def test_dedup_skips_on_same_session_id(self, mock_ctx): + plugin = _load_plugin() + plugin.register(mock_ctx) + handler = mock_ctx._hooks["on_session_start"] + handler(session_id="sess-1", model="test-model", platform="test") + handler(session_id="sess-1", model="test-model", platform="test") + assert len(mock_ctx._injected) == 1 + + def test_reinjects_on_new_session_id(self, mock_ctx): + plugin = _load_plugin() + plugin.register(mock_ctx) + handler = mock_ctx._hooks["on_session_start"] + handler(session_id="sess-1", model="test-model", platform="test") + handler(session_id="sess-2", model="test-model", platform="test") + assert len(mock_ctx._injected) == 2 + + def test_missing_skill_file_skips_silently(self, mock_ctx, tmp_path): + plugin = _load_plugin() + plugin._bootstrap_cache = None + plugin._SKILLS_DIR = str(tmp_path / "nonexistent") + plugin.register(mock_ctx) + handler = mock_ctx._hooks["on_session_start"] + handler(session_id="sess-1", model="test-model", platform="test") + assert len(mock_ctx._injected) == 0 + + def test_cache_populated_after_first_call(self, mock_ctx): + plugin = _load_plugin() + plugin._bootstrap_cache = None + plugin.register(mock_ctx) + handler = mock_ctx._hooks["on_session_start"] + handler(session_id="sess-1", model="test-model", platform="test") + assert plugin._bootstrap_cache is not None + assert plugin._bootstrap_cache is not False + + +class TestBootstrapContent: + def test_contains_extremely_important_tags(self, mock_ctx): + plugin = _load_plugin() + plugin.register(mock_ctx) + handler = mock_ctx._hooks["on_session_start"] + handler(session_id="sess-1", model="test-model", platform="test") + content = mock_ctx._injected[0]["content"] + assert "" in content + assert "" in content + + def test_frontmatter_stripped(self, mock_ctx): + plugin = _load_plugin() + plugin.register(mock_ctx) + handler = mock_ctx._hooks["on_session_start"] + handler(session_id="sess-1", model="test-model", platform="test") + content = mock_ctx._injected[0]["content"] + assert "---\nname:" not in content + + def test_tool_mapping_present(self, mock_ctx): + plugin = _load_plugin() + plugin.register(mock_ctx) + handler = mock_ctx._hooks["on_session_start"] + handler(session_id="sess-1", model="test-model", platform="test") + content = mock_ctx._injected[0]["content"] + assert "Hermes tool mapping" in content + assert "read_file" in content