feat: add /plan command (#1372)

* feat: add /plan command

* refactor: back /plan with bundled skill

* docs: document /plan skill
This commit is contained in:
Teknium
2026-03-14 21:18:17 -07:00
committed by GitHub
parent cb7690b2b5
commit ff3473a37c
10 changed files with 372 additions and 6 deletions

View File

@@ -1,17 +1,38 @@
"""Skill slash commands — scan installed skills and build invocation messages. """Shared slash command helpers for skills and built-in prompt-style modes.
Shared between CLI (cli.py) and gateway (gateway/run.py) so both surfaces Shared between CLI (cli.py) and gateway (gateway/run.py) so both surfaces
can invoke skills via /skill-name commands. can invoke skills via /skill-name commands and prompt-only built-ins like
/plan.
""" """
import json import json
import logging import logging
import os
import re
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_skill_commands: Dict[str, Dict[str, Any]] = {} _skill_commands: Dict[str, Dict[str, Any]] = {}
_PLAN_SLUG_RE = re.compile(r"[^a-z0-9]+")
def build_plan_path(
user_instruction: str = "",
*,
now: datetime | None = None,
) -> Path:
"""Return the default markdown path for a /plan invocation."""
hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
slug_source = (user_instruction or "").strip().splitlines()[0] if user_instruction else ""
slug = _PLAN_SLUG_RE.sub("-", slug_source.lower()).strip("-")
if slug:
slug = "-".join(part for part in slug.split("-")[:8] if part)[:48].strip("-")
slug = slug or "conversation-plan"
timestamp = (now or datetime.now()).strftime("%Y-%m-%d_%H%M%S")
return hermes_home / "plans" / f"{timestamp}-{slug}.md"
def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tuple[dict[str, Any], Path | None, str] | None: def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tuple[dict[str, Any], Path | None, str] | None:
@@ -56,6 +77,7 @@ def _build_skill_message(
skill_dir: Path | None, skill_dir: Path | None,
activation_note: str, activation_note: str,
user_instruction: str = "", user_instruction: str = "",
runtime_note: str = "",
) -> str: ) -> str:
"""Format a loaded skill into a user/system message payload.""" """Format a loaded skill into a user/system message payload."""
from tools.skills_tool import SKILLS_DIR from tools.skills_tool import SKILLS_DIR
@@ -115,6 +137,10 @@ def _build_skill_message(
parts.append("") parts.append("")
parts.append(f"The user has provided the following instruction alongside the skill invocation: {user_instruction}") parts.append(f"The user has provided the following instruction alongside the skill invocation: {user_instruction}")
if runtime_note:
parts.append("")
parts.append(f"[Runtime note: {runtime_note}]")
return "\n".join(parts) return "\n".join(parts)
@@ -172,6 +198,7 @@ def build_skill_invocation_message(
cmd_key: str, cmd_key: str,
user_instruction: str = "", user_instruction: str = "",
task_id: str | None = None, task_id: str | None = None,
runtime_note: str = "",
) -> Optional[str]: ) -> Optional[str]:
"""Build the user message content for a skill slash command invocation. """Build the user message content for a skill slash command invocation.
@@ -201,6 +228,7 @@ def build_skill_invocation_message(
skill_dir, skill_dir,
activation_note, activation_note,
user_instruction=user_instruction, user_instruction=user_instruction,
runtime_note=runtime_note,
) )

28
cli.py
View File

