Context
The plugin registers 19 agent tools but no slash commands. The upstream OpenSpec CLI defines a standard / command set (docs/) with two profiles: core (4 commands) and expanded (7 commands). Hermes plugins can register slash commands via ctx.register_command(name, handler, description, args_hint) — confirmed in hermes_cli/ and the build-a-plugin guide. Colon-delimited names work: the name cleaner does .lower().strip().lstrip("/, colons pass through. Command lookup in cli.py and gateway/ uses the full name including colon.
ctx.dispatch_tool(tool_name, args) routes through the normal approval/redaction pipeline — it's the documented pattern for slash commands that invoke tools.
The existing tool handlers in tools.py all accept (args: dict, **kwargs) -> str and return JSON strings. The __init__.py register() function currently registers tools only.
Goals / Non-Goals
Goals:
- Implement all 11 upstream
/commands as Hermes plugin slash commandsopsx:* - Route to existing tool handlers via
ctx.dispatch_tool()— no new tools, no new backend logic - Support
--workdirand--projectflags for repo targeting on all commands - Commands work in both CLI and gateway (Discord, Telegram)
Non-Goals:
- LLM-driven implementation (upstream
/does implementation — our version lists tasks + returns instructions, it doesn't write code)opsx:apply - Semantic analysis (upstream
/does LLM analysis — our version runsopsx:verify openspec_validate+openspec_status+openspec_task_listand reports results) - Auto-syncing delta specs into main specs (upstream
/merges — our version shows diffs for manual review)opsx:sync - Command-specific UI or formatting beyond plain text with emojis
Decisions
Decision 1: New commands.py module (not inline in __init__.py)
Choice: Create a dedicated commands.py module with all command handlers and a register_commands(ctx) function. Wire it in __init__.py with a single call.
Why not inline: 11 handlers + shared helpers (~300 lines) would bloat __init__.py (currently 68 lines, purely registration). A separate module keeps registration clean and makes the command layer testable in isolation.
Why not in tools.py: tools.py is for agent tool handlers (called by the model). Slash commands are user-invoked shortcuts that orchestrate tool calls. Different audience, different lifecycle.
Decision 2: ctx.dispatch_tool() for all tool invocations
Choice: All commands call ctx.dispatch_tool("openspec_*", args) to invoke tools. No direct calls to tools.py handler functions.
Why: dispatch_tool is the documented pattern. It routes through the approval, redaction, and budget pipelines — the command gets the same safety treatment as a model-initiated tool call. Direct handler calls would bypass this.
Trade-off: dispatch_tool returns a string (the handler's JSON return). Commands parse it with json.loads() to extract fields for formatting. If parsing fails, the raw string is shown.
Decision 3: Shared arg parsing via _parse_target()
Choice: A single _parse_target(raw_args) helper extracts --workdir/--project flags from the raw args string and returns (tool_args, positional_text).
/opsx:propose add-dark-mode --workdir ~/repos/my-project
→ tool_args = {"workdir": "~/repos/my-project"}
→ positional = "add-dark-mode"
/opsx:propose add-dark-mode --project hermes-openspec
→ tool_args = {"identifier": "hermes-openspec"}
→ positional = "add-dark-mode"
/opsx:propose add-dark-mode
→ tool_args = {}
→ positional = "add-dark-mode"
(tools.py _resolve_project falls back to cwd when neither workdir nor identifier is set)
Why shlex.split: Handles quoted arguments (/) correctly. Falls back to raw_args.split() if shlex fails (unbalanced quotes).
Decision 4: Command handler closure pattern
Choice: Register handlers with a lambda closure that captures ctx:
ctx.register_command(
"opsx:propose",
handler=lambda raw, h=_handle_propose, c=ctx: h(c, raw),
description="Create change + get proposal instructions",
args_hint="<change-name>",
)
Why: ctx.register_command expects handler(raw_args: str) -> str | None. The handlers need ctx for dispatch_tool. The closure captures ctx at registration time. This matches the pattern in the build-a-plugin guide.
Decision 5: Multi-step commands report per-step results
Choice: Commands that dispatch multiple tools (propose, ff, verify, archive, sync, bulk-archive) report each step's result sequentially:
✅ Change 'add-dark-mode' created
change_path: /home/tank/repos/my-project/openspec/changes/add-dark-mode
📋 Proposal authoring instructions:
...
Next: author proposal.md, then /opsx:ff add-dark-mode to fast-forward.
Why not just the final result: Multi-step commands can partially fail (e.g., change created but promotion failed). Per-step reporting tells the user exactly where it stopped.
Decision 6: / opsx:continue next-artifact logic
Choice: A _next_artifact(status_result) helper inspects the openspec_status output and returns the next missing artifact in dependency order: proposal → design → specs → tasks. If all exist, returns None and the command tells the user to /.
Why dependency order: Matches the upstream artifact graph (proposal → specs/design → tasks → apply). The orchestrator should author artifacts in this order.
Edge case: openspec_status output shape varies between CLI-backed and filesystem-backed calls. The helper checks for both the nested dict format ({"proposal": {"exists": true}}) and the flat format ({"hasProposal": true}). If neither matches, defaults to "proposal".
Decision 7: / opsx:onboard is static text
Choice: / returns a static help string with the command list, typical workflow, and flag documentation. No tool calls.
Why: Onboarding is documentation, not a tool operation. Static text is instant and doesn't consume tool budget.
Risks / Trade-offs
-
Partial failure in multi-step commands →
/creates then promotes. If create succeeds but promote fails, the change exists but isn't promoted. Mitigated: per-step reporting (Decision 5) makes the failure point clear. The user can re-runopsx:ff /(create will report "may already exist") or callopsx:ff /to proceed manually.opsx:continue -
dispatch_toolinherits session approval mode → Insmartapproval mode,/might auto-approve the archive tool call without prompting. This is the documented behavior — commands go through the same pipeline as model tool calls. Not a bug, but worth noting in the onboard text.opsx:archive -
Colon command names in Telegram → Telegram's bot menu uses underscores for autocomplete (
/). The gateway normalizes underscores to hyphens (opsx_propose command.replace("_", "-")), but colons may not be normalized. Need to verify that/works in Telegram/Discord, or if the colon gets mangled. If it does, register underscored aliases (opsx:propose opsx_propose) as fallbacks. -
/status shape ambiguity → Theopsx:continue _next_artifacthelper handles two output formats but could encounter a third if the CLI changes. Mitigated: defaulting to "proposal" is safe (worst case: user gets proposal instructions when they already have one — they'll see it exists and skip).
Open Questions
- Does Telegram's command parser handle colons in
/? Theopsx:propose get_command()method ingateway/splits on space and strips theplatforms/ base.py /— it doesn't reject colons. But Telegram's autocomplete menu may not surface colon-delimited commands. Need to test in a live Telegram session. If broken, register bothopsx:proposeandopsx_proposeas aliases.