Skip to content

feat(agents): add NousResearch Hermes adapter#42

Merged
zzet merged 4 commits into
mainfrom
feat/hermes-adapter
Jun 4, 2026
Merged

feat(agents): add NousResearch Hermes adapter#42
zzet merged 4 commits into
mainfrom
feat/hermes-adapter

Conversation

@zzet
Copy link
Copy Markdown
Owner

@zzet zzet commented Jun 2, 2026

Wire the gortex MCP server into NousResearch Hermes (~/.hermes), a user-level CLI agent-orchestrator that consumes MCP servers. Hermes users previously had to hand-author the server stanza and any usage guidance; this brings Hermes to parity with the Claude Code / Antigravity user-level treatment (MCP wiring + an instruction surface).

internal/agents/hermes — new user-mode adapter:

  • Detect: hermes on PATH or ~/.hermes present.
  • Upserts a gortex stdio server (command, args:[mcp], connect_timeout, timeout) under the snake_case mcp_servers map of the global ~/.hermes/config.yaml AND every existing ~/.hermes/profiles/*/config.yaml. Hermes profiles can redeclare their own server map, so writing only the global stanza does not guarantee reaching every profile; touching each existing profile config ensures the tools resolve everywhere (existing profiles only — we never create new ones).
  • Installs a user-level skill at ~/.hermes/skills/gortex/SKILL.md (Hermes frontmatter: name/description/version/metadata.hermes), teaching the agent to prefer graph tools and documenting multi-repo scoping.

Extra:

  • internal/agents.MergeYAML — comment-preserving YAML merge helper (a yaml.Node round-trip, not a map round-trip which would drop every comment), plus YAMLMapValue / YAMLSetMapValue / YAMLScalar / UpsertYAMLMapEntry. Honours the existing atomic-write + .bak-on-malformed contract.

Wiring: register hermes in the init/install registry, add the wizard label + detail, an ~/.hermes preserved-note in uninstall, and ReadYAML / WriteYAML test helpers. init doctor reports it automatically.

Implements the adapter requested in #38; QA against a real ~/.hermes (global stanza vs. per-profile resolution, comment preservation) pending.

@zzet zzet added the enhancement New feature or request label Jun 2, 2026
@rolldav
Copy link
Copy Markdown

rolldav commented Jun 4, 2026

QA'd against a real ~/.hermes (gortex v0.35.3, global config + 10 profiles, 2 tracked repos). I built the branch and ran gortex install --agents=hermes against a copy of my config in a throwaway $HOME. The design holds up and matches my setup; a few correctness fixes before merge.

Confirmed against the real config

  • Config shape/location: top-level snake_case mcp_servers:. The emitted stdio stanza (command, args: [mcp], connect_timeout: 60, timeout: 120) matches what my hand-wired entry already uses.
  • Per-profile resolution (the open question from feat(agents): add NousResearch Hermes adapter #38): writing per-profile is correct. Each of my 10 profiles/<name>/config.yaml carries its own mcp_servers: and does not inherit the global map, so a global-only stanza would have reached zero profiles. The per-profile upsert is necessary and works.
  • Idempotency: my existing global gortex entry → skip (already-configured), left untouched (60/120 preserved); re-runs are byte-identical.
  • Pre-existing servers in every profile are preserved (merge adds gortex alongside them).
  • Skills: 19 SKILL.md written, frontmatter parses, metadata.hermes.category is a real field, and flat skills/<name>/ does load. Master skill content is accurate.

Fix before merge

MAJOR

  1. --dry-run writes a .bak to disk. In MergeYAML, the malformed/non-mapping backup (path + ".bak") is written before the opts.DryRun check, so gortex init --dry-run on a malformed YAML mutates disk. Capture the backup bytes and write them only in the real-apply branch.
  2. A non-map mcp_servers: is silently replaced. UpsertYAMLMapEntry swaps a mcp_servers whose value is null/scalar/sequence for a fresh {} and drops the original node — no .bak, no error. An empty mcp_servers: (all servers commented out → null) is a realistic trigger. Treat a non-map value as schema-invalid: error or route through the backup path.

MINOR
3. Whole-file re-indent on merge. yaml.v3's encoder (SetIndent(2)) can't emit indentless block sequences, so merging a config that uses that style re-indents every sequence in the file (~200 of ~620 lines on one of my profiles). Comments survive and it's semantically identical, but it contradicts the "touches only the keys we add" goal and yields a noisy diff on hand-maintained files. At least worth calling out in the PR.
4. Partial profile failure is hidden: a failed profile write is only a warning, yet res.Configured=true, leaving that profile unconfigured (and profiles don't inherit). Aggregate profile errors / surface them in the result.
5. resolveGortexCommand can bake an ephemeral path: under go run, os.Executable() is a temp/deleted binary. Prefer exec.LookPath("gortex"); use os.Executable() only for a stable install path, else bare gortex.

NIT
6. Flat skills/gortex-*/ clutters the skills root; the categorized norm is skills/<category>/<name>/, and routingSkillTaxonomy already computes a category.
7. Uninstall note points at ~/.hermes/skills/gortex but leaves the 18 gortex-* routing skills orphaned — make it ~/.hermes/skills/gortex*.
8. Stray space in the wizard/uninstall strings: ~/.hermes/ config.yaml~/.hermes/config.yaml.
9. Frontmatter omits platforms: [linux, macos, windows] and related_skills, which most of my real skills carry (optional, but standard).
10. The master skill could list its /gortex-* slash commands for first-run discoverability.