@@ -1080,6 +1080,7 @@ from agent.skill_commands import (
scan_skill_commands, scan_skill_commands,
get_skill_commands, get_skill_commands,
build_skill_invocation_message, build_skill_invocation_message,
build_plan_path,
build_preloaded_skills_prompt, build_preloaded_skills_prompt,
) )
@@ -3193,6 +3194,8 @@ class HermesCLI:
elif cmd_lower.startswith("/personality"): elif cmd_lower.startswith("/personality"):
# Use original case (handler lowercases the personality name itself) # Use original case (handler lowercases the personality name itself)
self._handle_personality_command(cmd_original) self._handle_personality_command(cmd_original)
elif cmd_lower == "/plan" or cmd_lower.startswith("/plan "):
self._handle_plan_command(cmd_original)
elif cmd_lower == "/retry": elif cmd_lower == "/retry":
retry_msg = self.retry_last() retry_msg = self.retry_last()
if retry_msg and hasattr(self, '_pending_input'): if retry_msg and hasattr(self, '_pending_input'):
@@ -3304,6 +3307,31 @@ class HermesCLI:
return True return True
def _handle_plan_command(self, cmd: str):
"""Handle /plan [request] — load the bundled plan skill."""
parts = cmd.strip().split(maxsplit=1)
user_instruction = parts[1].strip() if len(parts) > 1 else ""
plan_path = build_plan_path(user_instruction)
msg = build_skill_invocation_message(
"/plan",
user_instruction,
task_id=self.session_id,
runtime_note=(
f"Save the markdown plan with write_file to this exact path: {plan_path}"
),
)
if not msg:
self.console.print("[bold red]Failed to load the bundled /plan skill[/]")
return
_cprint(f" 📝 Plan mode queued via skill. Markdown plan target: {plan_path}")
if hasattr(self, '_pending_input'):
self._pending_input.put(msg)
else:
self.console.print("[bold red]Plan mode unavailable: input queue not initialized[/]")
def _handle_background_command(self, cmd: str): def _handle_background_command(self, cmd: str):
"""Handle /background <prompt> — run a prompt in a separate background session. """Handle /background <prompt> — run a prompt in a separate background session.

View File

@@ -1114,7 +1114,7 @@ class GatewayRunner:
# Emit command:* hook for any recognized slash command # Emit command:* hook for any recognized slash command
_known_commands = {"new", "reset", "help", "status", "stop", "model", "reasoning", _known_commands = {"new", "reset", "help", "status", "stop", "model", "reasoning",
"personality", "retry", "undo", "sethome", "set-home", "personality", "plan", "retry", "undo", "sethome", "set-home",
"compress", "usage", "insights", "reload-mcp", "reload_mcp", "compress", "usage", "insights", "reload-mcp", "reload_mcp",
"update", "title", "resume", "provider", "rollback", "update", "title", "resume", "provider", "rollback",
"background", "reasoning", "voice"} "background", "reasoning", "voice"}
@@ -1149,6 +1149,27 @@ class GatewayRunner:
if command == "personality": if command == "personality":
return await self._handle_personality_command(event) return await self._handle_personality_command(event)
if command == "plan":
try:
from agent.skill_commands import build_plan_path, build_skill_invocation_message
user_instruction = event.get_command_args().strip()
plan_path = build_plan_path(user_instruction)
event.text = build_skill_invocation_message(
"/plan",
user_instruction,
task_id=_quick_key,
runtime_note=(
f"Save the markdown plan with write_file to this exact path: {plan_path}"
),
)
if not event.text:
return "Failed to load the bundled /plan skill."
command = None
except Exception as e:
logger.exception("Failed to prepare /plan command")
return f"Failed to enter plan mode: {e}"
if command == "retry": if command == "retry":
return await self._handle_retry_command(event) return await self._handle_retry_command(event)

View File

