feat: add /plan command

This commit is contained in:
teknium1 2026-03-14 20:54:23 -07:00
parent 7b140b31e6
commit 11f8744ddc
8 changed files with 291 additions and 6 deletions

View file

@ -1,17 +1,79 @@
"""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
can invoke skills via /skill-name commands.
can invoke skills via /skill-name commands and prompt-only built-ins like
/plan.
"""
import json
import logging
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
_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 build_plan_invocation_message(
user_instruction: str = "",
*,
plan_path: str | Path | None = None,
) -> str:
"""Build the injected user message for the built-in /plan command."""
resolved_path = Path(plan_path) if plan_path is not None else build_plan_path(user_instruction)
parts = [
'[SYSTEM: The user has invoked the "/plan" command. This means they want a markdown plan, not execution, for this turn.]',
"",
"You are in plan mode for this turn.",
"",
"Plan mode rules:",
"- Do not implement code, edit project files other than the plan document, run mutating terminal commands, commit, push, or take external actions.",
"- You may inspect the repo/context and use read-only tools or commands if needed.",
f"- Write the finished plan as markdown and save it with write_file to: {resolved_path}",
"- Make the plan concrete and actionable.",
"- Include: goal, context/assumptions, proposed approach, step-by-step plan, validation, and risks/open questions.",
"- If the task is code-related, include exact file paths, tests, and rollout or verification notes when possible.",
"- After saving the plan, reply with a short summary and the saved path.",
]
if user_instruction:
parts.extend(
[
"",
f"The user wants a plan for: {user_instruction}",
]
)
else:
parts.extend(
[
"",
"The user wants a plan based on the current conversation context. Infer the active task from the latest discussion, and only ask clarifying questions if the request is genuinely underspecified.",
]
)
return "\n".join(parts)
def scan_skill_commands() -> Dict[str, Dict[str, Any]]:

25
cli.py
View file

