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