@@ -0,0 +1,55 @@
---
name: plan
description: Plan mode for Hermes — inspect context, write a markdown plan, save it under $HERMES_HOME/plans, and do not execute the work.
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [planning, plan-mode, implementation, workflow]
related_skills: [writing-plans, subagent-driven-development]
---
# Plan Mode
Use this skill when the user wants a plan instead of execution.
## Core behavior
For this turn, you are planning only.
- Do not implement code.
- Do not edit project files except the plan markdown file.
- Do not run mutating terminal commands, commit, push, or perform external actions.
- You may inspect the repo or other context with read-only commands/tools when needed.
- Your deliverable is a markdown plan saved to `$HERMES_HOME/plans`.
## Output requirements
Write a markdown plan that is concrete and actionable.
Include, when relevant:
- Goal
- Current context / assumptions
- Proposed approach
- Step-by-step plan
- Files likely to change
- Tests / validation
- Risks, tradeoffs, and open questions
If the task is code-related, include exact file paths, likely test targets, and verification steps.
## Save location
Save the plan with `write_file` under:
- `$HERMES_HOME/plans/YYYY-MM-DD_HHMMSS-<slug>.md`
If the runtime provides a specific target path, use that exact path.
If not, create a sensible timestamped filename yourself.
## Interaction style
- If the request is clear enough, write the plan directly.
- If no explicit instruction accompanies `/plan`, infer the task from the current conversation context.
- If it is genuinely underspecified, ask a brief clarifying question instead of guessing.
- After saving the plan, reply briefly with what you planned and the saved path.

View File

@@ -1,13 +1,15 @@
"""Tests for agent/skill_commands.py — skill slash command scanning and platform filtering.""" """Tests for agent/skill_commands.py — skill slash command scanning and platform filtering."""
import os import os
from datetime import datetime
from unittest.mock import patch from unittest.mock import patch
import tools.skills_tool as skills_tool_module import tools.skills_tool as skills_tool_module
from agent.skill_commands import ( from agent.skill_commands import (
scan_skill_commands, build_plan_path,
build_skill_invocation_message,
build_preloaded_skills_prompt, build_preloaded_skills_prompt,
build_skill_invocation_message,
scan_skill_commands,
) )
@@ -272,3 +274,35 @@ Generate some audio.
assert msg is not None assert msg is not None
assert 'file_path="<path>"' in msg assert 'file_path="<path>"' in msg
class TestPlanSkillHelpers:
def test_build_plan_path_uses_hermes_home_and_slugifies_request(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
path = build_plan_path(
"Implement OAuth login + refresh tokens!",
now=datetime(2026, 3, 15, 9, 30, 45),
)
assert path == tmp_path / "plans" / "2026-03-15_093045-implement-oauth-login-refresh-tokens.md"
def test_plan_skill_message_can_include_runtime_save_path_note(self, tmp_path):
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_skill(
tmp_path,
"plan",
body="Save plans under $HERMES_HOME/plans and do not execute the work.",
)
scan_skill_commands()
msg = build_skill_invocation_message(
"/plan",
"Add a /plan command",
runtime_note="Save the markdown plan with write_file to /tmp/plans/plan.md",
)
assert msg is not None
assert "Save plans under $HERMES_HOME/plans" in msg
assert "Add a /plan command" in msg
assert "/tmp/plans/plan.md" in msg
assert "Runtime note:" in msg

View File

@@ -0,0 +1,128 @@
"""Tests for the /plan gateway slash command."""
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent.skill_commands import scan_skill_commands
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.session import SessionEntry, SessionSource
def _make_runner():
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
)
runner.adapters = {}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = SessionEntry(
session_key="agent:main:telegram:dm:c1:u1",
session_id="sess-1",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="dm",
)
runner.session_store.load_transcript.return_value = []
runner.session_store.has_any_sessions.return_value = True
runner.session_store.append_to_transcript = MagicMock()
runner.session_store.rewrite_transcript = MagicMock()
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._session_db = None
runner._reasoning_config = None
runner._provider_routing = {}
runner._fallback_model = None
runner._show_reasoning = False
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._run_agent = AsyncMock(
return_value={
"final_response": "planned",
"messages": [],
"tools": [],
"history_offset": 0,
"last_prompt_tokens": 0,
}
)
return runner
def _make_event(text="/plan"):
return MessageEvent(
text=text,
source=SessionSource(
platform=Platform.TELEGRAM,
user_id="u1",
chat_id="c1",
user_name="tester",
chat_type="dm",
),
message_id="m1",
)
def _make_plan_skill(skills_dir):
skill_dir = skills_dir / "plan"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"""---
name: plan
description: Plan mode skill.
---
# Plan
Use the current conversation context when no explicit instruction is provided.
Save plans under $HERMES_HOME/plans.
"""
)
class TestGatewayPlanCommand:
@pytest.mark.asyncio
async def test_plan_command_loads_skill_and_runs_agent(self, monkeypatch, tmp_path):
import gateway.run as gateway_run
runner = _make_runner()
event = _make_event("/plan Add OAuth login")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
monkeypatch.setattr(
"agent.model_metadata.get_model_context_length",
lambda *_args, **_kwargs: 100_000,
)
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_plan_skill(tmp_path)
scan_skill_commands()
result = await runner._handle_message(event)
assert result == "planned"
forwarded = runner._run_agent.call_args.kwargs["message"]
assert "Plan mode skill" in forwarded
assert "Add OAuth login" in forwarded
assert str(tmp_path / "plans") in forwarded
assert "Runtime note:" in forwarded
@pytest.mark.asyncio
async def test_plan_command_appears_in_help_output_via_skill_listing(self, tmp_path):
runner = _make_runner()
event = _make_event("/help")
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_plan_skill(tmp_path)
scan_skill_commands()
result = await runner._handle_help_command(event)
assert "/plan" in result