FYI (not a bug)

My profiles use scoped gortex servers (gortex_hlr, gortex_sos_toulon → per-workspace daemons), not a generic gortex. The adapter adds a generic gortex alongside those with no clash, so those profiles end up with both. Fine for the default case — just noting the multi-daemon pattern exists.

Happy to re-run this QA on a revised branch.

zzet added 4 commits June 4, 2026 18:04
Wire the gortex MCP server into NousResearch Hermes (~/.hermes), a
user-level CLI agent-orchestrator that consumes MCP servers. Hermes
users previously had to hand-author the server stanza and any usage
guidance; this brings Hermes to parity with the Claude Code / Antigravity
user-level treatment (MCP wiring + an instruction surface).

internal/agents/hermes — new user-mode adapter:
  - Detect: `hermes` on PATH or ~/.hermes present.
  - Upserts a `gortex` stdio server (command, args:[mcp], connect_timeout,
    timeout) under the snake_case `mcp_servers` map of the global
    ~/.hermes/config.yaml AND every existing ~/.hermes/profiles/*/config.yaml.
    Hermes profiles can re-declare their own server map, so writing only
    the global stanza is not guaranteed to reach every profile; touching
    each existing profile config makes the tools resolve everywhere
    (existing profiles only — we never create new ones).
  - Installs a user-level skill at ~/.hermes/skills/gortex/SKILL.md
    (Hermes frontmatter: name/description/version/metadata.hermes) teaching
    the agent to prefer graph tools and documenting multi-repo scoping.

internal/agents.MergeYAML — comment-preserving YAML merge helper (a
yaml.Node round-trip, not a map round-trip which would drop every comment),
plus YAMLMapValue / YAMLSetMapValue / YAMLScalar / UpsertYAMLMapEntry.
Honours the existing atomic-write + .bak-on-malformed contract.

Wiring: register hermes in the init/install registry, add the wizard
label + detail, an ~/.hermes preserved-note in `uninstall`, and
ReadYAML / WriteYAML test helpers. `init doctor` reports it automatically.

Implements the adapter requested in #38; QA against a real ~/.hermes
(global stanza vs. per-profile resolution, comment preservation) pending.
Bring the Hermes user-level skill surface to parity with Claude Code by
installing the per-task routing playbooks alongside the master `gortex`
skill: explore, impact, debug, refactor, rename, safe-edit, fix-all,
extract-function, cross-repo-usage, dataflow-trace, add-test,
incident-investigation, episode-replay, co-change, onboarding,
quality-audit, architecture-review, pr-review.

The bodies are reused verbatim from internal/agents/claudecode (single
source of truth — the two agents never drift) and re-wrapped with Hermes
frontmatter (name + description carried over, version, metadata.hermes
tags + category). Hermes turns every installed skill into a /skill-name
slash command, so the /gortex-*-style cross-references in the bodies
resolve to the sibling skills installed here.

gortex-guide is excluded: the native master `gortex` skill already fills
the guide role. Installed at ~/.hermes/skills/<name>/SKILL.md, skipped
when already present so user edits survive. Plan/doctor report them all.
Two data-touching bugs in the comment-preserving YAML merge, surfaced by
QA on the Hermes adapter (#42):

- MergeYAML wrote a malformed / non-mapping file's .bak during the parse
  phase, before the DryRun check, so `gortex init --dry-run` mutated
  disk. The backup bytes are now captured and written only in the
  real-apply branch.
- UpsertYAMLMapEntry silently swapped a non-mapping value under the outer
  key (a null / scalar / sequence `mcp_servers:`) for a fresh map,
  dropping the original node with no .bak and no error. A null value
  (every server commented out) is now populated in place — keeping the
  key's comment — while a non-null scalar or sequence is refused as
  schema-invalid, leaving the file untouched.

Also detect and match the file's existing indent width on re-encode
instead of forcing 2-space; block sequences are still re-indented one
level, a yaml.v3 encoder limitation documented at the detection site.

Regression tests cover the dry-run-no-backup, non-map-refusal,
null-populate, and indent-width paths.
Follow-ups from the same QA pass, none data-mutating:

- Aggregate per-profile write failures onto a new Result.Warnings field
  and surface them in the install summary, so a failed profile no longer
  hides behind Configured=true (those profiles don't inherit the global
  stanza).
- resolveGortexCommand prefers a real installed binary: it trusts
  os.Executable() only when it points at a `gortex` outside the temp dir
  (a `go run` build is ephemeral and may even be named gortex), else
  falls back to LookPath, else the bare name.
- Write skills under skills/<category>/<name>/ (reusing the routing
  taxonomy) instead of cluttering the skills root, and fix the uninstall
  note so the gortex-* routing skills aren't left orphaned.
- Add platforms + related_skills to the master and routing frontmatter,
  and a slash-command index to the master skill, derived from the
  installed routing set so it can't drift.
- Drop a stray space in the ~/.hermes/config.yaml wizard / uninstall
  hints.
@zzet zzet force-pushed the feat/hermes-adapter branch from e1909a3 to 99767a0 Compare June 4, 2026 17:07
@zzet
Copy link
Copy Markdown
Owner Author

zzet commented Jun 4, 2026

Hey @rolldav! I have an issue with the whole-file re-indent (MINOR - 3 - Whole-file re-indent on merge).

I can address this only partially unless you can give me hints about conventions. For now, I added detection of the current file indentation and inherited it to match the file's own indent width (a 4-space config is re-emitted at 4-space, not forced to 2). The residual is the indentless block sequence style: yaml.v3's encoder has no setting to emit an item at the parent key's column, so block sequences are always re-indented one level on a comment-preserving re-encode.

The only way to eliminate that diff is to stop whole-document re-encoding and surgically byte-splice just the changed key; that's a larger change I deferred since the result is semantically identical and comment-preserving.

Fixes for the remaining issues are in the branch.

@rolldav
Copy link
Copy Markdown

rolldav commented Jun 4, 2026

Re-QA'd the revised branch at 99767a0: built it and ran gortex install --agents=hermes against a throwaway $HOME copy of my real ~/.hermes (global + 10 profiles), plus pathological configs to exercise the edge paths. All MAJOR/MINOR/NIT items hold. I also ran a regression sweep over the two fix commits and found three new edge cases — all minor / non-corrupting, listed at the end.

Verified fixed

MAJOR

  • --dry-run no longer writes .bak: dry-run against a malformed config → 0 .bak, target byte-identical; a real apply on the same file does write the .bak. Deferral is correct.
  • Non-map mcp_servers: no longer silently replaced: a scalar (mcp_servers: "x") leaves the file byte-identical and surfaces "mcp_servers" is a scalar, not a mapping; refusing to overwrite; a null value populates gortex in place. The null→in-place split is a better outcome than my original "error or backup" suggestion.

MINOR / NIT

  • Partial profile failure is now surfaced (JSON warnings[] + stderr summary), not masked by configured=true.
  • No ephemeral exec path: even running the freshly-built binary from /tmp, the stanza is command: /opt/homebrew/bin/gortex (via LookPath).
  • Skills are categorized (skills/<category>/<name>/). Confirmed at runtime, not just structurally: hermes skills list enumerates all 19 as enabled under the right categories (analysis, code-intelligence, debugging, navigation, refactoring, testing) — matching the layout the bulk of my real skills already use.
  • Frontmatter carries platforms + related_skills; the rendered master skill lists all 18 /gortex-* commands; stray-space string gone.

Idempotency holds (second run = all-skip, byte-stable). go test ./internal/agents/... green (incl. hermes + agents).

MINOR-3 (re-indent) — your question

You read the convention right: my hand-maintained configs use indentless block sequences (items at the parent key's column), which yaml.v3's encoder can't reproduce. The indent-width inheritance you added does work (a 4-space config with only mapping keys → clean diff, only the added gortex: block). I wouldn't block on the residual; the surgical byte-splice is the clean fix but a real scope increase — good follow-up. One ask: note the re-indent behaviour somewhere user-facing so hand-maintained configs aren't a surprise.

Regression sweep of 5f8501e..99767a0 — 3 new edges (all minor, none corrupting)

  1. detectYAMLIndent is fooled by block scalars (internal/agents/yaml.go). It takes the smallest leading-space across all physical lines, including block-scalar (|/>) body lines. A 4-space config with a 2-space-indented block body makes it detect 2 and re-emit the whole document at 2-space. Verified: a profile with notes: | (body at 2) + 4-space structure → github: came back as github:. Since Hermes configs routinely carry multi-line prompt strings, this defeats the MINOR-3 indent-inheritance mitigation in practice. Skip block-scalar content when scanning. (Most material of the three; still cosmetic — semantically identical, comments preserved.)

  2. An anchored/aliased mcp_servers: is refused (mcp_servers: *base"mcp_servers" is an alias, not a mapping; refusing to overwrite). The switch doesn't dereference AliasNode. Fails safe (file untouched, warning surfaced) but leaves that profile unconfigured. Fine to document as unsupported rather than fix.

  3. An explicit empty string is converted without a warning. isNullYAMLNode treats any Value == "" scalar as null regardless of tag, so mcp_servers: "" (tag !!str) is populated in place — whereas mcp_servers: "x" is refused. Inconsistent with the shape-strict path, though the dropped "data" is just an empty string; arguably acceptable, just emit a note or tighten the null check to !!null-only.

Two smaller notes: on null→in-place, an inner placeholder comment (# - foo nested under the key) is repositioned to a top-level trailing line — preserved, not lost, but inner-comment association isn't held. And the uninstall note says to delete gortex / gortex-* directories under ~/.hermes/skills/, but the NIT-6 categorized move put them one level deeper (skills/<category>/gortex-*/), so that glob would miss them.

LGTM to merge. Nothing here is blocking — MINOR-3 + the block-scalar indent edge are the two worth a follow-up.

@zzet zzet merged commit 4d24093 into main Jun 4, 2026
10 checks passed
@zzet zzet deleted the feat/hermes-adapter branch June 4, 2026 23:08
@zzet
Copy link
Copy Markdown
Owner Author

zzet commented Jun 4, 2026

Thank you for the help @rolldav!
I'll experiment with the Hermes setup once I have a bit more free time and take a look at lefttovers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants