Skip to content

hermes: register_skill fix + CLI parity + canonical per-profile hooks + pack deploy - #41

Open
grimmjoww wants to merge 9 commits into
xoai:mainfrom
grimmjoww:hermes-cli-init-parity
Open

hermes: register_skill fix + CLI parity + canonical per-profile hooks + pack deploy#41
grimmjoww wants to merge 9 commits into
xoai:mainfrom
grimmjoww:hermes-cli-init-parity

Conversation

@grimmjoww

@grimmjoww grimmjoww commented Aug 6, 2026

Copy link
Copy Markdown

why this follow-up exists

#40 got the Hermes Tier A adapter onto main. This PR fixes the install and update paths around it.

The adapter worked once it was loaded, but sage init --platform hermes could not reliably reach it. hermes was missing from several platform switches in bin/sage, the community generator resolved its repo root incorrectly on Windows, and the old install path guessed one Hermes home even though plugins and hooks live per profile.

There were two quieter bugs too. The plugin passed a string to ctx.register_skill() even though Hermes expects a Path, so those registrations failed. sage update also left normal managed plugin copies stale after the framework itself had updated.

what changed

  • bin/sage now treats hermes as a real platform during init, update, --platform all, interactive selection, worktree setup, and the get-started output.
  • generate-hermes.sh builds SOUL.md from Sage's shared emitters, discovers Hermes profiles, lets an interactive user choose which profiles get Sage, and installs to every discovered profile in non-interactive mode.
  • Each selected profile gets its own plugin copy, gate scripts, and idempotent config.yaml hook registrations.
  • The Windows handoffs normalize paths before they cross the MSYS Bash/native Python boundary.
  • sage update now refreshes normal managed plugin copies. It refuses to overwrite a Git-managed checkout when its contents differ, because an update should not destroy somebody's branch.
  • skill_manager.py can deploy and undeploy packs on Hermes's native skill surface.
  • register_skill() now receives a Path.

The profile chooser is intentionally a numbered prompt rather than a TUI widget. In automation or any other non-TTY run, the installer provisions all discovered profiles.

proof I reran

I reran the current branch from a clean worktree at 8c2e696:

  • Hermes CLI/init/update tests: 14 passed
  • plugin build and audit tests: 21 passed
  • Windows Bash/path/config probe: 11 passed
  • Hermes gate probe: 16 passed
  • Bash syntax check: clean

I also ran the profile chooser through a real PTY with two fake profiles, selected only alpha, and asserted that beta stayed untouched:

PASS selected=alpha version=1.3.17 siblings_untouched=true hook_installed=true

GitHub's required checks are green. The community conformance matrix entry is skipped because Hermes is a community platform; it is not a failed check.

relation to #40

#40 is already merged at 9fa8aa0. This branch's merge base is that exact commit, so #41 contains #40 and adds nine follow-up commits. It is zero commits behind main, GitHub reports the merge state as CLEAN, and there is no second reconciliation branch to merge.

note from the #40 review

The review on #40 pointed out that the Hermes allowlist and SKILLS_NOT_IN_PLUGIN could drift. This branch carries a regression check for that relationship. sage-autoresearch remains a pip package rather than a shipped plugin skill, so the intended invariant is _HERMES_SKILLS ⊆ SKILLS_NOT_IN_PLUGIN, not strict equality.

ctx.register_skill() calls .exists() on its path argument; passing
str(skill_md) raised 'str object has no attribute exists' on every
load, silently dropping all 21 skill registrations. Native skill
discovery masked it, so no session ever noticed. Pass the Path.

Verified: mock-ctx probe registers 21/21 clean, zero warnings.
sage init --platform hermes was unreachable: the flag parser, the
interactive menu, the update-path parser, and the worktree copy case
all omitted hermes. All four now accept it.

generate-hermes.sh rewritten to canonical install parity with
generate-claude-code.sh:
- SOUL.md from the shared instructions-body + constitution emitters
- root resolution works in both repo checkout and vendored layouts
- gate scripts install to <profile>/agent-hooks/sage/ with a new
  sage-hermes-gate.sh adapter translating the claude-code wire
  protocol (exit-2 + stderr, tool_input.file_path) to the Hermes
  shell-hook protocol (JSON stdout, tool_input.path)
- hooks registered in <profile>/config.yaml hooks: block via
  idempotent merge, 11 entries mirroring the claude-code WANTED table
- per-profile multi-select (plugins and hooks are per-profile; Hermes
  has no marketplace, so file-copy is the distribution path)
- MSYS/Windows guards on every python3 handoff (cygpath -w)

Verified: probe 13/13, conformance pass 6 fail 0, E2E against a fake
3-profile HERMES_HOME (YAML valid, 11 hooks under correct event keys,
rerun adds 0).
… set -e

Independent review (read-only subagent) found: the adapter's allow
path emitted two JSON documents (context + trailing {}) on stdout,
which fails Hermes core's whole-stdout json.loads and silently drops
context (sage-scope-journal.sh emits additionalContext — live, not
theoretical). Allow path now emits exactly one document: the context
injection if the gate produced one, {} otherwise.

Also: hermes added to the --platform help text; the hook-merge
success check was dead under set -e, so the heredoc now runs in an
if/else condition explicitly.

Verified: adapter wire test (allow+context parses as single doc,
block path returns decision:block, fail-open paths clean), generator
bash -n clean, E2E fake-home merge 11 hooks valid YAML, probe 13/13,
conformance pass 6 fail 0.
sage add installs packs to sage/skills/ but deploy_to_platform() only
had branches for claude-code (.claude/skills loader stubs) and
antigravity (.agent/skills full copies). On hermes the pack skills
never reached the runtime surface — 'sage add' silently did nothing
the agent could see.

