fix(#2284): project Hermes named dispatch onto delegate_task; protect comparison tables (#2309)

Hermes installs brand-swapped "Claude Code" -> "Hermes Agent" in shipped
workflows/*.md but never projected the Agent(...) dispatch calls onto
Hermes's delegate_task contract, so installed workflows kept literal
Agent(...) syntax and falsely asserted "The Agent tool IS available"
(Hermes exposes delegate_task, not Agent).

Dispatch projection: a generic named-dispatch engine
(projectNamedDispatchToStructuralDelegate) wired into the per-runtime
RUNTIME_CONTENT_DISPATCH.hermes.md converter, branching entirely on the
documentation-sourced hostIntegration.dispatch facts read via
_hostIntegrationDispatch (capability.json unchanged): namedDispatch:false
-> resolve the gsd-* role and embed a load-its-prompt instruction in the
payload; background:true -> map onto delegate_task background; read-only /
maxDepth:1 -> no nested delegation to leaf roles; per-call model dropped.
Span detection uses literal Agent( scanning + local balanced paren/quote
matching (immune to upstream document quote imbalance) and handles all
three corpus call forms (multi-line, object-literal, single-line compact).
An independent, mask-free post-projection guard fails the install loud on
any residual Agent(/subagent_type/leaked model. Fail-closed: install
throws if a literal gsd-* role reference cannot be resolved. commands ->
skill path untouched. No literal Agent( survives in installed Hermes
workflows.

Folded in (maintainer-directed) a pre-existing cross-cutting branding
defect: the "Claude Code" -> brand swap corrupted <runtime_compatibility>
comparison tables (where "Claude Code" is a compared-runtime label) for
every branding runtime. New shared applyClaudeCodeBrandSwap helper
protects <runtime_compatibility> regions via split-and-rejoin (no sentinel
token) while still rebranding genuine self-references; adopted by all six
branding .md converters. Also a surgical prose-consistency fix so
plan-review-convergence.md's dispatch-adjacent terminology is coherent
post-projection (no broad bare-word rename).

Golden install-parity regenerated for the six branding runtimes
(dispatch/branding scope only); other runtimes unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-15 17:10:06 -04:00
committed by GitHub
parent 612fcb00f7
commit 75bedf16fd
11 changed files with 1891 additions and 67 deletions

View File

@@ -573,6 +573,20 @@ describe('uninstall skills cleanup — hermes', () => {
// ─── Section 4: No Claude references leak into non-Claude runtimes ────────────
// #2284(b): a `<runtime_compatibility>...</runtime_compatibility>` block (e.g.
// execute-phase.md/plan-phase.md's "**Subagent spawning is runtime-specific:**"
// table) INTENTIONALLY retains "Claude Code" as a COMPARISON-RUNTIME LABEL —
// "- **Claude Code:** Uses `Agent(subagent_type=..., ...)`" documents how
// Claude Code behaves for a reader on ANY installed runtime; it is never a
// host-self-reference the install is supposed to rebrand away. Strip these
// blocks before the leak scan below so that legitimate retention doesn't trip
// the "zero Claude references" invariant — everything OUTSIDE a
// `runtime_compatibility` block is still held to zero, so an actual leaked
// self-reference elsewhere in the file still fails this test.
function stripRuntimeCompatibilityBlocks(content) {
return content.replace(/<runtime_compatibility>[\s\S]*?<\/runtime_compatibility>/g, '');
}
for (const runtime of ['hermes', 'qwen']) {
describe(`no Claude references leak into ${runtime} install`, () => {
let tmpDir;
@@ -628,7 +642,9 @@ for (const runtime of ['hermes', 'qwen']) {
path.basename(f) !== 'CHANGELOG.md'
);
const leaks = allFiles.filter(f => {
const c = fs.readFileSync(f, 'utf8');
// #2284(b): exempt `<runtime_compatibility>` comparison-table content
// — see the exemption's rationale above the `for` loop.
const c = stripRuntimeCompatibilityBlocks(fs.readFileSync(f, 'utf8'));
return /\bCLAUDE\.md\b/.test(c) || /\bClaude Code\b/.test(c) || /\.claude\//.test(c);
}).map(f => path.relative(tmpDir, f));
assert.strictEqual(leaks.length, 0, `Leaking: ${leaks.join(', ')}`);