mirror of
https://github.com/obra/superpowers.git
synced 2026-08-10 17:15:41 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02654f93bf | |||
| dcd3661b7c | |||
| 9be44ebf40 | |||
| 695744056e | |||
| fb518edf7b | |||
| b6613057ae | |||
| 178528c03e | |||
| 7b177613c0 |
@@ -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",
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 Code–compatible 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 Code–compatible tool surface) | `tests/hooks/` | — |
|
||||
| Gemini CLI | `gemini-extension.json` + `GEMINI.md` | instructions file `@`-includes bootstrap + mapping | `references/gemini-tools.md` | — | `gemini extensions install` |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Executable
+113
@@ -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
|
||||
Reference in New Issue
Block a user