Selaa lähdekoodia

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.
Jesse Vincent 2 kuukautta sitten
vanhempi
sitoutus
178528c03e
2 muutettua tiedostoa jossa 76 lisäystä ja 73 poistoa
  1. 75 72
      .hermes-plugin/__init__.py
  2. 1 1
      .hermes-plugin/plugin.yaml

+ 75 - 72
.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
-
-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
+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())
 
-    with open(skill_path, encoding="utf-8") as f:
-        raw = 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()
 
-    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
-
-    ctx.register_hook("on_session_start", on_session_start)
+    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)

+ 1 - 1
.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