From 7b177613c03a00042e67422dce8fc7814ae248c3 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 23 Jul 2026 12:18:13 -0700 Subject: [PATCH 1/4] 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 From 178528c03e7f0cc051bccf5b1249c480cb972105 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 23 Jul 2026 15:17:55 -0700 Subject: [PATCH 2/4] fix(hermes): working bootstrap injection via pre_llm_call + native skill registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirical findings from the quorum eval bring-up (superpowers-evals docs/experiments/2026-07-23-hermes-target-bringup.md): - ctx.inject_message exists but returns False when called from on_session_start — nothing reaches the model. The documented path, a pre_llm_call hook returning {"context": ...} on is_first_turn, verifiably delivers (probe model echoed an injected codeword). - ctx.register_skill requires a pathlib.Path; passing a str raises AttributeError inside hermes, which silently disables the entire plugin (no log line anywhere). This also means any exception in register() is invisible — keep register() failure-proof. - Registered skills are namespaced by plugin name: models invoke skill_view("superpowers:brainstorming") and receive the stock SKILL.md — verified live on GLM 5.2, both install layouts. The plugin now: resolves skills/ for both the git-clone layout (.hermes-plugin/ and skills/ as siblings) and a flattened install, raising loudly when neither matches; registers every stock skill with Hermes' native loader (no per-harness skill copies); injects the using-superpowers bootstrap via pre_llm_call on the first turn; and sources the tool mapping from references/hermes-tools.md instead of duplicating it. Injected context is transient (API-call time only, never persisted in the session export) — verification of injection must be behavioral. --- .hermes-plugin/__init__.py | 145 +++++++++++++++++++------------------ .hermes-plugin/plugin.yaml | 2 +- 2 files changed, 75 insertions(+), 72 deletions(-) diff --git a/.hermes-plugin/__init__.py b/.hermes-plugin/__init__.py index 663d7b42..3b41644b 100644 --- a/.hermes-plugin/__init__.py +++ b/.hermes-plugin/__init__.py @@ -1,23 +1,35 @@ import os import re -from typing import Optional +from pathlib import Path 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 +def _skills_dir() -> str: + """Locate the stock skills/ tree for either supported install layout. -_last_session_id = None + - 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: @@ -25,44 +37,20 @@ def _strip_frontmatter(content: str) -> str: 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 +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()) -When skills request actions, use these Hermes equivalents: + 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() -| 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 = ( + return ( f"\n" f"{BOOTSTRAP_MARKER}\n\n" f"You have superpowers.\n\n" @@ -71,31 +59,46 @@ def _get_bootstrap() -> Optional[str]: 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"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"" ) - 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 + skills_dir = _skills_dir() + bootstrap = _build_bootstrap(skills_dir) - ctx.register_hook("on_session_start", on_session_start) + # 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 index d8aa14be..9260499d 100644 --- a/.hermes-plugin/plugin.yaml +++ b/.hermes-plugin/plugin.yaml @@ -3,4 +3,4 @@ version: 6.0.3 description: Superpowers skills and workflow bootstrap for Hermes Agent author: obra provides_hooks: - - on_session_start + - pre_llm_call From b6613057ae5cb1ecb634f12e52cc47a260caf0e4 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 23 Jul 2026 16:05:54 -0700 Subject: [PATCH 3/4] test(hermes): realign suite with the pre_llm_call mechanism; slim docs to the README section The 20-test suite still exercised the dead on_session_start/inject_message mechanism (17 failures against the rewritten plugin). Rewritten for the real contract: pre_llm_call registration + first-turn-only context return, register_skill receiving pathlib.Path (the conftest mock now raises on str, mirroring hermes' AttributeError that silently disables a plugin), both install layouts resolving skills, loud failure when skills are missing, tool mapping sourced verbatim from hermes-tools.md, and a bootstrap-size guard against hermes' 10k-char context spill threshold. 19 tests, passing. Install docs collapse into the README section per maintainer direction: docs/README.hermes.md and .hermes-plugin/INSTALL.md are gone; the README carries the two-line install plus the compaction caveat. plugin.yaml version aligned to 6.1.1. --- .hermes-plugin/INSTALL.md | 30 ------ .hermes-plugin/plugin.yaml | 2 +- README.md | 6 +- docs/README.hermes.md | 29 ------ tests/hermes/conftest.py | 19 +++- tests/hermes/test_bootstrap.py | 95 ++++++++++------- tests/hermes/test_plugin.py | 183 ++++++++++++++++++++------------- 7 files changed, 185 insertions(+), 179 deletions(-) delete mode 100644 .hermes-plugin/INSTALL.md delete mode 100644 docs/README.hermes.md diff --git a/.hermes-plugin/INSTALL.md b/.hermes-plugin/INSTALL.md deleted file mode 100644 index b08ef5b1..00000000 --- a/.hermes-plugin/INSTALL.md +++ /dev/null @@ -1,30 +0,0 @@ -# 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/plugin.yaml b/.hermes-plugin/plugin.yaml index 9260499d..c10f9f55 100644 --- a/.hermes-plugin/plugin.yaml +++ b/.hermes-plugin/plugin.yaml @@ -1,5 +1,5 @@ name: superpowers -version: 6.0.3 +version: 6.1.1 description: Superpowers skills and workflow bootstrap for Hermes Agent author: obra provides_hooks: diff --git a/README.md b/README.md index 8a781e64..61dfc732 100644 --- a/README.md +++ b/README.md @@ -207,9 +207,9 @@ Install Superpowers as a Hermes plugin from this repository: hermes plugins install obra/superpowers --enable ``` -Restart any active Hermes sessions after installing. - -Detailed docs: [docs/README.hermes.md](docs/README.hermes.md) +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 diff --git a/docs/README.hermes.md b/docs/README.hermes.md deleted file mode 100644 index 0ad692c5..00000000 --- a/docs/README.hermes.md +++ /dev/null @@ -1,29 +0,0 @@ -# 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/tests/hermes/conftest.py b/tests/hermes/conftest.py index a4c85197..561cd8ca 100644 --- a/tests/hermes/conftest.py +++ b/tests/hermes/conftest.py @@ -1,3 +1,5 @@ +from pathlib import Path + import pytest from unittest.mock import MagicMock @@ -6,14 +8,23 @@ from unittest.mock import MagicMock def mock_ctx(): ctx = MagicMock() ctx._hooks = {} - ctx._injected = [] + ctx._skills = {} def register_hook(event, fn): ctx._hooks[event] = fn - def inject_message(content, role="user"): - ctx._injected.append({"content": content, "role": role}) + 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.inject_message.side_effect = inject_message + ctx.register_skill.side_effect = register_skill return ctx diff --git a/tests/hermes/test_bootstrap.py b/tests/hermes/test_bootstrap.py index a8e8f282..8b60153c 100644 --- a/tests/hermes/test_bootstrap.py +++ b/tests/hermes/test_bootstrap.py @@ -1,6 +1,7 @@ +import importlib import os import sys -import importlib + import pytest sys.path.insert(0, os.path.abspath( @@ -9,6 +10,10 @@ sys.path.insert(0, os.path.abspath( 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: @@ -16,6 +21,11 @@ def _load(): 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() @@ -33,49 +43,56 @@ class TestStripFrontmatter: assert m._strip_frontmatter(content) == "# Body" -class TestGetBootstrap: - def test_returns_none_when_skill_file_missing(self, tmp_path): +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() - m._bootstrap_cache = None - m._SKILLS_DIR = str(tmp_path / "nonexistent") - assert m._get_bootstrap() is None + skills = m._skills_dir() + assert os.path.isfile( + os.path.join(skills, "using-superpowers", "SKILL.md") + ) - 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) +class TestBootstrapContent: + def test_marker_and_wrapper(self): + content = _bootstrap() + assert BOOTSTRAP_MARKER in content + assert content.startswith("") + assert content.rstrip().endswith("") - 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_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_contains_marker(self): - m = _load() - m._bootstrap_cache = None - result = m._get_bootstrap() - assert BOOTSTRAP_MARKER in result + def test_frontmatter_stripped(self): + content = _bootstrap() + assert "---\nname:" not in content - def test_contains_extremely_important_wrapper(self): + def test_tool_mapping_sourced_from_reference_file(self): m = _load() - m._bootstrap_cache = None - result = m._get_bootstrap() - assert result.startswith("") - assert result.rstrip().endswith("") + 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_frontmatter_absent_from_output(self): - m = _load() - m._bootstrap_cache = None - result = m._get_bootstrap() - assert "---\nname:" not in result + 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 index e9317a41..9c6c4f08 100644 --- a/tests/hermes/test_plugin.py +++ b/tests/hermes/test_plugin.py @@ -1,105 +1,142 @@ -import os -import sys 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.join(os.path.dirname(__file__), "../../.hermes-plugin") -sys.path.insert(0, os.path.abspath(_PLUGIN_DIR)) +_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 (clears module-level cache).""" + """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_session_start_hook(self, mock_ctx): + def test_register_attaches_only_pre_llm_call_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" + 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_session_start_injects_bootstrap(self, mock_ctx): + def test_first_turn_returns_bootstrap_context(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"] + 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_injection_uses_user_role(self, mock_ctx): + def test_later_turns_return_none(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" + 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_dedup_skips_on_same_session_id(self, mock_ctx): + def test_hook_tolerates_future_kwargs(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 + result = _fire_pre_llm( + mock_ctx, is_first_turn=True, telemetry_schema_version=3 + ) + assert BOOTSTRAP_MARKER in result["context"] -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 +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 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 _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_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 + 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) From 695744056ed1338404539aba3fbd98928d78005c Mon Sep 17 00:00:00 2001 From: Drew Ritter Date: Wed, 5 Aug 2026 17:57:31 -0700 Subject: [PATCH 4/4] chore(hermes): align plugin version with dev Update the Hermes plugin manifest from 6.1.1 to 6.2.0 so PR #2025 matches the current release version at the tip of origin/dev.\n\nThis intentionally does not change the version bump tooling. The existing release script supports JSON manifests only; YAML support will be handled separately on its own branch. --- .hermes-plugin/plugin.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.hermes-plugin/plugin.yaml b/.hermes-plugin/plugin.yaml index c10f9f55..78f76e8b 100644 --- a/.hermes-plugin/plugin.yaml +++ b/.hermes-plugin/plugin.yaml @@ -1,5 +1,5 @@ name: superpowers -version: 6.1.1 +version: 6.2.0 description: Superpowers skills and workflow bootstrap for Hermes Agent author: obra provides_hooks: