diff --git a/README.md b/README.md
index beec279..509a311 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -149,6 +149,14 @@ agy plugin install https://github.com/DietrichGebert/ponytail
It reuses this repo's `gemini-extension.json`. One difference: Antigravity converts the `/ponytail` commands into skills, so you type them into the chat (e.g. `/ponytail-review` as a message) instead of picking them from a slash menu. Until the migration completes (around June 18, 2026), `gemini extensions install` still works too. To run it as an always-on rule instead, drop the ruleset into `.agents/rules/`.
+### Hermes Agent
+
+```bash
+hermes plugins install DietrichGebert/ponytail --enable
+```
+
+Restart Hermes after installing. The plugin injects the active Ponytail mode before each LLM turn, registers the bundled skills as `ponytail:`, and adds `/ponytail`, `/ponytail-review`, `/ponytail-audit`, `/ponytail-debt`, and `/ponytail-help`. In shared gateways, restrict `/ponytail` to trusted users with Hermes slash-command access controls; runtime mode is process-local.
+
That was it. He'd be proud. He won't say it.
Active every session, with a handful of commands (see [Commands](#commands)). `/ponytail ultra` exists for when the codebase has wronged you personally. Startup and mode-change text shows the current mode.
@@ -175,7 +183,7 @@ Which files map to which agent: [Agent portability](docs/agent-portability.md).
| `/ponytail-debt` | Harvest the `ponytail:` shortcuts you've deferred into a ledger, so "later" doesn't become "never". |
| `/ponytail-help` | Quick reference for the commands above. |
-Commands need a skill-capable host (Claude Code, Codex, OpenCode, Gemini, pi). In Codex they're skills, invoke with `@` (`@ponytail-review`). The instruction-only adapters (Cursor, Windsurf, Cline, Copilot, Kiro, Antigravity) load the always-on ruleset without the commands.
+Commands need a command/skill-capable host (Claude Code, Codex, OpenCode, Gemini, pi, Hermes Agent). In Codex they're skills, invoke with `@` (`@ponytail-review`). The instruction-only adapters (Cursor, Windsurf, Cline, Copilot, Kiro, Antigravity) load the always-on ruleset without the commands.
## Development
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..0ba03ab
--- /dev/null
+++ b/__init__.py
@@ -0,0 +1,216 @@
+"""Hermes plugin for Ponytail."""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+from pathlib import Path
+from typing import Any, Callable
+
+DEFAULT_MODE = "full"
+RUNTIME_MODES = {"off", "lite", "full", "ultra"}
+CONFIG_MODES = RUNTIME_MODES | {"review"}
+SKILL_COMMANDS = {
+ "ponytail-review": "Review the current diff or provided target for over-engineering.",
+ "ponytail-audit": "Audit the repo for over-engineering and deletion opportunities.",
+ "ponytail-debt": "List every deliberate `ponytail:` shortcut and its upgrade path.",
+ "ponytail-help": "Show the Ponytail command reference.",
+}
+
+ROOT = Path(__file__).resolve().parent
+SKILLS_DIR = ROOT / "skills"
+PONYTAIL_SKILL = SKILLS_DIR / "ponytail" / "SKILL.md"
+REVIEW_SKILL = SKILLS_DIR / "ponytail-review" / "SKILL.md"
+
+_current_mode = None
+
+
+def _normalize_runtime_mode(mode: str | None) -> str | None:
+ if not isinstance(mode, str):
+ return None
+ mode = mode.strip().lower()
+ return mode if mode in RUNTIME_MODES else None
+
+
+def _normalize_config_mode(mode: str | None) -> str | None:
+ if not isinstance(mode, str):
+ return None
+ mode = mode.strip().lower()
+ return mode if mode in CONFIG_MODES else None
+
+
+def _config_dir() -> Path:
+ if os.environ.get("XDG_CONFIG_HOME"):
+ return Path(os.environ["XDG_CONFIG_HOME"]) / "ponytail"
+ if os.name == "nt":
+ return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / "ponytail"
+ return Path.home() / ".config" / "ponytail"
+
+
+def _default_mode() -> str:
+ env_mode = _normalize_config_mode(os.environ.get("PONYTAIL_DEFAULT_MODE"))
+ if env_mode:
+ return env_mode
+ try:
+ data = json.loads((_config_dir() / "config.json").read_text(encoding="utf-8"))
+ file_mode = _normalize_config_mode(data.get("defaultMode"))
+ if file_mode:
+ return file_mode
+ except Exception:
+ pass
+ return DEFAULT_MODE
+
+
+def _strip_frontmatter(text: str) -> str:
+ return re.sub(r"^---[\s\S]*?---\s*", "", text or "", count=1)
+
+
+def _filter_skill_body_for_mode(body: str, mode: str) -> str:
+ effective = _normalize_runtime_mode(mode) or DEFAULT_MODE
+ lines = []
+ for line in _strip_frontmatter(body).splitlines():
+ table_label = re.match(r"^\|\s*\*\*(.+?)\*\*\s*\|", line)
+ if table_label:
+ label_mode = _normalize_runtime_mode(table_label.group(1))
+ if label_mode and label_mode != effective:
+ continue
+
+ example_label = re.match(r"^-\s*([^:]+):\s*", line)
+ if example_label:
+ label_mode = _normalize_runtime_mode(example_label.group(1))
+ if label_mode and label_mode != effective:
+ continue
+
+ lines.append(line)
+ return "\n".join(lines)
+
+
+def _fallback_instructions(mode: str) -> str:
+ return (
+ f"PONYTAIL MODE ACTIVE — level: {mode}\n\n"
+ "You are a lazy senior developer. Lazy means efficient, not careless. "
+ "The best code is the code never written.\n\n"
+ "Before any code, stop at the first rung that holds: YAGNI, stdlib, "
+ "native platform, installed dependency, one line, then minimum code. "
+ "No unrequested abstractions, avoidable dependencies, boilerplate, or "
+ "speculative scaffolding. Deletion over addition. Boring over clever. "
+ "Do not simplify away trust-boundary validation, data-loss handling, "
+ "security, accessibility, explicitly requested behavior, or one small "
+ "runnable check for non-trivial logic."
+ )
+
+
+def build_injected_context(mode: str | None = None) -> str:
+ """Return the mode-filtered Ponytail context injected before LLM turns."""
+ configured = _normalize_config_mode(mode) or _default_mode()
+ if configured == "off":
+ return ""
+ if configured == "review":
+ try:
+ body = REVIEW_SKILL.read_text(encoding="utf-8")
+ return f"PONYTAIL MODE ACTIVE — level: review\n\n{_strip_frontmatter(body)}"
+ except OSError:
+ return "PONYTAIL MODE ACTIVE — level: review. Review diffs for unnecessary complexity."
+
+ effective = _normalize_runtime_mode(configured) or DEFAULT_MODE
+ try:
+ body = PONYTAIL_SKILL.read_text(encoding="utf-8")
+ return f"PONYTAIL MODE ACTIVE — level: {effective}\n\n{_filter_skill_body_for_mode(body, effective)}"
+ except OSError:
+ return _fallback_instructions(effective)
+
+
+def _pre_llm_call(session_id: str = "", **_: Any) -> dict[str, str] | None:
+ mode = _current_mode or _default_mode()
+ context = build_injected_context(mode)
+ return {"context": context} if context else None
+
+
+def _skill_prompt(command: str, args: str = "") -> str:
+ tail = args.strip()
+ target = f"\n\nUser arguments: {tail}" if tail else ""
+ return (
+ f"Load and follow the Hermes plugin skill `ponytail:{command}`. "
+ f"{SKILL_COMMANDS[command]}{target}"
+ )
+
+
+def _slash_access_denied(event: Any, gateway: Any, command: str) -> bool:
+ if gateway is None or event is None:
+ return False
+ checker = getattr(gateway, "_check_slash_access", None)
+ source = getattr(event, "source", None)
+ if checker is None or source is None:
+ return False
+ try:
+ return checker(source, command) is not None
+ except Exception:
+ return True
+
+
+def rewrite_gateway_command(event: Any = None, gateway: Any = None, **_: Any) -> dict[str, str] | None:
+ """Rewrite authorized gateway /ponytail-* commands into normal agent prompts."""
+ text = str(getattr(event, "text", "") or "").strip()
+ if not text.startswith("/"):
+ return None
+ head, _, rest = text[1:].partition(" ")
+ command = head.replace("_", "-").lower()
+ if command not in SKILL_COMMANDS:
+ return None
+ if _slash_access_denied(event, gateway, command):
+ return None
+ return {"action": "rewrite", "text": _skill_prompt(command, rest)}
+
+
+def _handle_mode_command(raw_args: str) -> str:
+ global _current_mode
+ arg = (raw_args or "").strip().lower()
+ if not arg:
+ mode = _current_mode or _default_mode()
+ return f"Ponytail mode: {mode}. Use `/ponytail lite|full|ultra|off`."
+ mode = _normalize_runtime_mode(arg)
+ if not mode:
+ return "Usage: /ponytail [lite|full|ultra|off]"
+ _current_mode = mode
+ return f"Ponytail mode set to {mode}."
+
+
+def _make_skill_command_handler(ctx: Any, command: str) -> Callable[[str], str]:
+ def handler(raw_args: str) -> str:
+ prompt = _skill_prompt(command, raw_args or "")
+ injected = False
+ try:
+ injected = bool(ctx.inject_message(prompt))
+ except Exception:
+ injected = False
+ if injected:
+ return f"Queued `{command}` for the agent."
+ return prompt
+
+ return handler
+
+
+def register(ctx: Any) -> None:
+ """Register Ponytail hooks, skills, and slash commands with Hermes."""
+ for child in sorted(SKILLS_DIR.iterdir() if SKILLS_DIR.exists() else []):
+ skill_md = child / "SKILL.md"
+ if child.is_dir() and skill_md.exists():
+ ctx.register_skill(child.name, skill_md)
+
+ ctx.register_hook("pre_llm_call", _pre_llm_call)
+ ctx.register_hook("pre_gateway_dispatch", rewrite_gateway_command)
+
+ ctx.register_command(
+ "ponytail",
+ _handle_mode_command,
+ description="Set Ponytail lazy senior dev mode: lite, full, ultra, or off.",
+ args_hint="[lite|full|ultra|off]",
+ )
+ for command, description in SKILL_COMMANDS.items():
+ ctx.register_command(
+ command,
+ _make_skill_command_handler(ctx, command),
+ description=description,
+ args_hint="[target or notes]",
+ )
diff --git a/after-install.md b/after-install.md
new file mode 100644
index 0000000..245aa51
--- /dev/null
+++ b/after-install.md
@@ -0,0 +1,21 @@
+# Ponytail for Hermes installed
+
+Enable it if you did not install with `--enable`:
+
+```bash
+hermes plugins enable ponytail
+```
+
+Restart Hermes or the gateway after enabling.
+
+In shared gateways, restrict `/ponytail` to trusted users with Hermes slash-command access controls; runtime mode is process-local.
+
+Commands:
+
+- `/ponytail [lite|full|ultra|off]`
+- `/ponytail-review [target]`
+- `/ponytail-audit [target]`
+- `/ponytail-debt`
+- `/ponytail-help`
+
+Bundled skills are available as `ponytail:ponytail`, `ponytail:ponytail-review`, `ponytail:ponytail-audit`, `ponytail:ponytail-debt`, and `ponytail:ponytail-help`.
diff --git a/docs/agent-portability.md b/docs/agent-portability.md
index 40b4af8..002be1c 100644
--- a/docs/agent-portability.md
+++ b/docs/agent-portability.md
@@ -12,6 +12,7 @@ to load in a given agent.
| Codex | `.codex-plugin/plugin.json`, `hooks/hooks.json`, `hooks/`, `skills/` | Plugin install with the same skills plus lifecycle hooks for activation and mode tracking. |
| OpenCode | `.opencode/plugins/ponytail.mjs`, `.opencode/command/`, `hooks/`, `skills/` | Server plugin injects the ruleset each turn via `experimental.chat.system.transform` and persists `/ponytail` switches; reuses the shared instruction builder. |
| pi | `pi-extension/`, `skills/`, `hooks/` | Package extension: injects the ruleset each turn through the shared instruction builder and registers the `/ponytail` commands. |
+| Hermes Agent | `plugin.yaml`, `__init__.py`, `skills/` | Native Hermes plugin: injects active mode through `pre_llm_call`, rewrites gateway `/ponytail-*` skill commands into agent prompts, registers `/ponytail` mode switching, and exposes bundled skills as `ponytail:`. |
| Gemini CLI | `gemini-extension.json`, `AGENTS.md`, `commands/`, `skills/` | Extension manifest points `contextFileName` at `AGENTS.md` for always-on rules, and reuses the existing `commands/*.toml` and `skills/`, which Gemini CLI auto-discovers. |
| Cursor | `.cursor/rules/ponytail.mdc` | Always-on project rule. |
| Windsurf | `.windsurf/rules/ponytail.md` | Project rule. |
diff --git a/plugin.yaml b/plugin.yaml
new file mode 100644
index 0000000..f0fb021
--- /dev/null
+++ b/plugin.yaml
@@ -0,0 +1,19 @@
+name: ponytail
+version: 0.1.0
+description: Lazy senior dev mode for Hermes Agent — always-on context, bundled skills, and slash commands.
+author: Dietrich Gebert and contributors
+provides_hooks:
+ - pre_llm_call
+ - pre_gateway_dispatch
+provides_commands:
+ - ponytail
+ - ponytail-review
+ - ponytail-audit
+ - ponytail-debt
+ - ponytail-help
+provides_skills:
+ - ponytail
+ - ponytail-review
+ - ponytail-audit
+ - ponytail-debt
+ - ponytail-help
diff --git a/tests/hermes-plugin.test.js b/tests/hermes-plugin.test.js
new file mode 100644
index 0000000..f8e90dd
--- /dev/null
+++ b/tests/hermes-plugin.test.js
@@ -0,0 +1,221 @@
+#!/usr/bin/env node
+// Hermes support is a real plugin, not just copied rules: the repo root must be
+// installable with `hermes plugins install owner/repo`, register bundled skills,
+// inject active mode context, and expose slash commands.
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const commands = ['ponytail', 'ponytail-review', 'ponytail-audit', 'ponytail-debt', 'ponytail-help'];
+const skillCommands = commands.filter((name) => name !== 'ponytail');
+
+const root = path.join(__dirname, '..');
+
+function python(script, env = {}) {
+ const result = spawnSync('python3', ['-c', script], {
+ cwd: root,
+ env: { ...process.env, ...env },
+ encoding: 'utf8',
+ });
+ if (result.status !== 0) {
+ throw new Error(`python failed\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`);
+ }
+ return result.stdout.trim();
+}
+
+test('Hermes plugin manifest matches runtime skills, hooks, commands, and package version', () => {
+ const manifestPath = path.join(root, 'plugin.yaml');
+ assert.ok(fs.existsSync(manifestPath), 'missing root plugin.yaml');
+ const manifest = fs.readFileSync(manifestPath, 'utf8');
+ const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
+ const skillDirs = fs.readdirSync(path.join(root, 'skills'))
+ .filter((name) => fs.existsSync(path.join(root, 'skills', name, 'SKILL.md')))
+ .sort();
+
+ assert.match(manifest, /^name:\s*ponytail$/m);
+ assert.match(manifest, new RegExp(`^version:\\s*${packageJson.version}$`, 'm'));
+ assert.deepEqual(commands.filter((name) => manifest.includes(` - ${name}`)), commands);
+ assert.deepEqual(skillDirs.filter((name) => manifest.includes(` - ${name}`)), skillDirs);
+ assert.match(manifest, /pre_llm_call/);
+ assert.match(manifest, /pre_gateway_dispatch/);
+});
+
+test('Hermes plugin registers every shipped skill under the ponytail namespace', () => {
+ const output = python(String.raw`
+import importlib.util, json, pathlib
+spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
+mod = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(mod)
+class Ctx:
+ def __init__(self):
+ self.skills = []
+ self.hooks = []
+ self.commands = []
+ def register_skill(self, name, path):
+ self.skills.append((name, pathlib.Path(path).as_posix()))
+ def register_hook(self, name, handler):
+ self.hooks.append(name)
+ def register_command(self, name, handler, description='', args_hint=''):
+ self.commands.append(name)
+ctx = Ctx()
+mod.register(ctx)
+print(json.dumps({'skills': ctx.skills, 'hooks': ctx.hooks, 'commands': ctx.commands}, sort_keys=True))
+`);
+ const data = JSON.parse(output);
+ assert.deepEqual(data.skills.map(([name]) => name).sort(), [
+ 'ponytail',
+ 'ponytail-audit',
+ 'ponytail-debt',
+ 'ponytail-help',
+ 'ponytail-review',
+ ]);
+ assert.ok(data.skills.every(([, skillPath]) => skillPath.endsWith('/SKILL.md')));
+ assert.ok(data.hooks.includes('pre_llm_call'));
+ assert.ok(data.commands.includes('ponytail'));
+ assert.ok(data.commands.includes('ponytail-review'));
+});
+
+test('Hermes plugin builds mode-aware injected context from the canonical skill', () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-config-'));
+ const output = python(String.raw`
+import importlib.util, json
+spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
+mod = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(mod)
+ctx = mod.build_injected_context('ultra')
+print(json.dumps({'ctx': ctx}))
+`, { XDG_CONFIG_HOME: tmp });
+ const { ctx } = JSON.parse(output);
+
+ assert.match(ctx, /PONYTAIL MODE ACTIVE — level: ultra/);
+ assert.match(ctx, /The best\s+code is the code never written/);
+ assert.match(ctx, /ultra/i);
+ assert.doesNotMatch(ctx, /^---/);
+ assert.doesNotMatch(ctx, /\|\s*\*\*Lite\*\*/i);
+});
+
+test('Hermes mode config respects env, config file, off, and invalid command behavior', () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-config-'));
+ fs.mkdirSync(path.join(tmp, 'ponytail'), { recursive: true });
+ fs.writeFileSync(path.join(tmp, 'ponytail', 'config.json'), JSON.stringify({ defaultMode: 'lite' }));
+ const output = python(String.raw`
+import importlib.util, json
+spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
+mod = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(mod)
+class Ctx:
+ def __init__(self): self.commands = {}
+ def register_skill(self, name, path): pass
+ def register_hook(self, name, handler): pass
+ def register_command(self, name, handler, description='', args_hint=''):
+ self.commands[name] = handler
+ctx = Ctx()
+mod.register(ctx)
+status_before = ctx.commands['ponytail']('')
+invalid = ctx.commands['ponytail']('maximum')
+status_after = ctx.commands['ponytail']('')
+print(json.dumps({
+ 'default': mod.build_injected_context(None),
+ 'off': mod.build_injected_context('off'),
+ 'status_before': status_before,
+ 'invalid': invalid,
+ 'status_after': status_after,
+}))
+`, { XDG_CONFIG_HOME: tmp, PONYTAIL_DEFAULT_MODE: 'ultra' });
+ const data = JSON.parse(output);
+ assert.match(data.default, /level: ultra/);
+ assert.equal(data.off, '');
+ assert.match(data.status_before, /Ponytail mode: ultra/);
+ assert.match(data.invalid, /Usage:/);
+ assert.match(data.status_after, /Ponytail mode: ultra/);
+});
+
+test('Hermes plugin review mode injects the real review skill body', () => {
+ const output = python(String.raw`
+import importlib.util, json
+spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
+mod = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(mod)
+ctx = mod.build_injected_context('review')
+print(json.dumps({'ctx': ctx}))
+`);
+ const { ctx } = JSON.parse(output);
+ assert.match(ctx, /PONYTAIL MODE ACTIVE — level: review/);
+ assert.match(ctx, /Review diffs for unnecessary complexity/);
+ assert.match(ctx, /net: - lines possible/);
+ assert.doesNotMatch(ctx, /^---/);
+});
+
+test('Hermes /ponytail command changes mode and pre_llm_call injects current context', () => {
+ const output = python(String.raw`
+import importlib.util, json
+spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
+mod = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(mod)
+class Ctx:
+ def __init__(self):
+ self.hooks = {}
+ self.commands = {}
+ def register_skill(self, name, path): pass
+ def register_hook(self, name, handler): self.hooks[name] = handler
+ def register_command(self, name, handler, description='', args_hint=''):
+ self.commands[name] = handler
+ctx = Ctx()
+mod.register(ctx)
+message = ctx.commands['ponytail']('ultra')
+injected = ctx.hooks['pre_llm_call'](session_id='s1', user_message='build it', conversation_history=[], is_first_turn=False, model='m', platform='cli')
+print(json.dumps({'message': message, 'context': injected['context']}))
+`);
+ const data = JSON.parse(output);
+ assert.match(data.message, /ultra/);
+ assert.match(data.context, /PONYTAIL MODE ACTIVE — level: ultra/);
+});
+
+test('Hermes gateway rewrite respects slash access denial', () => {
+ const output = python(String.raw`
+import importlib.util, json
+spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
+mod = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(mod)
+class Source:
+ platform = None
+ chat_id = 'c1'
+ user_id = 'u1'
+class Event:
+ text = '/ponytail-review src/app.js'
+ source = Source()
+class Gateway:
+ def _check_slash_access(self, source, command):
+ return 'denied'
+result = mod.rewrite_gateway_command(event=Event(), gateway=Gateway())
+print(json.dumps(result))
+`);
+ assert.equal(output, 'null');
+});
+
+test('Hermes gateway rewrite preserves every skill command and ignores unrelated text', () => {
+ const output = python(String.raw`
+import importlib.util, json
+spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
+mod = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(mod)
+class Event:
+ def __init__(self, text): self.text = text
+cases = {}
+for text in ['/ponytail-review x', '/ponytail_audit repo', '/ponytail-debt', '/ponytail-help', '/status', 'hello']:
+ cases[text] = mod.rewrite_gateway_command(event=Event(text))
+print(json.dumps(cases, sort_keys=True))
+`);
+ const data = JSON.parse(output);
+ assert.match(data['/ponytail-review x'].text, /ponytail-review/);
+ assert.match(data['/ponytail_audit repo'].text, /ponytail-audit/);
+ assert.match(data['/ponytail_audit repo'].text, /repo/);
+ assert.match(data['/ponytail-debt'].text, /ponytail-debt/);
+ assert.match(data['/ponytail-help'].text, /ponytail-help/);
+ assert.equal(data['/status'], null);
+ assert.equal(data.hello, null);
+});