Files
hermes-agent/docs/plans/centralize-command-registry.md
Teknium 6794e79bb4 feat: add /bg as alias for /background slash command (#1590)
* feat: add optional smart model routing

Add a conservative cheap-vs-strong routing option that can send very short/simple turns to a cheaper model across providers while keeping the primary model for complex work. Wire it through CLI, gateway, and cron, and document the config.yaml workflow.

* fix(gateway): remove recursive ExecStop from systemd units, extend TimeoutStopSec to 60s

* fix(gateway): avoid recursive ExecStop in user systemd unit

* fix: extend ExecStop removal and TimeoutStopSec=60 to system unit

The cherry-picked PR #1448 fix only covered the user systemd unit.
The system unit had the same TimeoutStopSec=15 and could benefit
from the same 60s timeout for clean shutdown. Also adds a regression
test for the system unit.

---------

Co-authored-by: Ninja <ninja@local>

* feat(skills): add blender-mcp optional skill for 3D modeling

Control a running Blender instance from Hermes via socket connection
to the blender-mcp addon (port 9876). Supports creating 3D objects,
materials, animations, and running arbitrary bpy code.

Placed in optional-skills/ since it requires Blender 4.3+ desktop
with a third-party addon manually started each session.

* feat(acp): support slash commands in ACP adapter (#1532)

Adds /help, /model, /tools, /context, /reset, /compact, /version
to the ACP adapter (VS Code, Zed, JetBrains). Commands are handled
directly in the server without instantiating the TUI — each command
queries agent/session state and returns plain text.

Unrecognized /commands fall through to the LLM as normal messages.

/model uses detect_provider_for_model() for auto-detection when
switching models, matching the CLI and gateway behavior.

Fixes #1402

* fix(logging): improve error logging in session search tool (#1533)

* fix(gateway): restart on retryable startup failures (#1517)

* feat(email): add skip_attachments option via config.yaml

* feat(email): add skip_attachments option via config.yaml

Adds a config.yaml-driven option to skip email attachments in the
gateway email adapter. Useful for malware protection and bandwidth
savings.

Configure in config.yaml:
  platforms:
    email:
      skip_attachments: true

Based on PR #1521 by @an420eth, changed from env var to config.yaml
(via PlatformConfig.extra) to match the project's config-first pattern.

* docs: document skip_attachments option for email adapter

* fix(telegram): retry on transient TLS failures during connect and send

Add exponential-backoff retry (3 attempts) around initialize() to
handle transient TLS resets during gateway startup. Also catches
TimedOut and OSError in addition to NetworkError.

Add exponential-backoff retry (3 attempts) around send_message() for
NetworkError during message delivery, wrapping the existing Markdown
fallback logic.

Both imports are guarded with try/except ImportError for test
environments where telegram is mocked.

Based on PR #1527 by cmd8. Closes #1526.

* feat: permissive block_anchor thresholds and unicode normalization (#1539)

Salvaged from PR #1528 by an420eth. Closes #517.

Improves _strategy_block_anchor in fuzzy_match.py:
- Add unicode normalization (smart quotes, em/en-dashes, ellipsis,
  non-breaking spaces → ASCII) so LLM-produced unicode artifacts
  don't break anchor line matching
- Lower thresholds: 0.10 for unique matches (was 0.70), 0.30 for
  multiple candidates — if first/last lines match exactly, the
  block is almost certainly correct
- Use original (non-normalized) content for offset calculation to
  preserve correct character positions

Tested: 3 new scenarios fixed (em-dash anchors, non-breaking space
anchors, very-low-similarity unique matches), zero regressions on
all 9 existing fuzzy match tests.

Co-authored-by: an420eth <an420eth@users.noreply.github.com>

* feat(cli): add file path autocomplete in the input prompt (#1545)

When typing a path-like token (./  ../  ~/  /  or containing /),
the CLI now shows filesystem completions in the dropdown menu.
Directories show a trailing slash and 'dir' label; files show
their size. Completions are case-insensitive and capped at 30
entries.

Triggered by tokens like:
  edit ./src/ma     → shows ./src/main.py, ./src/manifest.json, ...
  check ~/doc       → shows ~/docs/, ~/documents/, ...
  read /etc/hos     → shows /etc/hosts, /etc/hostname, ...
  open tools/reg    → shows tools/registry.py

Slash command autocomplete (/help, /model, etc.) is unaffected —
it still triggers when the input starts with /.

Inspired by OpenCode PR #145 (file path completion menu).

Implementation:
- hermes_cli/commands.py: _extract_path_word() detects path-like
  tokens, _path_completions() yields filesystem Completions with
  size labels, get_completions() routes to paths vs slash commands
- tests/hermes_cli/test_path_completion.py: 26 tests covering
  path extraction, prefix filtering, directory markers, home
  expansion, case-insensitivity, integration with slash commands

* feat(privacy): redact PII from LLM context when privacy.redact_pii is enabled

Add privacy.redact_pii config option (boolean, default false). When
enabled, the gateway redacts personally identifiable information from
the system prompt before sending it to the LLM provider:

- Phone numbers (user IDs on WhatsApp/Signal) → hashed to user_<sha256>
- User IDs → hashed to user_<sha256>
- Chat IDs → numeric portion hashed, platform prefix preserved
- Home channel IDs → hashed
- Names/usernames → NOT affected (user-chosen, publicly visible)

Hashes are deterministic (same user → same hash) so the model can
still distinguish users in group chats. Routing and delivery use
the original values internally — redaction only affects LLM context.

Inspired by OpenClaw PR #47959.

* fix(privacy): skip PII redaction on Discord/Slack (mentions need real IDs)

Discord uses <@user_id> for mentions and Slack uses <@U12345> — the LLM
needs the real ID to tag users. Redaction now only applies to WhatsApp,
Signal, and Telegram where IDs are pure routing metadata.

Add 4 platform-specific tests covering Discord, WhatsApp, Signal, Slack.

* feat: smart approvals + /stop command (inspired by OpenAI Codex)

* feat: smart approvals — LLM-based risk assessment for dangerous commands

Adds a 'smart' approval mode that uses the auxiliary LLM to assess
whether a flagged command is genuinely dangerous or a false positive,
auto-approving low-risk commands without prompting the user.

Inspired by OpenAI Codex's Smart Approvals guardian subagent
(openai/codex#13860).

Config (config.yaml):
  approvals:
    mode: manual   # manual (default), smart, off

Modes:
- manual — current behavior, always prompt the user
- smart  — aux LLM evaluates risk: APPROVE (auto-allow), DENY (block),
           or ESCALATE (fall through to manual prompt)
- off    — skip all approval prompts (equivalent to --yolo)

When smart mode auto-approves, the pattern gets session-level approval
so subsequent uses of the same pattern don't trigger another LLM call.
When it denies, the command is blocked without user prompt. When
uncertain, it escalates to the normal manual approval flow.

The LLM prompt is carefully scoped: it sees only the command text and
the flagged reason, assesses actual risk vs false positive, and returns
a single-word verdict.

* feat: make smart approval model configurable via config.yaml

Adds auxiliary.approval section to config.yaml with the same
provider/model/base_url/api_key pattern as other aux tasks (vision,
web_extract, compression, etc.).

Config:
  auxiliary:
    approval:
      provider: auto
      model: ''        # fast/cheap model recommended
      base_url: ''
      api_key: ''

Bridged to env vars in both CLI and gateway paths so the aux client
picks them up automatically.

* feat: add /stop command to kill all background processes

Adds a /stop slash command that kills all running background processes
at once. Currently users have to process(list) then process(kill) for
each one individually.

Inspired by OpenAI Codex's separation of interrupt (Ctrl+C stops current
turn) from /stop (cleans up background processes). See openai/codex#14602.

Ctrl+C continues to only interrupt the active agent turn — background
dev servers, watchers, etc. are preserved. /stop is the explicit way
to clean them all up.

* feat: first-class plugin architecture + hide status bar cost by default (#1544)

The persistent status bar now shows context %, token counts, and
duration but NOT $ cost by default. Cost display is opt-in via:

  display:
    show_cost: true

in config.yaml, or: hermes config set display.show_cost true

The /usage command still shows full cost breakdown since the user
explicitly asked for it — this only affects the always-visible bar.

Status bar without cost:
  ⚕ claude-sonnet-4 │ 12K/200K │ 6% │ 15m

Status bar with show_cost: true:
  ⚕ claude-sonnet-4 │ 12K/200K │ 6% │ $0.06 │ 15m

* feat: improve memory prioritization + aggressive skill updates (inspired by OpenAI Codex)

* feat: improve memory prioritization — user preferences over procedural knowledge

Inspired by OpenAI Codex's memory prompt improvements (openai/codex#14493)
which focus memory writes on user preferences and recurring patterns
rather than procedural task details.

Key insight: 'Optimize for reducing future user steering — the most
valuable memory prevents the user from having to repeat themselves.'

Changes:
- MEMORY_GUIDANCE (prompt_builder.py): added prioritization hierarchy
  and the core principle about reducing user steering
- MEMORY_SCHEMA (memory_tool.py): reordered WHEN TO SAVE list to put
  corrections first, added explicit PRIORITY guidance
- Memory nudge (run_agent.py): now asks specifically about preferences,
  corrections, and workflow patterns instead of generic 'anything'
- Memory flush (run_agent.py): now instructs to prioritize user
  preferences and corrections over task-specific details

* feat: more aggressive skill creation and update prompting

Press harder on skill updates — the agent should proactively patch
skills when it encounters issues during use, not wait to be asked.

Changes:
- SKILLS_GUIDANCE: 'consider saving' → 'save'; added explicit instruction
  to patch skills immediately when found outdated/wrong
- Skills header: added instruction to update loaded skills before finishing
  if they had missing steps or wrong commands
- Skill nudge: more assertive ('save the approach' not 'consider saving'),
  now also prompts for updating existing skills used in the task
- Skill nudge interval: lowered default from 15 to 10 iterations
- skill_manage schema: added 'patch it immediately' to update triggers

* feat: first-class plugin architecture (#1555)

Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.

Core system (hermes_cli/plugins.py):
  - Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
    pip entry_points (hermes_agent.plugins group)
  - PluginContext with register_tool() and register_hook()
  - 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
    on_session_start/end
  - Namespace package handling for relative imports in plugins
  - Graceful error isolation — broken plugins never crash the agent

Integration (model_tools.py):
  - Plugin discovery runs after built-in + MCP tools
  - Plugin tools bypass toolset filter via get_plugin_tool_names()
  - Pre/post tool call hooks fire in handle_function_call()

CLI:
  - /plugins command shows loaded plugins, tool counts, status
  - Added to COMMANDS dict for autocomplete

Docs:
  - Getting started guide (build-a-hermes-plugin.md) — full tutorial
    building a calculator plugin step by step
  - Reference page (features/plugins.md) — quick overview + tables
  - Covers: file structure, schemas, handlers, hooks, data files,
    bundled skills, env var gating, pip distribution, common mistakes

Tests: 16 tests covering discovery, loading, hooks, tool visibility.

* feat: add /bg as alias for /background slash command

Adds /bg alias across CLI, gateway, and Slack platform adapter.
Updates help text, autocomplete, known_commands set, and dispatch
logic. Includes tests for the new alias.

* docs: add plan for centralized slash command registry

Scopes a refactor to replace 7+ scattered command definition sites
with a single CommandDef registry in hermes_cli/commands.py. Includes
derived helper functions for gateway help text, Telegram BotCommands,
Slack subcommand maps, and alias resolution.

Documents current drift (Telegram missing /rollback + /background,
Slack missing /voice + /update, gateway dead code) that the refactor
fixes for free.

---------

Co-authored-by: Ninja <ninja@local>
Co-authored-by: alireza78a <alireza78a@users.noreply.github.com>
Co-authored-by: Oktay Aydin <113846926+aydnOktay@users.noreply.github.com>
Co-authored-by: JP Lew <polydegen@protonmail.com>
Co-authored-by: an420eth <an420eth@users.noreply.github.com>
2026-03-16 17:27:02 -07:00

15 KiB

Plan: Centralize Slash Command Registry

Problem

Slash command definitions are scattered across 7+ locations with significant drift:

Location What it defines Commands
hermes_cli/commands.py COMMANDS_BY_CATEGORY dict 34 commands
cli.py process_command() if/elif dispatch chain ~30 branches
gateway/run.py _known_commands Hook emission set 25 entries
gateway/run.py _handle_message() if dispatch chain ~22 branches
gateway/run.py _handle_help_command() Hardcoded help text list 22 lines
gateway/platforms/telegram.py BotCommand registration 20 commands
gateway/platforms/discord.py @tree.command decorators 22 commands
gateway/platforms/slack.py subcommand_map dict 20 mappings

Known drift:

  • Telegram missing: /rollback, /background, /bg, /plan, /set-home
  • Slack missing: /sethome, /set-home, /update, /voice, /reload-mcp, /plan
  • Gateway help text missing: /bg alias mention
  • Gateway _known_commands has duplicate "reasoning" entry
  • Gateway dispatch has dead code: second "reasoning" check (line 1384) never executes
  • Adding one alias (/bg) required touching 6 files + 1 test file

Goal

Single source of truth for "what commands exist, what are their aliases, and what platforms support them." Adding a command or alias should require exactly one definition change + the handler implementation.

Design

1. CommandDef dataclass (hermes_cli/commands.py)

from dataclasses import dataclass, field

@dataclass(frozen=True)
class CommandDef:
    name: str                          # canonical name without slash: "background"
    description: str                   # human-readable description
    category: str                      # "Session", "Configuration", "Tools & Skills", "Info", "Exit"
    aliases: tuple[str, ...] = ()      # alternative names: ("bg",)
    args_hint: str = ""                # argument placeholder: "<prompt>", "[name]", "[level|show|hide]"
    gateway: bool = True               # available in gateway (Telegram/Discord/Slack/etc.)
    cli_only: bool = False             # only available in CLI (e.g., /clear, /paste, /skin)
    gateway_only: bool = False         # only available in gateway (e.g., /status, /sethome, /update)

2. COMMAND_REGISTRY list (hermes_cli/commands.py)

Replace COMMANDS_BY_CATEGORY with a flat list of CommandDef objects:

COMMAND_REGISTRY: list[CommandDef] = [
    # Session
    CommandDef("new", "Start a new session (fresh session ID + history)", "Session", aliases=("reset",)),
    CommandDef("clear", "Clear screen and start a new session", "Session", cli_only=True),
    CommandDef("history", "Show conversation history", "Session", cli_only=True),
    CommandDef("save", "Save the current conversation", "Session", cli_only=True),
    CommandDef("retry", "Retry the last message (resend to agent)", "Session"),
    CommandDef("undo", "Remove the last user/assistant exchange", "Session"),
    CommandDef("title", "Set a title for the current session", "Session", args_hint="[name]"),
    CommandDef("compress", "Manually compress conversation context", "Session"),
    CommandDef("rollback", "List or restore filesystem checkpoints", "Session", args_hint="[number]"),
    CommandDef("stop", "Kill all running background processes", "Session"),
    CommandDef("background", "Run a prompt in the background", "Session", aliases=("bg",), args_hint="<prompt>"),
    CommandDef("status", "Show session info", "Session", gateway_only=True),
    CommandDef("sethome", "Set this chat as the home channel", "Session", gateway_only=True, aliases=("set-home",)),
    CommandDef("resume", "Resume a previously-named session", "Session", args_hint="[name]"),

    # Configuration
    CommandDef("config", "Show current configuration", "Configuration", cli_only=True),
    CommandDef("model", "Show or change the current model", "Configuration", args_hint="[name]"),
    CommandDef("provider", "Show available providers and current provider", "Configuration"),
    CommandDef("prompt", "View/set custom system prompt", "Configuration", cli_only=True, args_hint="[text]"),
    CommandDef("personality", "Set a predefined personality", "Configuration", args_hint="[name]"),
    CommandDef("verbose", "Cycle tool progress display: off → new → all → verbose", "Configuration", cli_only=True),
    CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", args_hint="[level|show|hide]"),
    CommandDef("skin", "Show or change the display skin/theme", "Configuration", cli_only=True, args_hint="[name]"),
    CommandDef("voice", "Toggle voice mode", "Configuration", args_hint="[on|off|tts|status]"),

    # Tools & Skills
    CommandDef("tools", "List available tools", "Tools & Skills", cli_only=True),
    CommandDef("toolsets", "List available toolsets", "Tools & Skills", cli_only=True),
    CommandDef("skills", "Search, install, inspect, or manage skills", "Tools & Skills", cli_only=True),
    CommandDef("cron", "Manage scheduled tasks", "Tools & Skills", cli_only=True, args_hint="[subcommand]"),
    CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills", aliases=("reload_mcp",)),
    CommandDef("plugins", "List installed plugins and their status", "Tools & Skills", cli_only=True),

    # Info
    CommandDef("help", "Show available commands", "Info"),
    CommandDef("usage", "Show token usage for the current session", "Info"),
    CommandDef("insights", "Show usage insights and analytics", "Info", args_hint="[days]"),
    CommandDef("platforms", "Show gateway/messaging platform status", "Info", cli_only=True, aliases=("gateway",)),
    CommandDef("paste", "Check clipboard for an image and attach it", "Info", cli_only=True),
    CommandDef("update", "Update Hermes Agent to the latest version", "Info", gateway_only=True),

    # Exit
    CommandDef("quit", "Exit the CLI", "Exit", cli_only=True, aliases=("exit", "q")),
]

3. Derived data structures (hermes_cli/commands.py)

Build all downstream dicts/sets from the registry automatically:

# --- derived lookups (rebuilt on import, all consumers use these) ---

# name_or_alias -> CommandDef  (used by dispatch to resolve aliases)
_COMMAND_LOOKUP: dict[str, CommandDef] = {}
for _cmd in COMMAND_REGISTRY:
    _COMMAND_LOOKUP[_cmd.name] = _cmd
    for _alias in _cmd.aliases:
        _COMMAND_LOOKUP[_alias] = _cmd

def resolve_command(name: str) -> CommandDef | None:
    """Resolve a command name or alias to its CommandDef."""
    return _COMMAND_LOOKUP.get(name.lower().lstrip("/"))

# Backwards-compat: flat COMMANDS dict (slash-prefixed key -> description)
COMMANDS: dict[str, str] = {}
for _cmd in COMMAND_REGISTRY:
    desc = _cmd.description
    if _cmd.args_hint:
        desc = f"{desc} (usage: /{_cmd.name} {_cmd.args_hint})"
    COMMANDS[f"/{_cmd.name}"] = desc
    for _alias in _cmd.aliases:
        alias_desc = f"{desc} (alias for /{_cmd.name})" if _alias not in ("reset",) else desc
        COMMANDS[f"/{_alias}"] = alias_desc

# Backwards-compat: COMMANDS_BY_CATEGORY
COMMANDS_BY_CATEGORY: dict[str, dict[str, str]] = {}
for _cmd in COMMAND_REGISTRY:
    cat = COMMANDS_BY_CATEGORY.setdefault(_cmd.category, {})
    cat[f"/{_cmd.name}"] = COMMANDS[f"/{_cmd.name}"]
    for _alias in _cmd.aliases:
        cat[f"/{_alias}"] = COMMANDS[f"/{_alias}"]

# Gateway known commands set (for hook emission)
GATEWAY_KNOWN_COMMANDS: set[str] = set()
for _cmd in COMMAND_REGISTRY:
    if not _cmd.cli_only:
        GATEWAY_KNOWN_COMMANDS.add(_cmd.name)
        GATEWAY_KNOWN_COMMANDS.update(_cmd.aliases)

# Gateway help lines (for _handle_help_command)
def gateway_help_lines() -> list[str]:
    """Generate gateway help text from the registry."""
    lines = []
    for cmd in COMMAND_REGISTRY:
        if cmd.cli_only:
            continue
        args = f" {cmd.args_hint}" if cmd.args_hint else ""
        alias_note = ""
        if cmd.aliases:
            alias_strs = ", ".join(f"`/{a}`" for a in cmd.aliases)
            alias_note = f" (alias: {alias_strs})"
        lines.append(f"`/{cmd.name}{args}` — {cmd.description}{alias_note}")
    return lines

# Telegram BotCommand list
def telegram_bot_commands() -> list[tuple[str, str]]:
    """Return (command_name, description) pairs for Telegram's setMyCommands."""
    result = []
    for cmd in COMMAND_REGISTRY:
        if cmd.cli_only:
            continue
        # Telegram doesn't support hyphens in command names
        tg_name = cmd.name.replace("-", "_")
        result.append((tg_name, cmd.description))
    return result

# Slack subcommand map
def slack_subcommand_map() -> dict[str, str]:
    """Return subcommand -> /command mapping for Slack's /hermes handler."""
    mapping = {}
    for cmd in COMMAND_REGISTRY:
        if cmd.cli_only:
            continue
        mapping[cmd.name] = f"/{cmd.name}"
        for alias in cmd.aliases:
            mapping[alias] = f"/{alias}"
    return mapping

4. Consumer changes

cli.py — process_command()

The dispatch chain stays as-is (if/elif is fine for ~30 commands), but alias resolution moves to the top:

def process_command(self, command: str) -> bool:
    cmd_original = command.strip()
    cmd_lower = cmd_original.lower()
    base = cmd_lower.split()[0].lstrip("/")

    # Resolve alias to canonical name
    cmd_def = resolve_command(base)
    if cmd_def:
        canonical = cmd_def.name
    else:
        canonical = base

    # Dispatch on canonical name
    if canonical in ("quit", "exit", "q"):
        ...
    elif canonical == "help":
        ...
    elif canonical == "background":  # no more "or startswith /bg"
        ...

This eliminates every or cmd_lower.startswith("/bg") style alias check.

gateway/run.py — _handle_message()

from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command

# Replace hardcoded _known_commands set
if command and command in GATEWAY_KNOWN_COMMANDS:
    await self.hooks.emit(f"command:{command}", {...})

# Resolve aliases before dispatch
cmd_def = resolve_command(command)
canonical = cmd_def.name if cmd_def else command

if canonical in ("new",):
    return await self._handle_reset_command(event)
elif canonical == "background":
    return await self._handle_background_command(event)
...

gateway/run.py — _handle_help_command()

from hermes_cli.commands import gateway_help_lines

async def _handle_help_command(self, event):
    lines = gateway_help_lines()
    # ... append skill commands, format, return

Delete the hardcoded 22-line list entirely.

gateway/platforms/telegram.py — set_my_commands()

from hermes_cli.commands import telegram_bot_commands

async def set_my_commands(self):
    commands = [BotCommand(name, desc) for name, desc in telegram_bot_commands()]
    await self._bot.set_my_commands(commands)

Delete the hardcoded 20-entry list.

gateway/platforms/slack.py — _handle_slash_command()

from hermes_cli.commands import slack_subcommand_map

async def _handle_slash_command(self, command: dict):
    ...
    subcommand_map = slack_subcommand_map()
    ...

Delete the hardcoded dict.

gateway/platforms/discord.py — _register_slash_commands()

Discord is the exception. Its @tree.command() decorators need typed parameters, custom descriptions, and platform-specific interaction handling (defer, ephemeral, followups). These can't be generated from a simple registry.

Approach: Keep the decorator registrations, but validate at startup that every registered Discord command has a matching entry in COMMAND_REGISTRY (except platform-specific ones like /ask and /thread). Add a test for this.

# In _register_slash_commands(), after all decorators:
_DISCORD_ONLY_COMMANDS = {"ask", "thread"}
registered = {cmd.name for cmd in tree.get_commands()}
registry_names = {c.name for c in COMMAND_REGISTRY if not c.cli_only}
# Warn about Discord commands not in registry (excluding Discord-only)
for name in registered - registry_names - _DISCORD_ONLY_COMMANDS:
    logger.warning("Discord command /%s not in central registry", name)

Files Changed

File Change
hermes_cli/commands.py Add CommandDef, COMMAND_REGISTRY, derived structures, helper functions
cli.py Add alias resolution at top of process_command(), remove per-command alias checks
gateway/run.py Import GATEWAY_KNOWN_COMMANDS + resolve_command + gateway_help_lines, delete hardcoded sets/lists
gateway/platforms/telegram.py Import telegram_bot_commands(), delete hardcoded BotCommand list
gateway/platforms/slack.py Import slack_subcommand_map(), delete hardcoded dict
gateway/platforms/discord.py Add startup validation against registry
tests/hermes_cli/test_commands.py Update to test registry, derived structures, helper functions
tests/gateway/test_background_command.py Simplify — no more source-code-inspection tests

Bugfixes included for free

  1. Telegram missing commands: /rollback, /background, /bg automatically added
  2. Slack missing commands: /voice, /update, /reload-mcp automatically added
  3. Gateway duplicate "reasoning": Eliminated (generated from registry)
  4. Gateway dead code: Second "reasoning" dispatch branch removed
  5. Help text drift: Gateway help now generated from same source as CLI help

What stays the same

  • CLI dispatch remains an if/elif chain (readable, fast, explicit)
  • Gateway dispatch remains an if chain
  • Discord slash command decorators stay platform-specific
  • Handler function signatures and locations don't change
  • Quick commands and skill commands remain separate (config-driven / dynamic)

Migration / backwards compat

  • COMMANDS flat dict and COMMANDS_BY_CATEGORY dict are rebuilt from the registry, so any code importing them continues to work unchanged
  • SlashCommandCompleter continues to read from COMMANDS dict
  • No config changes, no user-facing behavior changes

Risks

  • Import ordering: gateway/run.py importing from hermes_cli/commands.py — verify no circular import. Currently gateway/run.py doesn't import from hermes_cli/ at all. Need to confirm this works or move the registry to a shared location (e.g., commands_registry.py at the top level).
  • Telegram command name sanitization: Telegram doesn't allow hyphens in command names. The telegram_bot_commands() helper handles this with .replace("-", "_"), but the gateway dispatch must still accept both forms. Currently handled via the ("reload-mcp", "reload_mcp") alias.

Estimated scope

  • ~200 lines of new code in commands.py (dataclass + registry + helpers)
  • ~100 lines deleted across gateway/run.py, telegram.py, slack.py (hardcoded lists)
  • ~50 lines changed in cli.py (alias resolution refactor)
  • ~80 lines of new/updated tests
  • Net: roughly even LOC, dramatically less maintenance surface