diff --git a/.gitignore b/.gitignore
index 2118649567..efa95832b9 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/__init__.py b/.hermes-plugin/__init__.py
new file mode 100644
index 0000000000..3b41644bb2
--- /dev/null
+++ b/.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"\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""
+ )
+
+
+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)
diff --git a/.hermes-plugin/plugin.yaml b/.hermes-plugin/plugin.yaml
new file mode 100644
index 0000000000..c10f9f55c5
--- /dev/null
+++ b/.hermes-plugin/plugin.yaml
@@ -0,0 +1,6 @@
+name: superpowers
+version: 6.1.1
+description: Superpowers skills and workflow bootstrap for Hermes Agent
+author: obra
+provides_hooks:
+ - pre_llm_call
diff --git a/README.md b/README.md
index bb398c6b68..61dfc732d9 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. 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.
diff --git a/skills/using-superpowers/SKILL.md b/skills/using-superpowers/SKILL.md
index 8a08873ba0..7ab2eb678f 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 0000000000..0f1fa57bc1
--- /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 0000000000..e69de29bb2
diff --git a/tests/hermes/conftest.py b/tests/hermes/conftest.py
new file mode 100644
index 0000000000..561cd8ca52
--- /dev/null
+++ b/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
diff --git a/tests/hermes/test_bootstrap.py b/tests/hermes/test_bootstrap.py
new file mode 100644
index 0000000000..8b60153c69
--- /dev/null
+++ b/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("")
+ assert content.rstrip().endswith("")
+
+ 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"
+ )
diff --git a/tests/hermes/test_plugin.py b/tests/hermes/test_plugin.py
new file mode 100644
index 0000000000..9c6c4f0811
--- /dev/null
+++ b/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("")
+ assert content.rstrip().endswith("")
+
+ 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)