Compare commits

..

8 Commits

Author SHA1 Message Date
Drew Ritter 02654f93bf test(writing-skills): cover render-graphs execution 2026-08-05 22:38:40 -07:00
Jesse Vincent dcd3661b7c fix(writing-skills): run graphviz without a shell in render-graphs.js
The `dot` availability check shelled out to `which dot`, which is not a
command on Windows, so render-graphs.js reported graphviz as missing on
Windows even when it was installed. Replace it with a direct `dot -V`
probe via execFileSync.

Also switch the SVG render call from execSync to execFileSync('dot',
['-Tsvg']). Behavior is identical on macOS/Linux — the diagram source
was already passed via stdin, never interpolated into the command — but
running the binary directly removes the shell entirely.
2026-08-05 22:38:40 -07:00
Drew Ritter 9be44ebf40 Merge pull request #2025 from obra/hermes-harness-rebase
feat(hermes): Hermes Agent harness support — eval-verified pre_llm_call bootstrap
2026-08-05 18:23:46 -07:00
Drew Ritter 695744056e 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.
2026-08-05 17:57:31 -07:00
Kattni fb518edf7b Moves Community up, and adds ToC. 2026-08-04 20:16:46 -07:00
Jesse Vincent b6613057ae 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.
2026-07-23 16:05:54 -07:00
Jesse Vincent 178528c03e 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.
2026-07-23 15:17:55 -07:00
Jesse Vincent 7b177613c0 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 <kumarabd@users.noreply.github.com>
2026-07-23 12:18:13 -07:00
21 changed files with 632 additions and 279 deletions
+1 -1
View File
@@ -21,7 +21,7 @@
"workflow"
],
"skills": "./skills/",
"hooks": "./hooks/hooks-codex.json",
"hooks": {},
"interface": {
"displayName": "Superpowers",
"shortDescription": "Planning, TDD, debugging, and delivery workflows for coding agents",
+6
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,6 @@
name: superpowers
version: 6.2.0
description: Superpowers skills and workflow bootstrap for Hermes Agent
author: obra
provides_hooks:
- pre_llm_call
+46 -25
View File
@@ -2,10 +2,35 @@
Superpowers is a complete software development methodology for your coding agents, built on top of a set of composable skills and some initial instructions that make sure your agent uses them.
## Table of Contents
- [Quickstart](#quickstart)
- [How it works](#how-it-works)
- [Commercial Services](#commercial-services)
- [Installation](#installation)
- [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)
- [The Basic Workflow](#the-basic-workflow)
- [Community](#community)
- [What's Inside](#whats-inside)
- [Philosophy](#philosophy)
- [Contributing](#contributing)
- [Updating](#updating)
- [License](#license)
- [Visual companion telemetry](#visual-companion-telemetry)
## 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
@@ -92,22 +117,6 @@ Superpowers is available via the [official Codex plugin marketplace](https://git
- Select `Install Plugin`.
#### Codex: compaction re-injection hook
Codex compacts long sessions, replacing the transcript with a summary that
drops Superpowers' skill instructions mid-run — long autonomous workflows
(like subagent-driven-development) then drift back to harness defaults.
Claude Code re-injects the bootstrap after every compaction; the plugin ships
a SessionStart hook (`hooks/hooks-codex.json`) that restores the same
behavior on Codex (0.145+). It fires only on post-compaction re-starts
(`source: "compact"`) and is silent at normal session start.
The hook installs with the plugin — no configuration needed. Codex asks you
to review and trust it once, the first time it loads after install or update.
Headless automation (CI, eval harnesses) must pass
`--dangerously-bypass-hook-trust` instead, because untrusted hooks are
skipped silently.
### Cursor
- In Cursor Agent chat, install from marketplace:
@@ -209,6 +218,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.
@@ -227,6 +248,14 @@ The Pi package loads the Superpowers skills and a small extension that injects t
**The agent checks for relevant skills before any task.** Mandatory workflows, not suggestions.
## Community
Superpowers is built by [Jesse Vincent](https://blog.fsck.com) and the rest of the folks at [Prime Radiant](https://primeradiant.com).
- **Discord**: [Join us](https://discord.gg/35wsABTejz) for community support, questions, and sharing what you're building with Superpowers
- **Issues**: https://github.com/obra/superpowers/issues
- **Release announcements**: [Sign up](https://primeradiant.com/superpowers/) to get notified about new versions
## What's Inside
### Skills Library
@@ -287,11 +316,3 @@ MIT License - see LICENSE file for details
## Visual companion telemetry
Because skills and plugins don't provide any feedback to creators, we have no idea how many of you are using Superpowers. By default, the Prime Radiant logo on brainstorming's optional visual companion feature is loaded from our website. It includes the version of Superpowers in use. It does not include any details about your project, prompt, or coding agent. We don't see your clicks or anything about what you're building. This helps us have a rough idea of how many folks are using Superpowers and which version of Superpowers they're using. It's 100% optional. To disable this, set the environment variable `SUPERPOWERS_DISABLE_TELEMETRY` to any true value. Superpowers also honors Claude Code's `DISABLE_TELEMETRY` and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` opt-outs.
## Community
Superpowers is built by [Jesse Vincent](https://blog.fsck.com) and the rest of the folks at [Prime Radiant](https://primeradiant.com).
- **Discord**: [Join us](https://discord.gg/35wsABTejz) for community support, questions, and sharing what you're building with Superpowers
- **Issues**: https://github.com/obra/superpowers/issues
- **Release announcements**: [Sign up](https://primeradiant.com/superpowers/) to get notified about new versions
+5 -7
View File
@@ -237,12 +237,10 @@ nesting differ per harness**.
- Manifests: `.cursor-plugin/plugin.json` is the Shape A manifest example that
points the harness at `./skills/` and the right `hooks-*.json`. Claude Code's
`.claude-plugin/plugin.json` sets neither field — it auto-discovers `skills/`
and `hooks/hooks.json` by convention. Codex's `.codex-plugin/plugin.json`
points `hooks` at `./hooks/hooks-codex.json` — a compaction-only hook, not a
bootstrap injector: Codex surfaces skills natively at session start, so its
hook fires only on post-compaction re-starts. The explicit pointer also
suppresses Codex's `hooks/hooks.json` auto-discovery fallback, which would
otherwise run the Claude Code hook.
and `hooks/hooks.json` by convention. Do **not** copy Codex's
`.codex-plugin/plugin.json` for Shape A: it declares an empty `hooks` object
specifically to suppress Codex's `hooks/hooks.json` auto-discovery, because
Codex surfaces skills natively and runs no session-start hook.
> **A hook *system* is not a session-start *event*.** A harness can have a
> `hooks.json` mechanism — and even contain the literal string `SessionStart` in
@@ -787,7 +785,7 @@ Use this as the live index; when in doubt, read the files, not this table.
| Harness | Entry point | Bootstrap mechanism | Tool mapping | Tests | Distribution |
|---|---|---|---|---|---|
| Claude Code | `.claude-plugin/plugin.json` + `hooks/hooks.json` | shell hook → `hooks/session-start` (`hookSpecificOutput.additionalContext`) | native `Skill` tool; no adapter file needed | `tests/hooks/` | marketplace |
| Codex | `.codex-plugin/plugin.json` + `hooks/hooks-codex.json` | native skill discovery at startup; shell hook → `hooks/session-start-codex` re-injects after compaction only | `references/codex-tools.md` | `tests/codex/`, `tests/codex-plugin-sync/` | fork sync (`scripts/sync-to-codex-plugin.sh`) |
| Codex | `.codex-plugin/plugin.json` (declares empty `hooks`) | native skill discovery (no session-start hook) | `references/codex-tools.md` | `tests/codex/`, `tests/codex-plugin-sync/` | fork sync (`scripts/sync-to-codex-plugin.sh`) |
| Cursor | `.cursor-plugin/plugin.json` + `hooks/hooks-cursor.json` | shell hook → `hooks/session-start` (`additional_context`) | none needed (Claude Codecompatible tool surface) | `tests/hooks/` | hand-authored |
| Copilot CLI | (shares Claude Code hook path; `COPILOT_CLI` env) | shell hook → `hooks/session-start` (`additionalContext`) | none needed (Claude Codecompatible tool surface) | `tests/hooks/` | — |
| Gemini CLI | `gemini-extension.json` + `GEMINI.md` | instructions file `@`-includes bootstrap + mapping | `references/gemini-tools.md` | — | `gemini extensions install` |
-17
View File
@@ -1,17 +0,0 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "\"${PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start-codex",
"async": false,
"timeout": 30
}
]
}
]
}
}
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env bash
# Codex SessionStart hook for the superpowers plugin.
#
# Codex re-fires SessionStart with source:"compact" after every context
# compaction (verified on codex-cli 0.145.0). Compaction replaces the live
# context with a summary, which sheds the using-superpowers bootstrap and any
# active skill's instructions — the measured cause of mid-session dispatch
# drift in long multi-agent runs. This hook re-injects the bootstrap at
# exactly that moment, restoring the same re-injection Claude Code performs
# via its "startup|clear|compact" SessionStart matcher.
#
# On source:"startup" it emits nothing: the native Codex plugin path owns
# session-start injection, and duplicating it here would recreate the
# redundancy that led to the original session-start-codex hook's removal.
#
# Codex injects raw hook stdout into the model's context (verified with
# sentinel probes), so output is plain text — not the JSON envelopes other
# harnesses require of hooks/session-start.
#
# A hook failure must never break a session: every path fails open to empty
# output and exit 0.
set -u
payload="$(cat 2>/dev/null || true)"
# Act only on post-compaction re-fires. Tolerate arbitrary whitespace around
# the JSON colon; anything unparseable falls through to a silent no-op.
if ! printf '%s' "$payload" | grep -qE '"source"[[:space:]]*:[[:space:]]*"compact"'; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
using_superpowers_content="$(cat "${PLUGIN_ROOT}/skills/using-superpowers/SKILL.md" 2>/dev/null)" || using_superpowers_content=""
if [ -z "$using_superpowers_content" ]; then
exit 0
fi
# printf instead of heredocs throughout: heredocs hang on bash 5.3+.
# See: https://github.com/obra/superpowers/issues/571
printf '%s\n' "<EXTREMELY_IMPORTANT>"
printf '%s\n\n' "You have superpowers."
printf '%s\n\n' "**Below is the full content of your 'superpowers:using-superpowers' skill - your introduction to using skills. For all other skills, use the 'Skill' tool:**"
printf '%s\n' "$using_superpowers_content"
printf '%s\n\n' "</EXTREMELY_IMPORTANT>"
printf '%s\n' "<CONTEXT_RESTORED>"
printf '%s\n' "Your context was just summarized (compacted). The summary preserves your progress but not your working instructions — the files are authoritative."
printf '%s\n' ""
printf '%s\n' "Before your next tool call:"
printf '%s\n' "- Re-read the SKILL.md of any skill you are mid-way through executing. If you are executing subagent-driven-development, re-read skills/subagent-driven-development/SKILL.md."
printf '%s\n' "- On Codex, also re-read skills/using-superpowers/references/codex-tools.md and follow its dispatch rules on every spawn_agent call."
printf '%s\n' "</CONTEXT_RESTORED>"
exit 0
+3 -7
View File
@@ -40,9 +40,8 @@ Options:
-h, --help Show this help.
The archive is rootless: .codex-plugin/, assets/, skills/, README.md, LICENSE,
CODE_OF_CONDUCT.md, and the Codex SessionStart hook (hooks/hooks-codex.json plus
its two scripts) sit at the archive root. Source-only repo files, other-harness
hooks, tests, docs, and other harness manifests are intentionally not shipped.
and CODE_OF_CONDUCT.md sit at the archive root. Source-only repo files, hooks, tests,
docs, and other harness manifests are intentionally not shipped.
EOF
}
@@ -239,9 +238,6 @@ git -C "$REPO_ROOT" -c tar.umask=0022 archive --format=tar "$REF" -- \
LICENSE \
README.md \
assets \
hooks/hooks-codex.json \
hooks/run-hook.cmd \
hooks/session-start-codex \
skills \
| tar -xpf - -C "$STAGE"
@@ -337,7 +333,7 @@ esac
unexpected_paths="$(
printf '%s\n' "$archive_paths" |
grep -E '(^superpowers/|^\.agents/|^hooks/hooks\.json$|^hooks/hooks-cursor\.json$|^hooks/session-start$|package\.json$|^\.git|^\.pytest_cache|^\.ruff_cache|^scripts/|^tests/|^docs/|^evals/|^lib/|^\.claude|^\.cursor|^\.kimi|^\.opencode|^\.pi|^AGENTS\.md$|^CLAUDE\.md$|^GEMINI\.md$|^RELEASE-NOTES\.md$|^CHANGELOG\.md$)' || true
grep -E '(^superpowers/|^\.agents/|^hooks/|package\.json$|^\.git|^\.pytest_cache|^\.ruff_cache|^scripts/|^tests/|^docs/|^evals/|^lib/|^\.claude|^\.cursor|^\.kimi|^\.opencode|^\.pi|^AGENTS\.md$|^CLAUDE\.md$|^GEMINI\.md$|^RELEASE-NOTES\.md$|^CHANGELOG\.md$)' || true
)"
if [[ -n "$unexpected_paths" ]]; then
printf '%s\n' "$unexpected_paths" | sed 's/^/ /' >&2
+1
View File
@@ -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
@@ -78,22 +78,6 @@ default_subagent_model = "<a mid-tier model from your spawn allowlist>"
default_subagent_reasoning_effort = "medium"
```
## Compaction sheds these instructions
Context compaction replaces your transcript with a summary that keeps
your progress but not your working instructions — the first
post-compaction dispatch is where routing drift starts, and once one
bare spawn lands, the broken pattern becomes its own precedent. The
plugin ships a compaction re-injection hook (`hooks/hooks-codex.json`,
Codex 0.145+) that restores the bootstrap after every compaction; it
needs one-time trust approval, so if you never see a
`<CONTEXT_RESTORED>` block after a compaction, tell your human partner
the hook may be untrusted or unsupported on this version. Without it,
re-ground yourself: when a summary appears in your context, re-read
this file and the SKILL.md of the skill you are mid-way through
executing before your next dispatch, and trust the ledger over your
summarized memory of what happened.
## Environment Detection
Skills that create worktrees or finish branches should detect their
@@ -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.
+7 -6
View File
@@ -13,9 +13,9 @@
* Requires: graphviz (dot) installed on system
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
import * as fs from 'fs';
import * as path from 'path';
import { execFileSync } from 'child_process';
function extractDotBlocks(markdown) {
const blocks = [];
@@ -69,7 +69,7 @@ ${bodies.join('\n\n')}
function renderToSvg(dotContent) {
try {
return execSync('dot -Tsvg', {
return execFileSync('dot', ['-Tsvg'], {
input: dotContent,
encoding: 'utf-8',
maxBuffer: 10 * 1024 * 1024
@@ -107,9 +107,10 @@ function main() {
process.exit(1);
}
// Check if dot is available
// Check if dot is available. Run the binary directly rather than probing
// with `which`, which is not a command on Windows.
try {
execSync('which dot', { encoding: 'utf-8' });
execFileSync('dot', ['-V'], { stdio: 'ignore' });
} catch {
console.error('Error: graphviz (dot) not found. Install with:');
console.error(' brew install graphviz # macOS');
+12 -24
View File
@@ -52,37 +52,25 @@ if not plugin_manifest.exists():
manifest = json.loads(plugin_manifest.read_text(encoding="utf-8"))
assert_equal(manifest.get("name"), plugin.get("name"), "plugin manifest name")
# The Codex manifest must declare its hooks explicitly. An absent field makes
# load_plugin_hooks fall back to a hardcoded DEFAULT_HOOKS_CONFIG_FILE =
# "hooks/hooks.json" — the Claude Code SessionStart hook, which injects the
# bootstrap at startup and must not run on Codex. The explicit pointer both
# registers the Codex compaction re-injection hook and overrides that fallback.
# Codex auto-discovers a plugin's hooks/hooks.json whenever the Codex manifest
# has no `hooks` field: load_plugin_hooks falls back to a hardcoded
# DEFAULT_HOOKS_CONFIG_FILE = "hooks/hooks.json" and registers it. That file is
# the Claude Code SessionStart hook, it is tracked in this repo, and this
# marketplace installs the whole repo root (source url "./"), so on Codex the
# fallback re-registers the SessionStart hook and its install-time trust prompt.
# Declaring an empty inline hooks object ({}) parses as an empty inline hook set
# and suppresses the auto-discovery. An absent field, an empty array ([]), and
# an empty inline list all collapse back to the fallback, so the value must be
# exactly an empty object.
hooks_config = repo_root / "hooks" / "hooks.json"
if not hooks_config.exists():
raise AssertionError("hooks/hooks.json must exist (Claude Code SessionStart hook)")
assert_equal(
manifest.get("hooks"),
"./hooks/hooks-codex.json",
"Codex manifest must point hooks at the Codex hook config (an absent field "
"falls back to auto-discovering the Claude Code hooks/hooks.json)",
{},
"Codex manifest must declare empty hooks {} to suppress hooks/hooks.json auto-discovery",
)
codex_hooks_path = repo_root / "hooks" / "hooks-codex.json"
if not codex_hooks_path.exists():
raise AssertionError("hooks/hooks-codex.json must exist (Codex manifest points at it)")
codex_hooks = json.loads(codex_hooks_path.read_text(encoding="utf-8"))
session_start = codex_hooks["hooks"]["SessionStart"]
assert_equal(len(session_start), 1, "Codex SessionStart hook group count")
assert_equal(session_start[0].get("matcher"), "compact", "Codex hook matcher")
entry = session_start[0]["hooks"][0]
assert_equal(entry.get("type"), "command", "Codex hook type")
command = entry.get("command", "")
if "${PLUGIN_ROOT}" not in command or not command.endswith("session-start-codex"):
raise AssertionError(
f"Codex hook command must run session-start-codex via ${{PLUGIN_ROOT}}: {command!r}"
)
print("Codex marketplace manifest looks good")
PY
+2 -5
View File
@@ -141,7 +141,7 @@ tar_extracted="$TEST_ROOT/tar-extracted"
write_metadata_fixture "$metadata_source"
source_hooks="$(python3 -c 'import json; print(json.load(open("'"$REPO_ROOT"'/.codex-plugin/plugin.json")).get("hooks"))')"
assert_equals "$source_hooks" "./hooks/hooks-codex.json" "source Codex manifest declares the Codex hook config"
assert_equals "$source_hooks" "{}" "source Codex manifest suppresses local hook auto-discovery"
if output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_source" --output "$archive" 2>&1)"; then
pass "package script exits successfully"
@@ -163,13 +163,10 @@ assert_contains "$output" "SHA-256:" "reports archive checksum"
extract_archive "$archive" "$extracted"
archive_paths="$(list_archive "$archive" | normalize_archive_paths)"
unexpected_pattern='(^superpowers/|^\.agents/|^hooks/hooks\.json$|^hooks/hooks-cursor\.json$|^hooks/session-start$|package\.json$|^\.git|^\.pytest_cache|^\.ruff_cache|^scripts/|^tests/|^docs/|^evals/|^lib/|^\.claude|^\.cursor|^\.kimi|^\.opencode|^\.pi|^AGENTS\.md$|^CLAUDE\.md$|^GEMINI\.md$|^RELEASE-NOTES\.md$|^CHANGELOG\.md$)'
unexpected_pattern='(^superpowers/|^\.agents/|^hooks/|package\.json$|^\.git|^\.pytest_cache|^\.ruff_cache|^scripts/|^tests/|^docs/|^evals/|^lib/|^\.claude|^\.cursor|^\.kimi|^\.opencode|^\.pi|^AGENTS\.md$|^CLAUDE\.md$|^GEMINI\.md$|^RELEASE-NOTES\.md$|^CHANGELOG\.md$)'
assert_not_matches "$archive_paths" "$unexpected_pattern" "archive excludes source-only paths"
assert_contains "$archive_paths" ".codex-plugin/plugin.json" "archive includes Codex manifest"
assert_contains "$archive_paths" "skills/brainstorming/SKILL.md" "archive includes skills"
assert_contains "$archive_paths" "hooks/hooks-codex.json" "archive includes Codex hook config"
assert_contains "$archive_paths" "hooks/session-start-codex" "archive includes Codex hook script"
assert_contains "$archive_paths" "hooks/run-hook.cmd" "archive includes hook runner"
assert_contains "$archive_paths" "skills/brainstorming/agents/openai.yaml" "archive includes OpenAI skill metadata"
assert_contains "$archive_paths" "assets/app-icon.png" "archive includes app icon"
assert_contains "$archive_paths" "assets/superpowers-small.svg" "archive includes composer icon"
View File
+30
View File
@@ -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
View File
@@ -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"
)
+142
View File
@@ -0,0 +1,142 @@
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.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."""
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_only_pre_llm_call_hook(self, mock_ctx):
plugin = _load_plugin()
plugin.register(mock_ctx)
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_turn_returns_bootstrap_context(self, mock_ctx):
plugin = _load_plugin()
plugin.register(mock_ctx)
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("<EXTREMELY_IMPORTANT>")
assert content.rstrip().endswith("</EXTREMELY_IMPORTANT>")
def test_later_turns_return_none(self, mock_ctx):
plugin = _load_plugin()
plugin.register(mock_ctx)
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_hook_tolerates_future_kwargs(self, mock_ctx):
plugin = _load_plugin()
plugin.register(mock_ctx)
result = _fire_pre_llm(
mock_ctx, is_first_turn=True, telemetry_schema_version=3
)
assert BOOTSTRAP_MARKER in result["context"]
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 _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_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)
-115
View File
@@ -1,115 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
HOOK_UNDER_TEST="$REPO_ROOT/hooks/session-start-codex"
CONFIG_UNDER_TEST="$REPO_ROOT/hooks/hooks-codex.json"
FAILURES=0
pass() {
echo " [PASS] $1"
}
fail() {
echo " [FAIL] $1"
FAILURES=$((FAILURES + 1))
}
# run_hook <stdin-payload> — echoes hook stdout; fails the calling test on
# non-zero exit. env -i mirrors the codex hook executor's clean environment.
run_hook() {
printf '%s' "$1" | env -i PATH="${PATH:-}" bash "$HOOK_UNDER_TEST"
}
echo "Codex SessionStart hook tests"
startup_payload='{"session_id":"s","hook_event_name":"SessionStart","model":"gpt-5.6-terra","source":"startup"}'
if output="$(run_hook "$startup_payload")" && [ -z "$output" ]; then
pass "source=startup emits nothing and exits 0"
else
fail "source=startup emits nothing and exits 0"
printf '%s\n' "$output" | head -3 | sed 's/^/ /'
fi
compact_payload='{"session_id":"s","hook_event_name":"SessionStart","model":"gpt-5.6-terra","source":"compact"}'
if output="$(run_hook "$compact_payload")"; then
ok=1
for needle in \
"<EXTREMELY_IMPORTANT>" \
"You have superpowers." \
"name: using-superpowers" \
"<CONTEXT_RESTORED>" \
"subagent-driven-development/SKILL.md" \
"references/codex-tools.md"; do
if [[ "$output" != *"$needle"* ]]; then
ok=0
echo " missing: $needle"
fi
done
if [ "$ok" -eq 1 ]; then
pass "source=compact emits bootstrap plus re-read addendum"
else
fail "source=compact emits bootstrap plus re-read addendum"
fi
else
fail "source=compact emits bootstrap plus re-read addendum (hook exited non-zero)"
fi
# Whitespace-tolerant source matching (serializers vary).
spaced_payload='{"hook_event_name":"SessionStart", "source" : "compact"}'
if output="$(run_hook "$spaced_payload")" && [[ "$output" == *"<CONTEXT_RESTORED>"* ]]; then
pass "whitespace around the source key still triggers injection"
else
fail "whitespace around the source key still triggers injection"
fi
if output="$(printf '' | env -i PATH="${PATH:-}" bash "$HOOK_UNDER_TEST")" && [ -z "$output" ]; then
pass "empty stdin fails open to no output, exit 0"
else
fail "empty stdin fails open to no output, exit 0"
fi
if output="$(run_hook 'not json at all {{{')" && [ -z "$output" ]; then
pass "garbage stdin fails open to no output, exit 0"
else
fail "garbage stdin fails open to no output, exit 0"
fi
# A compact mention inside some other field must not trigger injection.
decoy_payload='{"hook_event_name":"SessionStart","source":"startup","cwd":"/tmp/compact"}'
if output="$(run_hook "$decoy_payload")" && [ -z "$output" ]; then
pass "compact appearing outside the source field does not trigger"
else
fail "compact appearing outside the source field does not trigger"
fi
if node -e '
const config = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const group = config.hooks.SessionStart[0];
if (group.matcher !== "compact") {
console.error(`hook matcher is ${JSON.stringify(group.matcher)}, expected "compact"`);
process.exit(1);
}
const entry = group.hooks[0];
if (entry.type !== "command") {
console.error(`hook type is ${JSON.stringify(entry.type)}, expected "command"`);
process.exit(1);
}
if (!entry.command.includes("${PLUGIN_ROOT}") || !/run-hook\.cmd" session-start-codex$/.test(entry.command)) {
console.error(`unexpected command shape: ${entry.command}`);
process.exit(1);
}
' "$CONFIG_UNDER_TEST"; then
pass "hooks-codex.json runs session-start-codex via \${PLUGIN_ROOT} on compact"
else
fail "hooks-codex.json runs session-start-codex via \${PLUGIN_ROOT} on compact"
fi
if [[ "$FAILURES" -gt 0 ]]; then
echo "STATUS: FAILED ($FAILURES failure(s))"
exit 1
fi
echo "STATUS: PASSED"
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -u
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
SCRIPT_UNDER_TEST="$REPO_ROOT/skills/writing-skills/render-graphs.js"
NODE_BIN="$(command -v node)"
PASSES=0
FAILURES=0
TEST_ROOT="$(mktemp -d)"
cleanup() {
rm -rf "$TEST_ROOT"
}
trap cleanup EXIT
pass() {
echo " [PASS] $1"
PASSES=$((PASSES + 1))
}
fail() {
echo " [FAIL] $1"
FAILURES=$((FAILURES + 1))
}
assert_contains() {
local haystack="$1"
local needle="$2"
local description="$3"
if printf '%s' "$haystack" | grep -Fq -- "$needle"; then
pass "$description"
else
fail "$description"
echo " expected to find: $needle"
fi
}
assert_not_contains() {
local haystack="$1"
local needle="$2"
local description="$3"
if printf '%s' "$haystack" | grep -Fq -- "$needle"; then
fail "$description"
echo " did not expect to find: $needle"
else
pass "$description"
fi
}
fixture="$TEST_ROOT/fixture-skill"
mkdir -p "$fixture" "$TEST_ROOT/empty-path"
cat >"$fixture/SKILL.md" <<'EOF'
---
name: fixture-skill
---
# Fixture Skill
```dot
digraph fixture_graph {
start -> end;
}
```
EOF
echo "Writing-skills render-graphs tests"
missing_dot_output="$(PATH="$TEST_ROOT/empty-path" "$NODE_BIN" "$SCRIPT_UNDER_TEST" "$fixture" 2>&1)"
missing_dot_status=$?
if [[ "$missing_dot_status" -ne 0 ]]; then
pass "missing Graphviz exits non-zero"
else
fail "missing Graphviz exits non-zero"
fi
assert_contains "$missing_dot_output" "Error: graphviz (dot) not found." "missing Graphviz reports install guidance"
assert_not_contains "$missing_dot_output" "ReferenceError: require is not defined" "script runs as an ES module"
render_output="$("$NODE_BIN" "$SCRIPT_UNDER_TEST" "$fixture" 2>&1)"
render_status=$?
if [[ "$render_status" -eq 0 ]]; then
pass "fixture diagram renders"
else
fail "fixture diagram renders"
printf '%s\n' "$render_output"
fi
assert_contains "$render_output" "Found 1 diagram(s)" "reports discovered diagram"
assert_contains "$render_output" "Rendered: fixture_graph.svg" "reports rendered SVG"
if [[ -f "$fixture/diagrams/fixture_graph.svg" ]]; then
pass "writes SVG output"
else
fail "writes SVG output"
fi
if [[ -f "$fixture/diagrams/fixture_graph.svg" ]] && grep -Fq "<svg" "$fixture/diagrams/fixture_graph.svg"; then
pass "SVG output has SVG markup"
else
fail "SVG output has SVG markup"
fi
echo
echo "Results: $PASSES passed, $FAILURES failed"
if [[ "$FAILURES" -gt 0 ]]; then
exit 1
fi