Skip to content

Wire the Claude Code hooks and let the gate open on Claude Code - #220

Open
w9fyi wants to merge 1 commit into
Community-Access:mainfrom
w9fyi:fix/claude-code-hooks
Open

w9fyi wants to merge 1 commit into
Community-Access:mainfrom
w9fyi:fix/claude-code-hooks

Conversation

@w9fyi

@w9fyi w9fyi commented Oct 15, 2026

Copy link
Copy Markdown

Fixes the hooks part of #219. The MCP server, root CLAUDE.md and repository-relative path items are left for follow-ups, because each needs a decision from you.

What was wrong

From a Claude Code plugin install, the enforcement gate did nothing, and once loaded it could never open:

  • .claude-plugin/plugin.json didn't declare hooks/claude.hooks.json, and Claude Code only discovers hooks/hooks.json by itself.
  • The routers are skills, which Claude Code runs inline through the Skill tool, so SubagentStop never fires for the lead. When a subagent does finish, Claude Code names it agent_type, which mark didn't read.
  • finalize ignored stop_hook_active, so it could keep refusing a stop it had already refused.

What changed

  • .claude-plugin/plugin.json declares "hooks": "./hooks/claude.hooks.json".
  • hooks/claude.hooks.json
    • mark also runs on PostToolUse with matcher Skill. That's the same approach Copilot's postToolUse already takes, and subagentName() already read tool_input.skill.
    • ${CLAUDE_PLUGIN_ROOT} is quoted in every command, which clears the claude plugin validate warnings.
    • The $comment key is removed, because Claude Code reports it as unknown at every session start.
  • hooks/guard.mjs
    • subagentName() reads agent_type.
    • finalize returns 0 when stop_hook_active is set, and emits {decision, reason} rather than PreToolUse output, with the reason also on stderr.
    • The finalize message no longer says a file "was edited". The marker is set when the gate refuses an edit.
    • emitContext labels context with the event that fired, so persist on SessionStart is no longer reported as UserPromptSubmit.
  • .claude/settings.json gets the same PostToolUse entry, so repository development still mirrors the shipped manifest.
  • Docs and changelog:
    • docs/standards/hooks-by-client.md and docs/reference/hooks.md describe the Claude Code mark events and the manifest declaration, with test counts updated.
    • CHANGELOG.md has an Unreleased entry.

Design note

guard.mjs opens the gate when the lead completes, not when it launches. An inline skill has no completion event in Claude Code. PostToolUse for Skill fires when the skill's instructions load. That's the same trade-off the Copilot manifest already makes. The issue describes an alternative: marking when render-report.mjs runs. I'll switch to it if you prefer.

Tests

hooks/guard.test.mjs goes from 22 to 30 tests. The new tests cover:

  • agent_type on SubagentStop
  • a Skill tool call opening the gate, and an unrelated skill not opening it
  • the manifest declaring the hooks file
  • the hooks file having no unknown keys
  • mark running on both events
  • the Stop decision shape, its message and stderr
  • stop_hook_active not looping
  • SessionStart context naming its event

Six of the eight new tests fail on main, and so does the extended persist test. The two Skill tool tests pass on main because the script already read tool_input.skill. What was missing was the manifest entry, which the manifest tests catch.

npm run verify reports the same five failing gates on this branch as on main in my environment. All five come from dependencies I couldn't install from my sandbox: skills-ref, the MCP SDK, npm advisories, and verify:live. None of them involve the hooks.

claude plugin validate (2.1.283) passes. Its only remaining warning is the root CLAUDE.md one described in the issue.

Live check

I ran Claude Code 2.1.293 with claude --plugin-dir <this branch> from an empty folder holding a fixture page:

  • /hooks listed the seven plugin hooks.
  • The reminder was injected once.
  • An <h1> edit was refused before review.
  • The Stop hook refused once and the turn then ended.
  • Invoking accessibility-agents:accessibility-lead opened the gate, five specialists reported, and the same edit went through.

The $comment removal and the reworded Stop message came out of that run.

Source verification

  • Claim: plugin hooks default to hooks/hooks.json, and other files are declared with a hooks key, which must wrap its events in "hooks".
    Source: https://code.claude.com/docs/en/plugins/manifest-reference
    Verified on: 2026-10-15
  • Claim: SubagentStop input carries agent_type and stop_hook_active; Stop is blocked with {"decision": "block", "reason"} or exit 2 with stderr; stop_hook_active should be checked to avoid looping; hookSpecificOutput.hookEventName is the event name.
    Source: https://code.claude.com/docs/en/hooks
    Verified on: 2026-10-15

…as a skill

A Claude Code plugin install registered no hooks: .claude-plugin/plugin.json
did not declare hooks/claude.hooks.json, and Claude Code only discovers
hooks/hooks.json by itself. Once declared, the gate could never open, because
the routers are skills that Claude Code runs inline through the Skill tool, so
SubagentStop never fires for the lead, and a finished subagent is named
agent_type, which mark did not read.

- Declare the hooks file in .claude-plugin/plugin.json.
- Run mark on PostToolUse for Skill, as Copilot's postToolUse already does,
  and read agent_type.
- finalize honours stop_hook_active, emits a stop decision with the reason on
  stderr too, and no longer says a file was edited when the gate refused it.
- Context names the event that fired; ${CLAUDE_PLUGIN_ROOT} is quoted; the
  $comment key Claude Code warns about at every session start is removed.

hooks/guard.test.mjs goes from 22 to 30 tests.
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