diff --git a/.github/workflows/skill-docs.yml b/.github/workflows/skill-docs.yml index 34ea7f8e9b..06e29cc82a 100644 --- a/.github/workflows/skill-docs.yml +++ b/.github/workflows/skill-docs.yml @@ -31,3 +31,11 @@ jobs: echo "Generated Factory SKILL.md files are stale. Run: bun run gen:skill-docs --host factory" exit 1 } + - name: Generate Hermes skill docs + run: bun run gen:skill-docs --host hermes + - name: Verify Hermes skill docs are fresh + run: | + git diff --exit-code -- .hermes/ || { + echo "Generated Hermes SKILL.md files are stale. Run: bun run gen:skill-docs --host hermes" + exit 1 + } diff --git a/.gitignore b/.gitignore index 4a76c6c178..f13e664520 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ bin/gstack-global-discover .slate/ .cursor/ .openclaw/ +.hermes/ .context/ extension/.auth.json .gstack-worktrees/ diff --git a/hosts/hermes.ts b/hosts/hermes.ts new file mode 100644 index 0000000000..d5ad87464a --- /dev/null +++ b/hosts/hermes.ts @@ -0,0 +1,78 @@ +import type { HostConfig } from '../scripts/host-config'; + +const hermes: HostConfig = { + name: 'hermes', + displayName: 'Hermes', + cliCommand: 'hermes', + cliAliases: [], + + globalRoot: '.hermes/skills/gstack', + localSkillRoot: '.hermes/skills/gstack', + hostSubdir: '.hermes', + usesEnvVars: true, + + frontmatter: { + mode: 'allowlist', + keepFields: ['name', 'description'], + descriptionLimit: 1024, + descriptionLimitBehavior: 'error', + extraFields: { + version: '0.15.13.0', + }, + }, + + generation: { + generateMetadata: false, + skipSkills: ['codex'], + includeSkills: [], + }, + + pathRewrites: [ + { from: '~/.claude/skills/gstack', to: '~/.hermes/skills/gstack' }, + { from: '.claude/skills/gstack', to: '.hermes/skills/gstack' }, + { from: '.claude/skills', to: '.hermes/skills' }, + { from: 'CLAUDE.md', to: 'HERMES.md' }, + ], + toolRewrites: { + 'use the Bash tool': 'use the terminal tool', + 'use the Read tool': 'use the read_file tool', + 'use the Write tool': 'use the write_file tool', + 'use the Edit tool': 'use the patch tool', + 'use the Grep tool': 'use search_files with a regex pattern', + 'use the Glob tool': 'use search_files to find files matching', + 'use the Agent tool': 'use delegate_task', + 'the Bash tool': 'the terminal tool', + 'the Read tool': 'the read_file tool', + 'the Write tool': 'the write_file tool', + 'the Edit tool': 'the patch tool', + 'WebSearch': 'web_search', + }, + + // Suppress Claude-specific preamble sections that don't apply to Hermes + suppressedResolvers: [ + 'DESIGN_OUTSIDE_VOICES', + 'ADVERSARIAL_STEP', + 'CODEX_SECOND_OPINION', + 'CODEX_PLAN_REVIEW', + 'REVIEW_ARMY', + ], + + runtimeRoot: { + globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], + globalFiles: { + 'review': ['checklist.md', 'TODOS-format.md'], + }, + }, + + install: { + prefixable: false, + linkingStrategy: 'symlink-generated', + }, + + coAuthorTrailer: 'Co-Authored-By: Hermes Agent ', + learningsMode: 'basic', + + adapter: './scripts/host-adapters/hermes-adapter', +}; + +export default hermes; diff --git a/hosts/index.ts b/hosts/index.ts index 0b2050926e..88eefad302 100644 --- a/hosts/index.ts +++ b/hosts/index.ts @@ -14,9 +14,10 @@ import opencode from './opencode'; import slate from './slate'; import cursor from './cursor'; import openclaw from './openclaw'; +import hermes from './hermes'; /** All registered host configs. Add new hosts here. */ -export const ALL_HOST_CONFIGS: HostConfig[] = [claude, codex, factory, kiro, opencode, slate, cursor, openclaw]; +export const ALL_HOST_CONFIGS: HostConfig[] = [claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes]; /** Map from host name to config. */ export const HOST_CONFIG_MAP: Record = Object.fromEntries( @@ -63,4 +64,4 @@ export function getExternalHosts(): HostConfig[] { } // Re-export individual configs for direct import -export { claude, codex, factory, kiro, opencode, slate, cursor, openclaw }; +export { claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes }; diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 4da9203fff..388fa4a1c1 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -398,6 +398,13 @@ function processExternalHost( } } + // Run host adapter for semantic transforms (after generic rewrites) + if (hostConfig.adapter) { + const adapterPath = path.resolve(ROOT, hostConfig.adapter); + const adapter = require(adapterPath); + result = adapter.transform(result, hostConfig); + } + // Config-driven: generate metadata (e.g., openai.yaml for Codex) if (hostConfig.generation.generateMetadata && !symlinkLoop) { const agentsDir = path.join(outputDir, 'agents'); diff --git a/scripts/host-adapters/hermes-adapter.ts b/scripts/host-adapters/hermes-adapter.ts new file mode 100644 index 0000000000..8e9c0314f0 --- /dev/null +++ b/scripts/host-adapters/hermes-adapter.ts @@ -0,0 +1,63 @@ +/** + * Hermes host adapter — post-processing content transformer. + * + * Runs AFTER generic frontmatter/path/tool rewrites from the config system. + * Handles semantic transformations that string-replace can't cover: + * + * 1. AskUserQuestion → clarify (Hermes built-in tool) + * 2. Agent spawning → delegate_task patterns + * 3. Browse binary patterns ($B → terminal tool) + * 4. Learnings binary calls → memory tool + * 5. skill_manage hint footer + * 6. SOUL.md awareness + * + * Interface: transform(content, config) → transformed content + */ + +import type { HostConfig } from '../host-config'; + +/** + * Transform generated SKILL.md content for Hermes compatibility. + * Called after all generic rewrites (paths, tools, frontmatter) have been applied. + */ +export function transform(content: string, _config: HostConfig): string { + let result = content; + + // 1. AskUserQuestion references → clarify + result = result.replaceAll('AskUserQuestion', 'clarify'); + result = result.replaceAll('Use AskUserQuestion', 'Use clarify'); + result = result.replaceAll('use AskUserQuestion', 'use clarify'); + + // 2. Agent tool references → delegate_task (catch remaining patterns) + result = result.replaceAll('the Agent tool', 'delegate_task'); + result = result.replaceAll('Agent tool', 'delegate_task'); + result = result.replaceAll('subagent_type', 'task description'); + + // 3. Browse binary patterns → terminal tool invocation + result = result.replaceAll('`$B ', '`terminal $B '); + + // 4. Learnings binary calls → memory tool + result = result.replace( + /~\/\.hermes\/skills\/gstack\/bin\/gstack-learnings-log\s+'([^']+)'/g, + 'Use the memory tool to save: $1', + ); + result = result.replace( + /~\/\.hermes\/skills\/gstack\/bin\/gstack-learnings-search/g, + 'Use the memory tool to search for relevant learnings', + ); + + // 5. SOUL.md awareness — inject note when persona/voice config is referenced + if (result.includes('persona') || result.includes('voice configuration')) { + result = result.replace( + /^(# .+)$/m, + '$1\n\n> Voice and persona are configured via SOUL.md (~/.hermes/SOUL.md).', + ); + } + + // 6. skill_manage hint — add footer to generated skills + if (!result.includes('skill_manage')) { + result = result.trimEnd() + '\n\n---\n\n> If you find outdated steps in this skill, use skill_manage(action=\'patch\') to fix them.\n'; + } + + return result; +} diff --git a/test/host-config.test.ts b/test/host-config.test.ts index 296b96f59f..6af6add2ff 100644 --- a/test/host-config.test.ts +++ b/test/host-config.test.ts @@ -22,6 +22,7 @@ import { slate, cursor, openclaw, + hermes, } from '../hosts/index'; import { HOST_PATHS } from '../scripts/resolvers/types'; @@ -30,8 +31,8 @@ const ROOT = path.resolve(import.meta.dir, '..'); // ─── hosts/index.ts ───────────────────────────────────────── describe('hosts/index.ts', () => { - test('ALL_HOST_CONFIGS has 8 hosts', () => { - expect(ALL_HOST_CONFIGS.length).toBe(8); + test('ALL_HOST_CONFIGS has 9 hosts', () => { + expect(ALL_HOST_CONFIGS.length).toBe(9); }); test('ALL_HOST_NAMES matches config names', () => { @@ -493,12 +494,44 @@ describe('host config correctness', () => { expect(openclaw.generation.includeSkills!.length).toBe(0); }); + test('hermes has tool rewrites for terminal/read_file/write_file', () => { + expect(hermes.toolRewrites).toBeDefined(); + expect(hermes.toolRewrites!['use the Bash tool']).toBe('use the terminal tool'); + expect(hermes.toolRewrites!['use the Read tool']).toBe('use the read_file tool'); + expect(hermes.toolRewrites!['use the Edit tool']).toBe('use the patch tool'); + }); + + test('hermes has CLAUDE.md→HERMES.md path rewrite', () => { + expect(hermes.pathRewrites.some(r => r.from === 'CLAUDE.md' && r.to === 'HERMES.md')).toBe(true); + }); + + test('hermes has adapter path', () => { + expect(hermes.adapter).toBeDefined(); + expect(hermes.adapter).toContain('hermes-adapter'); + }); + + test('hermes has description limit for agentskills.io', () => { + expect(hermes.frontmatter.descriptionLimit).toBe(1024); + expect(hermes.frontmatter.descriptionLimitBehavior).toBe('error'); + }); + + test('hermes has agentskills.io version field', () => { + expect(hermes.frontmatter.extraFields).toBeDefined(); + expect(hermes.frontmatter.extraFields!.version).toBeDefined(); + }); + + test('hermes includeSkills is empty (native skills separate from generated)', () => { + expect(hermes.generation.includeSkills).toBeDefined(); + expect(hermes.generation.includeSkills!.length).toBe(0); + }); + test('every host has coAuthorTrailer or undefined', () => { - // Claude, Codex, Factory, OpenClaw have explicit trailers + // Claude, Codex, Factory, OpenClaw, Hermes have explicit trailers expect(claude.coAuthorTrailer).toContain('Claude'); expect(codex.coAuthorTrailer).toContain('Codex'); expect(factory.coAuthorTrailer).toContain('Factory'); expect(openclaw.coAuthorTrailer).toContain('OpenClaw'); + expect(hermes.coAuthorTrailer).toContain('Hermes'); }); test('every external host skips the codex skill', () => {