@ -1043,7 +1043,13 @@ def build_welcome_banner(console: Console, model: str, cwd: str, tools: List[dic
# Skill Slash Commands — dynamic commands generated from installed skills
# ============================================================================
from agent.skill_commands import scan_skill_commands, get_skill_commands, build_skill_invocation_message
from agent.skill_commands import (
scan_skill_commands,
get_skill_commands,
build_skill_invocation_message,
build_plan_invocation_message,
build_plan_path,
)
_skill_commands = scan_skill_commands()
@ -3013,6 +3019,8 @@ class HermesCLI:
elif cmd_lower.startswith("/personality"):
# Use original case (handler lowercases the personality name itself)
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":
retry_msg = self.retry_last()
if retry_msg and hasattr(self, '_pending_input'):
@ -3124,6 +3132,21 @@ class HermesCLI:
return True
def _handle_plan_command(self, cmd: str):
"""Handle /plan [request] — queue a markdown planning request."""
parts = cmd.strip().split(maxsplit=1)
user_instruction = parts[1].strip() if len(parts) > 1 else ""
plan_path = build_plan_path(user_instruction)
plan_path.parent.mkdir(parents=True, exist_ok=True)
msg = build_plan_invocation_message(user_instruction, plan_path=plan_path)
_cprint(f" 📝 Plan mode queued. 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):
"""Handle /background <prompt> — run a prompt in a separate background session.

View file

@ -1098,7 +1098,7 @@ class GatewayRunner:
# Emit command:* hook for any recognized slash command
_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",
"update", "title", "resume", "provider", "rollback",
"background", "reasoning", "voice"}
@ -1133,6 +1133,22 @@ class GatewayRunner:
if command == "personality":
return await self._handle_personality_command(event)
if command == "plan":
try:
from agent.skill_commands import build_plan_invocation_message, build_plan_path
user_instruction = event.get_command_args().strip()
plan_path = build_plan_path(user_instruction)
plan_path.parent.mkdir(parents=True, exist_ok=True)
event.text = build_plan_invocation_message(
user_instruction,
plan_path=plan_path,
)
command = None
except Exception as e:
logger.exception("Failed to prepare /plan command")
return f"Failed to enter plan mode: {e}"
if command == "retry":
return await self._handle_retry_command(event)
@ -1854,6 +1870,7 @@ class GatewayRunner:
"`/model [provider:model]` — Show/change model (or switch provider)",
"`/provider` — Show available providers and auth status",
"`/personality [name]` — Set a personality",
"`/plan [request]` — Write/save a markdown plan instead of taking action",
"`/retry` — Retry your last message",
"`/undo` — Remove the last exchange",
"`/sethome` — Set this chat as the home channel",

View file

@ -21,6 +21,7 @@ COMMANDS_BY_CATEGORY = {
"/clear": "Clear screen and start a new session",
"/history": "Show conversation history",
"/save": "Save the current conversation",
"/plan": "Write/save a markdown plan instead of taking action (usage: /plan [request])",
"/retry": "Retry the last message (resend to agent)",
"/undo": "Remove the last user/assistant exchange",
"/title": "Set a title for the current session (usage: /title My Session Name)",

View file

@ -1,10 +1,16 @@
"""Tests for agent/skill_commands.py — skill slash command scanning and platform filtering."""
import os
from datetime import datetime
from unittest.mock import patch
import tools.skills_tool as skills_tool_module
from agent.skill_commands import scan_skill_commands, build_skill_invocation_message
from agent.skill_commands import (
build_plan_invocation_message,
build_plan_path,
build_skill_invocation_message,
scan_skill_commands,
)
def _make_skill(
@ -241,3 +247,35 @@ Generate some audio.
assert msg is not None
assert 'file_path="<path>"' in msg
class TestBuildPlanInvocationMessage:
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_build_plan_invocation_message_includes_rules_and_target_path(self, tmp_path):
plan_path = tmp_path / "plans" / "my-plan.md"
msg = build_plan_invocation_message(
"Add a /plan command",
plan_path=plan_path,
)
assert '"/plan" command' in msg
assert str(plan_path) in msg
assert "Do not implement code" in msg
assert "Add a /plan command" in msg
assert "write_file" in msg
def test_build_plan_invocation_message_without_instruction_uses_conversation_context(self, tmp_path):
msg = build_plan_invocation_message(plan_path=tmp_path / "plans" / "conversation.md")
assert "current conversation context" in msg
assert "genuinely underspecified" in msg

View file

@ -0,0 +1,103 @@
"""Tests for the /plan gateway slash command."""
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
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",
)
class TestGatewayPlanCommand:
@pytest.mark.asyncio
async def test_plan_command_rewrites_message_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,
)
result = await runner._handle_message(event)
assert result == "planned"
forwarded = runner._run_agent.call_args.kwargs["message"]
assert '"/plan" command' in forwarded
assert "Add OAuth login" in forwarded
assert str(tmp_path / "plans") in forwarded
@pytest.mark.asyncio
async def test_plan_command_appears_in_help_output(self):
runner = _make_runner()
event = _make_event("/help")
result = await runner._handle_help_command(event)
assert "/plan" in result

View file

@ -9,7 +9,7 @@ from hermes_cli.commands import COMMANDS, SlashCommandCompleter
# All commands that must be present in the shared COMMANDS dict.
EXPECTED_COMMANDS = {
"/help", "/tools", "/toolsets", "/model", "/provider", "/prompt",
"/personality", "/clear", "/history", "/new", "/reset", "/retry",
"/personality", "/clear", "/history", "/new", "/reset", "/plan", "/retry",
"/undo", "/save", "/config", "/cron", "/skills", "/platforms",
"/verbose", "/reasoning", "/compress", "/title", "/usage", "/insights", "/paste",
"/reload-mcp", "/rollback", "/background", "/skin", "/voice", "/quit",

View file

@ -0,0 +1,41 @@
"""Tests for the /plan CLI slash command."""
from unittest.mock import MagicMock
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
class TestCLIPlanCommand:
def test_plan_command_queues_plan_mode_message(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cli_obj = _make_cli()
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" command' in queued
assert "Add OAuth login" in queued
assert str(tmp_path / "plans") in queued
def test_plan_without_args_uses_conversation_context(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cli_obj = _make_cli()
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