Skip to content

Skills and hooks for Codex, OpenCode and GitHub Copilot CLI - #586

Merged
zzet merged 22 commits into
mainfrom
feat/agent-skills-hooks-parity
Aug 15, 2026
Merged

Skills and hooks for Codex, OpenCode and GitHub Copilot CLI#586
zzet merged 22 commits into
mainfrom
feat/agent-skills-hooks-parity

Conversation

@zzet

@zzet zzet commented Aug 15, 2026

Copy link
Copy Markdown
Owner

A community user runs Claude Code, Codex, OpenCode and GitHub Copilot CLI, and gets the full Gortex integration in exactly one of them. This closes that.

Each host turned out to need something different, and two are more capable than the gap suggests:

Host Before After
codex MCP + hooks + rule block, 0 skills 21 curated skills, per-community skills, 2 sub-agents
opencode MCP + routing block only 21 skills, 20 commands, and an enforcement bridge
copilot-cli no adapter at all new adapter: MCP, instructions, skills, sub-agents, hooks

The existing vscode adapter targets Copilot inside VS Code and never probes the copilot binary, so the standalone CLI had no adapter. It is now the twentieth.

Vendor facts that shape the code

Each of these is a place where the obvious implementation ships something that tests green and loads nothing:

  • Codex does not read ~/.codex/skills. That tree is configuration. Skills live in .agents/skills, $HOME/.agents/skills and /etc/codex/skills. A negative test asserts we never write to the ignored paths.
  • ~/.agents/skills is a shared cross-agent root that Codex and OpenCode both scan, so one write serves both. Copilot CLI dropped it in v1.0.66 and needs ~/.copilot/skills; it also stopped reading home-level ~/.claude/* in v1.0.36.
  • OpenCode rejects a skill whose frontmatter name differs from its directory. The name is taken from the same value used as the directory, so the rule is unrepresentable to violate.
  • Copilot's userPromptSubmitted accepts only modifiedPrompt. Reusing Claude's additionalContext lowering compiles, passes a shape test, and discards the whole payload.
  • Copilot hook entries carry separate bash and powershell commands, and powershell is a first-class tool name. A POSIX-only entry leaves the entire Windows surface unenforced.
  • Copilot's agentStop has no context channel, so there is no turn-end briefing on that host — recorded as a stop-line rather than faked.
  • Copilot discovers a repo .mcp.json ahead of its user config. Gortex already writes one, without the required type and with a shell-style env value Copilot does not expand, so a stale entry shadowed the new adapter. It is reconciled in place.
  • OpenCode has no hook system at all. Its only extension point is a JS plugin, so it gets one that speaks the same bridge protocol the Pi extension uses.
  • A Codex sub-agent file is rejected whole on one unknown key (deny_unknown_fields), and its mcp_servers field is a table of full server definitions, not a list of names — the intuitive mcp_servers = ["gortex"] fails to deserialize and voids the entire agent. Four keys are emitted and mcp_servers is omitted, which is documented to inherit the session's servers. Codex has no per-agent allowlist, so there was nothing to gain by emitting it.

Deliberate calls worth reviewing

  • Curated skills are user-level; only per-community skills go in the repo. This matches what claudecode already does, so gortex init never drops 21 host-agnostic markdown files into a working tree for teammates to review.
  • The OpenCode bridge installs to ~/.config/opencode/plugin/gortex.js, not .opencode/. It is the only executable artifact Gortex writes and the repo-level directory is committed — a repo install would push a file that shells out on every tool call onto everyone who clones. It is gated on InstallHooks, so --no-hooks turns it off, and uninstall removes it.
  • Copilot artifacts default to ~/.copilot, not .github/. .github/hooks/ and .github/skills/ are committed and would run for every teammate.
  • skillpack imports no adapter package. A later skillpack.Sync had to be callable from claudecode, so an edge the other way is a cycle. The rule is stated in the package doc.
  • Only 5 of Copilot's 14 hook events are registered. The other nine map to nothing Gortex emits, and each one costs a subprocess spawn per occurrence.

Bugs found and fixed on the way

  • The community-routing block that 13 adapters already receive was double-fenced, and advertised /gortex-<label> slash commands that exist on no host but Claude Code.
  • uninstall --global was gated on Claude Code having artifacts, so a machine with only OpenCode configured was told there was nothing to uninstall.
  • The render fence rendered neither user-scope artifacts, nor hook stanzas, nor generated skills — every artifact this branch adds would have shipped unpinned. Turning user-scope rendering on first required closing two escapes where go test ./cmd/gortex would have written to the developer's real home; a guard now fails the test if a render touches it, and it was verified to fail when the pin is removed.
  • The codex hook command interpolates GORTEX_CODEX_HOOK_MODE at render time, so the golden encoded whichever posture the regenerating developer had exported.
  • hookEffectivenessEvents is a PascalCase allowlist that silently drops anything else, which would have made doctor report "configured but never ran" forever on a working Copilot install.
  • The Pi bridge core never wrote telemetry at all. The shared core now does, so Pi gets it too.
  • gortex instructions switch reshaped only Claude Code's skills. Hermes had 20 skills that never shrank, and the three new hosts would have inherited the same gap.
  • Adapter and skill counts in docs/agents.md, docs/skills.md and README.md were all stale; a guard now derives them from buildRegistry().
  • Dead agents.AppendInstructions removed — it could not update a block in place and wrote non-atomically, and a new adapter author would have copied it.

Verification

go build ./..., go vet ./..., golangci-lint, gofmt — clean.
go test ./... — 184 packages, zero failures, exit 0.
go test -race on every touched package — clean.

Removal is by evidence, never by path: the skill trees are shared with the user's own skills, so an entry is deleted only when its bytes still match what we shipped. A customised copy is kept and reported, on install, on profile switch and on uninstall.

Not done, and why

  • No manual verification against the real binaries. Several of these surfaces can pass every unit test while loading nothing, because the failure mode is a path or a key the host silently ignores. Each needs one run against the real CLI before this is trusted in the wild: Codex /skills listing the pack, Copilot denying a grep with a reason that names the Gortex tool, OpenCode blocking the same grep with the bridge installed.

    For Codex there is a real oracle worth using: codex doctor reports a rejected agent file as a startup warning mentioning agent role, while the exit code stays 0 and everything else loads. codex doctor 2>&1 | grep -i "agent role" should print nothing.

  • OpenCode sub-agents. Their tools map is keyed by tool name and the spelling for an MCP-served tool is unverified; a wrong key silently grants nothing.

  • Repo-level AGENTS.md / CLAUDE.md routing blocks are still not stripped by uninstall — pre-existing, and it affects every adapter, so it does not belong in this branch.

zzet added 22 commits August 15, 2026 12:23
GenerateRouting emitted its own gortex:skills marker pair inside a
payload every adapter re-wraps with the communities markers, so the
block landed nested. It also advertised /gortex-<label> slash commands
that exist only on Claude Code, telling every other host to invoke
something it has no way to resolve, and named legacy one-tool-per-
operation tools the public facade no longer exposes.

The routing table now points at analyze(operation:"communities") and
the skill bodies name only facade tools.
AppendInstructions had no call sites left — every adapter moved to
UpsertMarkedBlock. Keeping it invited a new adapter author to copy a
pattern that cannot update a block in place (it detects the sentinel
and skips, so a changed body never reaches a file that already has
one) and writes non-atomically.
audit_agent_config probed only the single-file rules surfaces, so
path-scoped Copilot instructions and every skill tree were invisible
to a drift audit — including the trees Gortex itself writes.
The standalone `copilot` binary had no adapter — the vscode adapter
targets Copilot inside VS Code and never probes it. This registers
gortex as an MCP server in ~/.copilot/mcp-config.json, writes the rule
block into ~/.copilot/copilot-instructions.md, and shares the repo
communities block with the vscode adapter.

Two Copilot-specific facts the shared helpers do not cover: the MCP map
is keyed "mcpServers" rather than VS Code's "servers", and each entry
needs a "type" transport discriminator, which DefaultGortexMCPEntry
omits because VS Code infers stdio.

Copilot also discovers a repo .mcp.json ahead of the user config, so a
gortex-authored entry there is reconciled in place — it otherwise
shadows this adapter with an entry that has no type and a shell-style
env value Copilot does not expand.

The wizard now fails a test when a registered adapter has no label:
without one the slug is displayed but silently dropped from the user's
picks.
The 21 skill bodies are authored as Claude SKILL.md constants, and
hermes was the only adapter reusing them — via private frontmatter
surgery that the next three hosts would each have had to copy.

skillpack parses one of those files into a neutral Skill and re-renders
frontmatter from ordered pairs, so a host contributes only its own
frontmatter shape. The package is stdlib-only and imports no adapter:
a later skillpack.Sync has to be callable from claudecode, so an edge
in the other direction would be a cycle.

Hermes keeps a verbatim copy of the deleted helpers as a test oracle,
which pins all 20 routing skills to byte-identical output.
Three files advertised how many integrations ship and all three had
drifted — nineteen adapters where the registry had twenty, nineteen
slash commands where there are twenty, nineteen skills where there are
twenty-one. A count a reader can disprove costs more than no count.

The new guard derives the total from buildRegistry(), so the next
adapter fails until every page claiming a total is updated.
Copilot CLI's hook events are camelCase and its response fields are
flat and per-event, not Claude's hookSpecificOutput envelope. The one
that bites: userPromptSubmitted accepts only modifiedPrompt, so reusing
Claude's lowering compiles, passes a shape test, and silently discards
every byte of prompt-time context.

Only the five events Gortex produces payloads for are handled; the
other nine return empty at exit 0 rather than paying a subprocess spawn
to say nothing. agentStop is deliberately silent — it carries decision
and reason only, with no channel for a turn-end briefing.

powershell normalises to Bash alongside bash. It is the shell tool on
Windows, and a bash-only table would leave that whole surface
unenforced.

Event names are mapped to Claude's vocabulary before telemetry:
hookEffectivenessEvents is a PascalCase allowlist that silently drops
anything else, which would have made doctor report "configured but
never ran" forever on a working install.
OpenCode has no lifecycle-hook configuration at all — a JS plugin is
its only extension point — so it gets the same treatment Pi already
had: a Gortex-owned event envelope on stdin, a decision on stdout.

Rather than fork handlePi, PiEvent/PiDecision become BridgeEvent/
BridgeDecision (Pi keeps aliases) and both hosts share one core that
folds host event names into three slots.

The core now writes hook-effectiveness records, which the Pi bridge
never did. Without a denominator, doctor reads a working bridge as a
hook that has never fired. Pi gets the fix for free.
…ser scope

renderOne pinned ModeProject with InstallHooks false and no generated
skills, so no adapter's hook stanza, no per-community skill and no
user-level artifact was pinned by CI — precisely the surfaces about to
grow. Manifest keys are now mode-prefixed and each pass gets its own
sandbox.

Turning user-scope rendering on required closing two escapes first.
InstructionsDir fell back to the real ~/.gortex/instructions, which
also made the rendered skill set depend on the developer's active
profile; CLAUDE_CONFIG_DIR let the whole user-scope write leave the
sandbox, silently emptying that half of the manifest. Both are pinned,
and a guard now fails the test if a render touches the real home — it
was verified to fail when the pin is removed.

The codex hook command is deterministic too: it interpolates
GORTEX_CODEX_HOOK_MODE at render time, so the golden used to encode
whichever posture the regenerating developer had exported.
Codex already had MCP, four lifecycle hooks and a rule block; skills
were the gap. The curated pack goes to $HOME/.agents/skills in global
mode and per-community skills to .agents/skills in the repo, matching
claudecode's split so a plain init leaves the working tree clean.

Codex does NOT read ~/.codex/skills — that tree is configuration and
silently ignores a SKILL.md — so a positive-only test suite would pass
over a feature that loads nothing. The negative assertion is a required
test, not a nicety.

Directories are never nested: Codex documents one directory per skill
under .agents/skills and says nothing about recursing, and the
generated DirName is already prefixed, so a grouping level would buy a
silent no-op.

$HOME/.agents/skills is also one of OpenCode's global skill roots, so
this single write serves both hosts. The render is byte-stable and the
writer skips unchanged files, so two adapters reaching the same path in
one install run converge instead of churning.
Curated skills and subagents install to ~/.copilot; per-community
skills to .github/skills in the repo. Home-level ~/.claude loading was
removed in Copilot v1.0.36 and ~/.agents/skills in v1.0.66, so those
paths would be a silent no-op and are asserted against.

Hooks register only the four events Gortex produces payloads for, and
each entry carries both a bash and a powershell command — Copilot hook
entries take separate fields per shell, so a POSIX-only string leaves
Windows users with a hook that never runs.

The camelCase wire names stay confined to the config writer. Inspect
reports Claude's PascalCase vocabulary because doctor tallies runs
against a PascalCase allowlist, and keying it the other way would
report a working install as never having fired.

The subagent tools allowlist is omitted rather than translated: the
Claude spelling is mcp__gortex__*, which Copilot does not resolve, and
an unrecognised entry grants nothing instead of falling back to the
full toolbox.
OpenCode had MCP and a routing block only. It now gets the curated
skills and slash commands at user level, per-community skills in the
repo, and — since it has no lifecycle-hook configuration at all — a JS
plugin that shells the bridge protocol.

The skill name is taken from the same value used as the directory
name, so OpenCode's name-equals-directory rule is unrepresentable to
violate rather than merely tested.

The plugin installs to ~/.config/opencode/plugin/gortex.js, not into
the repo. It is the only executable artifact Gortex writes, and the
repo-level plugin dir is committed, so a repo install would push a
file that shells out on every tool call onto every teammate who clones.
The singular plugin/ spelling is deliberate: that glob alone does not
recurse.

HookEvents omits PostToolUse because tool.execute.after never shells
the bridge — declaring it would spend a subprocess per tool call and
still report a permanent "configured but never ran" blocker.
… exist

Adds the copilot-cli row, records the skill/command/hook paths Codex,
OpenCode and Copilot CLI now receive, and disambiguates copilot-cli
from the vscode adapter and the copilot LLM provider.

The per-host table states the NOs explicitly, because each is a vendor
limit a reader would otherwise file as a Gortex omission: Codex has no
slash-command surface worth targeting, Copilot CLI does not read prompt
files, OpenCode has no hook system at all, and Copilot's agentStop
carries no context channel.

The helper list was three entries out of date and omitted most of what
adapters actually write through, including the marked-block upsert
every instructions surface depends on.
Adds the ~/.copilot sentinel and the copilot-cli expectation, and notes
that the list is hand-maintained so the next adapter is covered only
when both places are updated.
Profile.Skills was honoured only by claudecode, so switching to a
three-skill profile left the full pack installed everywhere else — and
the three hosts that just gained curated packs inherited the same gap.

skillpack.Sync is a faithful port of the claudecode rule, because the
rule is what decides which files get deleted from a user's machine on
an upgrade: prune only when the skill is outside the profile AND its
bytes are still one we shipped. A customised copy is kept with a
warning on every pass, not just the first.

A host with no installed skills root is a no-op rather than an install,
so a switch cannot conjure a Codex or Copilot surface on a machine that
never ran gortex install. The call site stays in cmd/gortex: routing
hosts through claudecode would invert the direction skillpack exists to
protect.
…mpty home

Hermes was the first adapter to reuse the Claude skill bodies and the
last to learn about profiles, so a switch to a three-skill profile left
all twenty routing skills installed — the same machine enforced two
different profiles depending on which agent the user opened.

Hermes files skills under a category directory rather than directly
under the root, so the reconciler takes an optional per-skill directory
resolver. Its native master skill is absent from the routing set, which
is what keeps it out of the subset comparison and alive across every
profile.

Codex's curated install now refuses an unresolved home instead of
joining onto it: the relative path lands the pack in the process's
working directory, which is usually the user's repo — the one place
this pack must never go.
doctorAgentProbes is hand-maintained and covered codex and claude-code
only, so the other hosts reported nothing at all. A guard now scans the
adapter tree for Inspect functions and fails when one has no probe, so
the next host is not silently forgotten.

The probe key has to be the same string the hook writer bakes into
--agent, or the activity lane reads zero forever on a working install;
both sides now share the adapter's Name constant and two tests pin it.

Hook lines used to print only when the host's config file was present.
OpenCode's hook surface is a plugin that lives nowhere near its config,
so a fully wired install rendered as a single complaint about a missing
opencode.json.
Every surface added for Codex, OpenCode and Copilot CLI survived
uninstall, including the OpenCode bridge plugin — which keeps shelling
out to a binary the same command may have just removed.

Deletion is by evidence, never by path. The skill trees are shared with
the user's own skills, so an entry is removed only when its bytes still
match what we shipped, or for generated skills when it carries the
gortex- prefix the generator enforces. A customised copy is kept and
reported. Directory pruning uses os.Remove, which refuses a non-empty
directory.

Preview and removal now read one table, so the wizard cannot promise a
deletion that will not happen. That also fixes a live bug: the --global
branch was gated on Claude Code having artifacts, so a machine with
only OpenCode configured was told there was nothing to uninstall.
.claude/skills/generated survived a repo uninstall. Unlike the shared
skill trees it needs no per-entry ownership check: the generated/ level
exists precisely because nothing but Gortex writes there.
Keys are now mode-prefixed and every adapter renders twice, so the diff
is large but mechanical. What is worth reading: codex and opencode gain
skill trees, copilot-cli arrives, and every adapter's hook stanza and
per-community skills are pinned for the first time.
Pre-existing comment misalignment in a file this branch already
touches; leaving half a file unformatted is worse than the small
unrelated hunk.
Codex has a real delegation surface at ~/.codex/agents/*.toml, so the
two graph-only sub-agents Claude Code gets now install there too.

The file format has two ways to fail that leave nothing behind, both
confirmed against a real Codex install before writing a line:

Codex deserializes an agent file with deny_unknown_fields, so a single
unrecognised key rejects the whole agent — the emitted key set is
therefore exactly name, description, sandbox_mode and
developer_instructions, and a test pins it.

mcp_servers is a table of full server definitions, not a list of server
names. The intuitive mcp_servers = ["gortex"] fails to deserialize and
voids the entire file. It is omitted: omission inherits the session's
servers, and Codex has no allowlist semantic, so there was nothing to
gain by emitting it.

sandbox_mode = "read-only" turns both agents' written promise not to
mutate files into a sandbox guarantee. Instructions use a TOML literal
string so a backslash in a future body cannot become an escape.

User-level only: project-scoped .codex/agents is skipped entirely
unless the project is trusted, and the curated artifacts in this branch
all stay out of the user's working tree.
@zzet
zzet merged commit 4de3e87 into main Aug 15, 2026
12 checks passed
@zzet
zzet deleted the feat/agent-skills-hooks-parity branch August 15, 2026 12:41
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.

1 participant