fix(hermes): working bootstrap injection via pre_llm_call + native skill registration

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.
This commit is contained in:
Jesse Vincent
2026-07-23 15:17:55 -07:00
parent 7b177613c0
commit 178528c03e
2 changed files with 75 additions and 72 deletions

View File

@@ -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")
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`."
)
# 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:
@@ -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"<EXTREMELY_IMPORTANT>\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"</EXTREMELY_IMPORTANT>"
)
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)

View File

@@ -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