Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ triage/
# development (see CLAUDE.md / README.md). It is not part of the published
# plugin, so the whole directory is ignored here.
evals/

# Python
__pycache__/
*.pyc
*.pyo
.pytest_cache/
104 changes: 104 additions & 0 deletions .hermes-plugin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import os
import re
from pathlib import Path

BOOTSTRAP_MARKER = "superpowers:using-superpowers bootstrap for hermes"


def _skills_dir() -> str:
"""Locate the stock skills/ tree for either supported install layout.

- git-clone install (`hermes plugins install obra/superpowers`): the plugin
dir is the repo root, so `.hermes-plugin/` and `skills/` are siblings and
this module resolves `../skills`.
- flattened install (plugin files copied to the plugin dir root): `skills/`
sits next to this module.

Raises loudly when neither matches — a bootstrap that silently skips is how
a broken install masquerades as a working one.
"""
here = os.path.dirname(os.path.realpath(__file__))
candidates = (
os.path.realpath(os.path.join(here, "..", "skills")),
os.path.realpath(os.path.join(here, "skills")),
)
for cand in candidates:
if os.path.isfile(os.path.join(cand, "using-superpowers", "SKILL.md")):
return cand
raise RuntimeError(
"superpowers plugin: cannot find the skills/ tree "
f"(looked at {candidates}). Reinstall with "
"`hermes plugins install obra/superpowers`."
)


def _strip_frontmatter(content: str) -> str:
match = re.match(r"^---\n[\s\S]*?\n---\n([\s\S]*)$", content)
return (match.group(1) if match else content).strip()


def _build_bootstrap(skills_dir: str) -> str:
with open(
os.path.join(skills_dir, "using-superpowers", "SKILL.md"),
encoding="utf-8",
) as f:
body = _strip_frontmatter(f.read())

tools_path = os.path.join(
skills_dir, "using-superpowers", "references", "hermes-tools.md"
)
with open(tools_path, encoding="utf-8") as f:
tool_mapping = f.read().strip()

return (
f"<EXTREMELY_IMPORTANT>\n"
f"{BOOTSTRAP_MARKER}\n\n"
f"You have superpowers.\n\n"
f"The using-superpowers skill content is included below and is already "
f"loaded for this Hermes session. Follow it now. "
f"Do not try to load using-superpowers again.\n\n"
f"{body}\n\n"
f"## Loading Superpowers Skills on Hermes\n\n"
f"Superpowers skills are registered with Hermes' native skill loader: "
f'invoke one with `skill_view("superpowers:skill-name")` '
f'(for example `skill_view("superpowers:brainstorming")`). '
f"If a namespaced lookup returns 'not found', read the skill file "
f"directly instead:\n"
f'`read_file("{skills_dir}/skill-name/SKILL.md")`\n\n'
f"The superpowers skills directory is: `{skills_dir}`\n\n"
f"{tool_mapping}\n"
f"</EXTREMELY_IMPORTANT>"
)


def register(ctx):
skills_dir = _skills_dir()
bootstrap = _build_bootstrap(skills_dir)

# Register every stock skill with Hermes' native loader so skill_view can
# load them on demand. Standard markdown; no conversion (plugin guide).
# register_skill requires a pathlib.Path — a str raises AttributeError and
# hermes silently disables the whole plugin (verified 2026-07-23).
for name in sorted(os.listdir(skills_dir)):
skill_md = os.path.join(skills_dir, name, "SKILL.md")
if os.path.isfile(skill_md):
ctx.register_skill(name, Path(skill_md))

# pre_llm_call returning {"context": ...} is the documented injection path
# (on_session_start return values are ignored, and ctx.inject_message
# refuses from that hook — verified empirically 2026-07-23). The context is
# appended to the first turn's user message.
def pre_llm_call(
session_id=None,
user_message=None,
conversation_history=None,
is_first_turn=None,
model=None,
platform=None,
**kwargs,
):
if is_first_turn:
return {"context": bootstrap}
return None

ctx.register_hook("pre_llm_call", pre_llm_call)
6 changes: 6 additions & 0 deletions .hermes-plugin/plugin.yaml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions skills/using-superpowers/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
56 changes: 56 additions & 0 deletions skills/using-superpowers/references/hermes-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Hermes Agent Tool Mapping

Skills speak in actions ("dispatch a subagent", "create a todo", "read a file"). On Hermes Agent these resolve to the tools below.

## Tools

| Action skills request | Hermes tool |
|---|---|
| Read a file | `read_file` |
| Create a new file | `write_file` |
| Edit a file (targeted patch) | `patch` |
| Run a shell command | `terminal` |
| Search file contents | `search_files` |
| Find files by name | `terminal` with `find` |
| Fetch a URL / read a webpage | `web_extract(urls=[...])` |
| Search the web | `web_search(query=...)` |
| Dispatch a subagent | `delegate_task(goal=..., context=..., toolsets=[...], role="leaf")` |
| Task tracking | `todo` tool |
| Invoke a skill | `skill_view("skill-name")` |

## Instructions file

When a skill mentions "your instructions file," on Hermes Agent this is **`AGENTS.md`** in the project directory, or **`SOUL.md`** globally at `~/.hermes/SOUL.md`.

## Invoking a skill

Hermes Agent has a `skills` toolset with `skill_view` and `skills_list` tools.
To invoke a superpowers skill, use:

```
skill_view("brainstorming")
skill_view("test-driven-development")
```

If `skill_view` cannot find a superpowers skill (it may not appear in the catalog
until the plugin fully registers it), fall back to reading the SKILL.md directly:

```
read_file(path="~/.hermes/plugins/superpowers/skills/<skill-name>/SKILL.md")
```

This fallback is the same mechanism used by other harnesses without native skill loading.

## Subagent dispatch

Use `delegate_task` to spawn isolated subagents for parallel or sequential workstreams:

```
delegate_task(goal="...", context="...", toolsets=[...], role="leaf")
```

If `delegate_task` is unavailable, do the work inline rather than inventing tool calls.

## Task tracking

Use the `todo` tool for task tracking within a session. For multi-agent task boards, use `hermes kanban` CLI if available. Treat older `TodoWrite` references as the task-tracking action.
Empty file added tests/hermes/__init__.py
Empty file.
30 changes: 30 additions & 0 deletions tests/hermes/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from pathlib import Path

import pytest
from unittest.mock import MagicMock


@pytest.fixture
def mock_ctx():
ctx = MagicMock()
ctx._hooks = {}
ctx._skills = {}

def register_hook(event, fn):
ctx._hooks[event] = fn

def register_skill(name, path):
# Mimic hermes' real register_skill, which calls path.exists() and
# therefore breaks on a str (the bug that silently disabled the whole
# plugin, found 2026-07-23). Keeping that fidelity here means a
# regression to str paths fails these tests instead of failing
# silently inside hermes.
if not isinstance(path, Path):
raise AttributeError(
f"register_skill requires a pathlib.Path, got {type(path).__name__}"
)
ctx._skills[name] = path

ctx.register_hook.side_effect = register_hook
ctx.register_skill.side_effect = register_skill
return ctx
98 changes: 98 additions & 0 deletions tests/hermes/test_bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import importlib
import os
import sys

import pytest

sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), "../../.hermes-plugin")
))

BOOTSTRAP_MARKER = "superpowers:using-superpowers bootstrap for hermes"

# Hermes spills injected context over 10,000 chars to a file, which breaks
# inline injection semantics. The bootstrap must stay under it with margin.
HERMES_CONTEXT_SPILL_LIMIT = 10_000


def _load():
if "__init__" in sys.modules:
del sys.modules["__init__"]
return importlib.import_module("__init__")


def _bootstrap():
m = _load()
return m._build_bootstrap(m._skills_dir())


class TestStripFrontmatter:
def test_strips_yaml_block(self):
m = _load()
content = "---\nname: foo\ndescription: bar\n---\n# Body\nContent here"
assert m._strip_frontmatter(content) == "# Body\nContent here"

def test_no_frontmatter_returns_trimmed_content(self):
m = _load()
content = "# No frontmatter\nJust content"
assert m._strip_frontmatter(content) == "# No frontmatter\nJust content"

def test_strips_surrounding_whitespace_from_body(self):
m = _load()
content = "---\nname: foo\n---\n\n\n# Body\n\n"
assert m._strip_frontmatter(content) == "# Body"


class TestSkillsDirResolution:
def test_repo_layout_resolves(self):
# The repo checkout IS the git-clone layout: .hermes-plugin/ and
# skills/ are siblings, so resolution must succeed from here.
m = _load()
skills = m._skills_dir()
assert os.path.isfile(
os.path.join(skills, "using-superpowers", "SKILL.md")
)


class TestBootstrapContent:
def test_marker_and_wrapper(self):
content = _bootstrap()
assert BOOTSTRAP_MARKER in content
assert content.startswith("<EXTREMELY_IMPORTANT>")
assert content.rstrip().endswith("</EXTREMELY_IMPORTANT>")

def test_contains_using_superpowers_body(self):
content = _bootstrap()
# A distinctive line from the skill body proves the real SKILL.md was
# embedded, not a stub.
assert "You have superpowers" in content
assert "## The Rule" in content

def test_frontmatter_stripped(self):
content = _bootstrap()
assert "---\nname:" not in content

def test_tool_mapping_sourced_from_reference_file(self):
m = _load()
content = _bootstrap()
ref = os.path.join(
m._skills_dir(), "using-superpowers", "references", "hermes-tools.md"
)
with open(ref, encoding="utf-8") as f:
ref_text = f.read().strip()
# The mapping is included verbatim from the reference file — the
# single source, not a drift-prone inline copy.
assert ref_text in content
assert "read_file" in content

def test_skill_view_guidance_present(self):
content = _bootstrap()
assert 'skill_view("superpowers:brainstorming")' in content

def test_under_hermes_context_spill_limit(self):
content = _bootstrap()
assert len(content) < HERMES_CONTEXT_SPILL_LIMIT, (
f"bootstrap is {len(content)} chars; hermes spills injected "
f"context over {HERMES_CONTEXT_SPILL_LIMIT} to a file, which "
"breaks inline injection"
)
Loading