View File

@@ -0,0 +1,66 @@
"""Tests for the /plan CLI slash command."""
from unittest.mock import MagicMock, patch
from agent.skill_commands import scan_skill_commands
from cli import HermesCLI
def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.config = {}
cli_obj.console = MagicMock()
cli_obj.agent = None
cli_obj.conversation_history = []
cli_obj.session_id = "sess-123"
cli_obj._pending_input = MagicMock()
return cli_obj
def _make_plan_skill(skills_dir):
skill_dir = skills_dir / "plan"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"""---
name: plan
description: Plan mode skill.
---
# Plan
Use the current conversation context when no explicit instruction is provided.
Save plans under $HERMES_HOME/plans.
"""
)
class TestCLIPlanCommand:
def test_plan_command_queues_plan_skill_message(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cli_obj = _make_cli()
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_plan_skill(tmp_path)
scan_skill_commands()
result = cli_obj.process_command("/plan Add OAuth login")
assert result is True
cli_obj._pending_input.put.assert_called_once()
queued = cli_obj._pending_input.put.call_args[0][0]
assert "Plan mode skill" in queued
assert "Add OAuth login" in queued
assert str(tmp_path / "plans") in queued
assert "Runtime note:" in queued
def test_plan_without_args_uses_skill_context_guidance(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cli_obj = _make_cli()
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_plan_skill(tmp_path)
scan_skill_commands()
cli_obj.process_command("/plan")
queued = cli_obj._pending_input.put.call_args[0][0]
assert "current conversation context" in queued
assert "conversation-plan" in queued

View File

@@ -236,6 +236,7 @@ Skills for controlling smart home devices — lights, switches, sensors, and hom
| Skill | Description | Path | | Skill | Description | Path |
|-------|-------------|------| |-------|-------------|------|
| `code-review` | Guidelines for performing thorough code reviews with security and quality focus | `software-development/code-review` | | `code-review` | Guidelines for performing thorough code reviews with security and quality focus | `software-development/code-review` |
| `plan` | Plan mode for Hermes — inspect context, write a markdown plan, save it under `$HERMES_HOME/plans`, and do not execute the work. | `software-development/plan` |
| `requesting-code-review` | Use when completing tasks, implementing major features, or before merging. Validates work meets requirements through systematic review process. | `software-development/requesting-code-review` | | `requesting-code-review` | Use when completing tasks, implementing major features, or before merging. Validates work meets requirements through systematic review process. | `software-development/requesting-code-review` |
| `subagent-driven-development` | Use when executing implementation plans with independent tasks. Dispatches fresh delegate_task per task with two-stage review (spec compliance then code quality). | `software-development/subagent-driven-development` | | `subagent-driven-development` | Use when executing implementation plans with independent tasks. Dispatches fresh delegate_task per task with two-stage review (spec compliance then code quality). | `software-development/subagent-driven-development` |
| `systematic-debugging` | Use when encountering any bug, test failure, or unexpected behavior. 4-phase root cause investigation — NO fixes without understanding the problem first. | `software-development/systematic-debugging` | | `systematic-debugging` | Use when encountering any bug, test failure, or unexpected behavior. 4-phase root cause investigation — NO fixes without understanding the problem first. | `software-development/systematic-debugging` |

View File

@@ -11,7 +11,7 @@ Hermes has two slash-command surfaces:
- **Interactive CLI slash commands** — handled by `cli.py` / `hermes_cli/commands.py` - **Interactive CLI slash commands** — handled by `cli.py` / `hermes_cli/commands.py`
- **Messaging slash commands** — handled by `gateway/run.py` - **Messaging slash commands** — handled by `gateway/run.py`
Installed skills are also exposed as dynamic slash commands on both surfaces. Installed skills are also exposed as dynamic slash commands on both surfaces. That includes bundled skills like `/plan`, which opens plan mode and saves markdown plans under `~/.hermes/plans/`.
## Interactive CLI slash commands ## Interactive CLI slash commands
@@ -32,6 +32,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
| `/compress` | Manually compress conversation context (flush memories + summarize) | | `/compress` | Manually compress conversation context (flush memories + summarize) |
| `/rollback` | List or restore filesystem checkpoints (usage: /rollback [number]) | | `/rollback` | List or restore filesystem checkpoints (usage: /rollback [number]) |
| `/background` | Run a prompt in the background (usage: /background &lt;prompt&gt;) | | `/background` | Run a prompt in the background (usage: /background &lt;prompt&gt;) |
| `/plan [request]` | Load the bundled `plan` skill to write a markdown plan instead of executing the work. Plans are saved under `~/.hermes/plans/`. |
### Configuration ### Configuration
@@ -109,6 +110,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
| `/voice [on\|off\|tts\|join\|channel\|leave\|status]` | Control spoken replies in chat. `join`/`channel`/`leave` manage Discord voice-channel mode. | | `/voice [on\|off\|tts\|join\|channel\|leave\|status]` | Control spoken replies in chat. `join`/`channel`/`leave` manage Discord voice-channel mode. |
| `/rollback [number]` | List or restore filesystem checkpoints. | | `/rollback [number]` | List or restore filesystem checkpoints. |
| `/background &lt;prompt&gt;` | Run a prompt in a separate background session. | | `/background &lt;prompt&gt;` | Run a prompt in a separate background session. |
| `/plan [request]` | Load the bundled `plan` skill to write a markdown plan instead of executing the work. Plans are saved under `~/.hermes/plans/`. |
| `/reload-mcp` | Reload MCP servers from config. | | `/reload-mcp` | Reload MCP servers from config. |
| `/update` | Update Hermes Agent to the latest version. | | `/update` | Update Hermes Agent to the latest version. |
| `/help` | Show messaging help. | | `/help` | Show messaging help. |

View File

@@ -24,11 +24,14 @@ Every installed skill is automatically available as a slash command:
/gif-search funny cats /gif-search funny cats
/axolotl help me fine-tune Llama 3 on my dataset /axolotl help me fine-tune Llama 3 on my dataset
/github-pr-workflow create a PR for the auth refactor /github-pr-workflow create a PR for the auth refactor
/plan design a rollout for migrating our auth provider
# Just the skill name loads it and lets the agent ask what you need: # Just the skill name loads it and lets the agent ask what you need:
/excalidraw /excalidraw
``` ```
The bundled `plan` skill is a good example of a skill-backed slash command with custom behavior. Running `/plan [request]` tells Hermes to inspect context if needed, write a markdown implementation plan instead of executing the task, and save the result under `~/.hermes/plans/`.
You can also interact with skills through natural conversation: You can also interact with skills through natural conversation:
```bash ```bash