Add a hermes branch: copy the full skill into the profile skills dir,
which is hermes's flat native skill-discovery surface. Resolution:
$HERMES_HOME/skills, then every ~/.hermes/profiles/*/skills/, then the
flat ~/.hermes/skills/. Matching undeploy branch so removal cleans up.

Verified live: sage-product@v1.3.2 (11 skills) and
sage-pack-authoring@v1.3.2 (5 skills) installed with sha256-verified
integrity; all 16 deployed to the profile skills dir and loadable.
sage-autoresearch is a pip package (not skills) by design.
@grimmjoww grimmjoww changed the title hermes: CLI parity for sage init + canonical per-profile hook registration hermes: register_skill fix + CLI parity + canonical per-profile hooks + pack deploy Aug 6, 2026
…ed needle

Independent reviewer (deleg_ea121c26) found the live profile config.yaml
had 22 sage hooks (11 exact duplicates). Root cause: the dedup check
'if cmd in text' built the needle as a single-line double-quoted scalar
("bash \"...gate.sh\" sage-spec-gate.sh"), but the written entries
are folded plain scalars split across two lines, so the needle never
matched and every rerun appended a full duplicate set. Every gate
double-fired per tool call.

Dedup now matches on script basename near the adapter path via a
folding/quoting-tolerant regex. Verified against a seeded config with
the real folded format: 2 seeded entries detected, only the 9 genuinely
missing added, no duplicates. Live profile config cleaned 22 → 11
(backup config.yaml.bak-20260806-dedupe).
@grimmjoww
grimmjoww force-pushed the hermes-cli-init-parity branch from d61f035 to 1bf0cd5 Compare August 6, 2026 17:28
…k rewrite

Root cause (proven byte-exact): hermes spawns hook commands via
shlex.split + shell=False; on Windows CreateProcess searches System32
before PATH, so a bare bash argv[0] resolves to WSL System32 bash.exe,
which cannot read G:/ script paths. Every registered sage shell hook
exited 127 and failed open — decorative, never firing.

Fix, two parts:
1. SAGE_BASH_EXE: the generator resolves the bash actually running it
   to an absolute Windows path at install time (cygpath -w of
   command -v bash, forward-slashed) and uses it as argv[0] in _cmd().
   Nothing machine-specific hardcoded; non-cygpath hosts fall back to
   bare bash.
2. In-place rewrite of broken entries: dedup now searches the WHOLE
   text (in folded configs the adapter+script pattern spans two lines,
   so a per-line search can never match — root cause of a silent
   updated=0 in the first fix attempt), walks back from the match to
   the command: line, and if that line uses the bare-bash form
   rewrites the WHOLE folded block. Block terminator derives from the
   command line's own indent (continuations strictly deeper); a
   hardcoded shallow threshold would have eaten timeout: 30, which
   sits at the same indent. MERGED_OK now reports updated=N.

Independent review findings addressed:
- deleg_c30d782b CRITICAL: rewriting only the first line of a folded
  scalar orphans continuations and breaks yaml.safe_load — the probe
  now asserts the merged config parses.
- Read-only pass 2026-08-06: 11 live entries (probe asserts 11, not
  3); entry regex must allow hyphens in script names; timeout: 30 at
  6-space indent must survive rewrites.

Proof: test_hermes_bash_fix_probe.py 11/11 via pytest — run 1 rewrites
the 3 broken blocks and appends the 8 missing (added=8 updated=3),
run 2 a clean no-op (added=0 updated=0), timeout intact 11/11, yaml
parses. Wire spawn: broken form returns 127, fixed absolute-bash form
returns 0 — the adapter is now reached and executes.
…h + MSYS_NO_PATHCONV

Proven byte-exact second failure after the exit=127 fix landed: gate
scripts write their python to an mktemp file and exec python3 "$TMPFILE".
Windows-native python3 cannot open /tmp/... (MSYS2 tree), and MSYS arg
conversion rewrote the path to G:\tmp\... — every gate hit an internal
error and failed open (silent allow), e.g. secrets-gate on a live
sk-ant probe returned rc=0 allow instead of a block JSON.

Fix at the adapter boundary (gate scripts stay canonical, untouched):
export TMPDIR=$(cygpath -w ...) and MSYS_NO_PATHCONV=1 before running
the gate. No-op on hosts without cygpath.

Proof (exact registered command shape, absolute-bash argv[0]):
  block probe  -> {"decision": "block", ...} sk-... key vetoed
  allow probe  -> empty stdout (clean edit passes)
  her suite    -> test_hermes_bash_fix_probe.py 1 passed
@grimmjoww

Copy link
Copy Markdown
Author

Two commits landed since the last update (1bf0cd597cf216). Both fix Windows failure modes found by live-firing the installed hooks, not by reading the code.

5838838 — shell-hook exit=127: hooks were decorative on Windows

Hermes spawns hook commands via shlex.split + shell=False. On Windows, CreateProcess searches System32 before PATH, so a bare bash argv[0] resolves to WSL's System32 bash.exe. That bash cannot read G:/-style script paths, so every registered hook exited 127 and failed open. The gates looked installed. They never ran.

Two fixes:

  1. SAGE_BASH_EXE: the generator resolves the bash actually running it to an absolute Windows path at install time (cygpath -w of command -v bash, forward-slashed) and uses it as argv[0]. Nothing machine-specific is written into the config; hosts without cygpath fall back to bare bash.
  2. In-place rewrite of broken entries. Dedup now searches the whole config text, because in folded configs the adapter+script pattern spans two lines and a per-line search can never match (that was the root cause of a silent updated=0 on the first fix attempt). The rewriter walks back from the match to the command: line, and if that line uses the bare-bash form it rewrites the whole folded block. The block terminator derives from the command line's own indent, so timeout: 30, which sits at the same indent, survives. A hardcoded shallow threshold would have eaten it.

New probe: develop/conformance/probes/test_hermes_bash_fix_probe.py (199 lines). It asserts 11 live entries, not 3, rewrites the broken blocks on run 1 (added=8 updated=3), no-ops on run 2 (added=0 updated=0), keeps all 11 timeouts, and asserts the merged config still parses with yaml.safe_load. Wire-spawn check: the broken form returns 127, the fixed absolute-bash form returns 0.

97cf216 — second failure, found only after the first was fixed: gates still failed open

With the 127 gone, the hooks were reachable but still allowed everything. Every gate script writes its decision logic to an mktemp file and runs python3 "$TMPFILE". Two Windows problems stacked: native python3 cannot open /tmp/... (that is the MSYS2 tree, not a Windows path), and MSYS argument conversion rewrote the path to G:\tmp\... on the way through. Result: internal error in every gate, and the fail-open design did its job, silently.

Fixed at the adapter boundary so the gate scripts stay the single canonical copy of decision logic: before running a gate, the adapter exports TMPDIR pointed at a Windows-native temp dir and sets MSYS_NO_PATHCONV=1 so paths arrive untouched. No-op on hosts without cygpath. Proof is a live-fire of the exact registered command shape: secrets-gate now returns a block JSON for an sk-ant payload and clean output for a benign edit.

Portability check (per review request)

No hardcoded paths, homes, or usernames in any shipped file. HERMES_HOME env override with $HOME/.hermes fallback, profile enumeration with default mapped to the home root, and the generated command shape was verified to survive shlex.split with spaces in the path (C:/Program Files/... parses to exactly 3 argv parts). Gates remain opt-in per project via .sage/config.yaml; installing into one project never enforces anything outside it.

Refresh existing managed plugin copies from the staged framework payload so Sage update cannot silently retain stale adapter files. Refuse to overwrite distinct Git-managed checkouts because Sage cannot prove them current.

Add regressions for stale root and deep plugin payload files.
@grimmjoww

Copy link
Copy Markdown
Author

quick follow-up after the last push: I found two more install/update problems and fixed both.

72fa482 finishes the CLI wiring. hermes is now reachable from init, update, --platform all, the interactive menu, worktree setup, and the saved platform flow.

8c2e696 fixes stale managed installs. sage update refreshes a normal managed plugin copy, but refuses to overwrite a divergent Git checkout.

I reran the branch from a clean worktree:

  • CLI/init/update: 14 passed
  • plugin build/audit: 21 passed
  • Windows path/config probe: 11 passed
  • Hermes gates: 16 passed
  • interactive PTY profile test: selected alpha; beta stayed untouched

I also checked the relationship to #40 rather than assuming it. #40 is merged at 9fa8aa0, and that is the exact merge base for this branch. #41 is 0 behind and 9 ahead, with a CLEAN merge state. Nothing needs to be reopened or double-merged.

I rewrote the PR description so it matches the current branch and the checks above. ready for review.

@grimmjoww

Copy link
Copy Markdown
Author

@xoai, when you have a minute, #41 is ready for review. #40 is already merged, and this branch starts from that merge commit, so there isn't another branch to reconcile. This one covers the remaining CLI and per-profile install/update fixes. CI is green, and the description has the latest Windows and PTY test receipts. thanks.

@xoai

xoai commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Review — approve after rebase; strong follow-up, one merge conflict, four non-blocking findings

First: thank you — this is careful work, and two things in it deserve explicit credit. The backtick escaping in the config.yaml heredoc is a real pre-existing bug fix, not cosmetics: that heredoc is unquoted, so the bare backticks were live command substitutions silently mangling every generated config (I verified the heredoc is now fully escaped — zero unescaped backticks remain in its 73 lines). And the gate adapter's copy-our-hooks-per-profile design means the claude-code gate hardening that landed on main this week flows to Hermes automatically, with no forked decision logic. That's the right architecture.

The rebase, and the one conflict you'll hit. This branch is now 10 commits behind main — v1.3.18 (the parallel-lanes pack) landed after you opened this, so the "zero commits behind, CLEAN" note in the description is stale. There's exactly one conflict: develop/validators/tools/test_build_plugin.py. Main's 10b89f2 independently fixed the same #40 collision bug your test_build_omits_excluded_skills rewrite fixes. Your version is tighter (it asserts the canonical replacement ships and encodes overlay-before-system priority; main's only asserts absence-or-byte-match), so the resolution is: keep your test body, and also keep main's OVERLAY_SKILL_NAMES class attribute + test_collision_names_ship_the_canonical_source_not_the_mirror companion — they compose. I ran exactly that composition on a trial merge: all 23 tests pass, and the full battery on the merged tree is green (fastcheck 26/26, hook suite 166/166, generation-smoke 8/8 including your hermes case, coverage registry clean at 129 surfaces).

Findings (all reproduced; none blocking, descending severity):

  1. develop/conformance/probes/test_hermes_bash_fix_probe.py exits 1 on POSIX hosts. It derives a Windows bash path (C:/Program Files/Git/bin/bash.exe) and crashes with FileNotFoundError on Linux — no skip guard anywhere in the file. It isn't wired into CI, but anyone running the probes directory on a POSIX box gets a red test that looks like a real failure. A unittest.skipUnless(shutil.which("cygpath"), "Windows/MSYS-only probe") at the class would fix it. Would appreciate this in the same push as the rebase.
  2. The git-checkout refusal exit 1s mid-loop (generate-hermes.sh, the [ -e "$DEST_PLUGIN/.git" ] branch): one git-managed profile aborts the whole multi-profile install, leaving later profiles unprovisioned and that profile half-done (its hooks are already installed by step 1). Suggest warn-and-continue with a nonzero exit at the end summarizing which profiles were skipped.
  3. PR description vs. code on the refusal condition: the body says it refuses "when its contents differ"; the code refuses on any .git presence, unconditionally. The code is the safer behavior — I'd just amend the description to match it rather than weaken the code.
  4. Plugin refresh merge-copies without pruning: cp -r "$PLUGIN_STAGE"/. "$DEST_PLUGIN"/ never removes files deleted upstream, so a skill or gate removed from Sage lingers in every profile across updates — the stale-artifact class the repo's own Gate-4 history is about. An rm -rf of the destination before copy (you already stage, so the window is small), or a post-copy sweep of files absent from the stage, would close it. Fine as a follow-up if you'd rather keep this PR's scope.

Two nits, take or leave: the Hermes home-resolution block in skill_manager.py is duplicated verbatim between deploy and undeploy (a small helper would do), and build_plugin.py's comment still cites runtime/platforms/community/hermes/__init__.py for the plugin entry point that deliberately lives at the repo root.

What I verified beyond the diff: your claimed suites reproduce at the PR head (init tests OK, plugin tests OK, hermes gate probe 16/16); bin/sage covers every platform switch (init, update, all, interactive, worktree, help, get-started) with the empty-array bash-3.2 idiom intact and parses clean under bash -n; the root __init__.py placement matches the documented Hermes packaging model; the _HERMES_SKILLS ⊆ SKILLS_NOT_IN_PLUGIN regression check does what the #40 review asked.

Happy to hand you the composed test_build_plugin.py resolution if useful. After the rebase + finding 1, this is an approve from me.

@xoai

xoai commented Aug 9, 2026

Copy link
Copy Markdown
Owner

As promised — the composed test_build_plugin.py resolution for the rebase, verified against the actual merge with current main (22 tests OK, no duplicate method names, build_plugin.py --check clean at 1.3.18).

Resolving the conflict, in two steps:

1. For test_build_omits_excluded_skills, take your side (the canonical-first version with the sources tuple) verbatim — it supersedes main's split absence-check. Take main's side for nothing in that method.

2. Add the following immediately before def test_gate_scripts_are_identical_to_their_sources — this is main's 10b89f2 companion tripwire, with one adjustment for your PR (noted below):

    OVERLAY_SKILL_NAMES = frozenset(
        p.name for p in (REPO_ROOT / "runtime" / "plugin-overlay"
                         / "skills").iterdir()
        if (p / "SKILL.md").is_file())

    def test_collision_names_ship_the_canonical_source_not_the_mirror(self):
        """The Gate-4 duplication lesson as a tripwire: for names that
        exist both as hermes mirrors (skills/) and as system skills or
        overlay skills, the plugin must ship the CANONICAL source — if
        an edit lands there and the plugin starts shipping the stale
        mirror, this fails. (Overlay outranks system-skills, matching
        the canonical-resolution order in test_build_omits_excluded_
        skills.)"""
        out = self._build()
        for name in (build_plugin.SKILLS_NOT_IN_PLUGIN
                     & build_plugin.SYSTEM_SKILL_NAMES
                     - self.OVERLAY_SKILL_NAMES):
            shipped = (out / "skills" / name / "SKILL.md").read_bytes()
            system = (REPO_ROOT / "core" / "system-skills" / name
                      / "SKILL.md").read_bytes()
            self.assertEqual(shipped, system,
                             f"{name}: shipped bytes are not the "
                             f"system-skill source")
        for name in (build_plugin.SKILLS_NOT_IN_PLUGIN
                     & self.OVERLAY_SKILL_NAMES):
            shipped = (out / "skills" / name / "SKILL.md").read_bytes()
            overlay = (REPO_ROOT / "runtime" / "plugin-overlay" / "skills"
                       / name / "SKILL.md").read_bytes()
            self.assertEqual(shipped, overlay,
                             f"{name}: shipped bytes are not the "
                             f"overlay source")

The one adjustment vs. main's version: the system-skill loop now subtracts OVERLAY_SKILL_NAMES (& SYSTEM_SKILL_NAMES - self.OVERLAY_SKILL_NAMES), so for a name that ever exists in both overlay and system-skills, the tripwire compares against the overlay — matching the overlay-before-system priority your sources tuple encodes. Main's original would have compared such a name against system-skills and disagreed with your test. The intersection is empty today, so both pass now; this keeps them agreeing if it ever isn't.

Two composition traps I hit so you don't have to: concatenating both conflict sides compiles fine but leaves two methods named test_build_omits_excluded_skills, and Python silently runs only the second — the suite stays green while one version stops executing. Worth an eyeball (grep -c "def test_build_omits_excluded_skills" should say 1) after resolving. And your version + this companion are redundant where they overlap, deliberately — yours asserts the canonical file ships, this one pins which bytes; they fail on different mutations.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants