hermes: register_skill fix + CLI parity + canonical per-profile hooks + pack deploy - #41
hermes: register_skill fix + CLI parity + canonical per-profile hooks + pack deploy#41grimmjoww wants to merge 9 commits into
Conversation
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.
…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).
d61f035 to
1bf0cd5
Compare
…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
|
Two commits landed since the last update (1bf0cd5 → 97cf216). 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 Two fixes:
New probe: 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 Fixed at the adapter boundary so the gate scripts stay the single canonical copy of decision logic: before running a gate, the adapter exports Portability check (per review request) No hardcoded paths, homes, or usernames in any shipped file. |
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.
|
quick follow-up after the last push: I found two more install/update problems and fixed both.
I reran the branch from a clean worktree:
I also checked the relationship to #40 rather than assuming it. #40 is merged at I rewrote the PR description so it matches the current branch and the checks above. ready for review. |
|
@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. |
|
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 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: Findings (all reproduced; none blocking, descending severity):
Two nits, take or leave: the Hermes home-resolution block in 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); Happy to hand you the composed |
|
As promised — the composed Resolving the conflict, in two steps: 1. For 2. Add the following immediately before 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 Two composition traps I hit so you don't have to: concatenating both conflict sides compiles fine but leaves two methods named |
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 hermescould not reliably reach it.hermeswas missing from several platform switches inbin/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 aPath, so those registrations failed.sage updatealso left normal managed plugin copies stale after the framework itself had updated.what changed
bin/sagenow treatshermesas a real platform during init, update,--platform all, interactive selection, worktree setup, and the get-started output.generate-hermes.shbuildsSOUL.mdfrom 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.config.yamlhook registrations.sage updatenow 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.pycan deploy and undeploy packs on Hermes's native skill surface.register_skill()now receives aPath.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:I also ran the profile chooser through a real PTY with two fake profiles, selected only
alpha, and asserted thatbetastayed untouched:PASS selected=alpha version=1.3.17 siblings_untouched=true hook_installed=trueGitHub'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 asCLEAN, 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_PLUGINcould drift. This branch carries a regression check for that relationship.sage-autoresearchremains a pip package rather than a shipped plugin skill, so the intended invariant is_HERMES_SKILLS ⊆ SKILLS_NOT_IN_PLUGIN, not strict equality.