Skip to content

fix(claude): drop the manifest hooks pointer Claude Code reports as a duplicate; pin every Claude hook_event_name - #470

Merged
ScriptedAlchemy merged 2 commits into
mainfrom
fix/claude-hook-manifest-duplicate
Sep 3, 2026
Merged

fix(claude): drop the manifest hooks pointer Claude Code reports as a duplicate; pin every Claude hook_event_name#470
ScriptedAlchemy merged 2 commits into
mainfrom
fix/claude-hook-manifest-duplicate

Conversation

@ScriptedAlchemy

Copy link
Copy Markdown
Owner

Symptom

The maintainer's real Claude Code session (2.1.257/2.1.259) failed every hook between 2026-09-03 20:47:53Z and 20:49:29Z:

PostToolUse:Bash hook error
Failed with non-blocking status code: Agent Bundle event route error: native hook_event_name must equal postToolUse

(and preToolUse for PreToolUse:Bash, stop for Stop). #450 landed under this lane with the explanation that Claude Code was "discovering hooks/hooks-cursor.json" and invoking the Cursor wrappers, and fixed it by naming ./hooks/hooks.json in .claude-plugin/plugin.json.

Root cause

Two findings, both verified against the installed Claude Code binary rather than inferred:

  1. Claude Code never scans hooks/. Its plugin loader (~/.local/share/claude/versions/2.1.259) reads exactly hooks/hooks.json from the plugin root, then walks manifest.hooks for additional files. hooks/hooks-cursor.json is invisible to it, so fix(plugin): point Claude plugin.json at hooks/hooks.json so Cursor wrappers are not loaded #450's root cause cannot be what happened. The session transcript (~/.claude/projects/-fast-projects-agent-bundle/36af94c3-….jsonl, hook_non_blocking_error attachments) confirms the failing command was the Claude wrapper, node "${CLAUDE_PLUGIN_ROOT}/hooks/event-route-tool-after.mjs", not a .cursor.mjs file. The errors are confined to the window in which the cargo-hauler plugin was being migrated from the composite plugin target to per-host packs and re-registered in the real ~/.claude (marketplace re-added 20:46:02Z, cache written 20:49:25Z); 182 hook successes precede the window and none fail after it. For a directory marketplace Claude Code resolves ${CLAUDE_PLUGIN_ROOT} to the build output directory itself (reproduced below), so the wrapper file that ran mid-rebuild is gone — but only a wrapper compiled for the cursor target bakes const nativeEvent = "postToolUse". The agent-bundle event contract for Claude was and is correct: capabilities/claude-2.1.250.json maps all 19 supported routes to Claude's PascalCase names and planHooks bakes exactly those.

  2. The hooks pointer fix(plugin): point Claude plugin.json at hooks/hooks.json so Cursor wrappers are not loaded #450 added (and the claude target has emitted since b256d44) is itself a Claude Code plugin error. Reproduced with an isolated CLAUDE_CONFIG_DIR/HOME, installing a freshly built Claude pack and starting claude --debug-file:

    [DEBUG] Read hooks.json for plugin cargo-hauler (enabled=true): …/artifact/claude/hooks/hooks.json
    [DEBUG] Skipping duplicate hooks file for plugin cargo-hauler: ./hooks/hooks.json (resolves to already-loaded file: …/artifact/claude/hooks/hooks.json)
    [ERROR] Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file …/artifact/claude/hooks/hooks.json. The standard hooks/hooks.json is loaded automatically, so manifest.hooks should only reference additional hook files.
    [DEBUG] Plugin loading errors: Hook load failed: Duplicate hooks file detected: …
    

    Hooks still register (the auto-loaded copy wins) but the plugin carries a hook-load-failed error on every start under the default strict marketplace entry. The official plugins reference documents hooks as "./my-extra-hooks.json" — extra files only.

Fix

Tests

New tests/claude-hook-event-name.test.ts (unit pool):

  • the table of covered routes equals every state: "supported" route in claude-2.1.250.json (19);
  • for each route, the claude-target wrapper's nativeEvent, its baked const nativeEvent = "…", and the hooks/hooks.json key are the pinned PascalCase name, and a real Claude envelope (15 from fixtures/events/claude-*.json, plus inline SessionStart/Stop/PreToolUse/PostToolUse) passes validateNativeEventEnvelope under that wrapper's validation;
  • regression for the exact failure: the live PostToolUse:Bash envelope is accepted by the Claude wrapper and rejected with exactly Agent Bundle event route error: native hook_event_name must equal postToolUse only under a Cursor-baked validation (and preToolUse for PreToolUse);
  • the unified plugin bundle keeps .mjs (PostToolUse) and .cursor.mjs (postToolUse) apart and its two hook documents keyed by their own host spellings;
  • neither the claude nor the plugin target puts hooks on the Claude manifest; Cursor's manifest still names ./hooks/hooks-cursor.json.

Adjusted: plugin-bundle.test.ts, hooks.test.ts (Claude manifest has no hooks), removed plugin-claude-hook-manifest.test.ts (superseded).

Evidence

  • pnpm typecheck — exit 0 (after pnpm build).
  • pnpm lint — 0 errors, 0 warnings.
  • pnpm test:unit — 3030 passed, 0 failed, 5 skipped.
  • rstest --config rstest.integration.config.ts plugin-bundle.test.ts hooks.test.ts target-hook-contract.test.ts — 58 passed, 0 failed.
  • pnpm docs:site:build — build, dead-link, and language-parity checks pass.
  • Isolated Claude Code 2.1.259 run against a built Claude pack: hooks/hooks.json loaded from the marketplace directory; 4 hooks registered; Duplicate hooks file detected recorded for the manifest pointer (the condition this PR removes).

Follow-up (not in this PR)

The maintainer's installed cargo-hauler pack was rebuilt against agent-bundle 886b192 and is correct today (nativeEvent = "PostToolUse" baked in artifact/claude/hooks/*.mjs); it will stop reporting the duplicate-hooks error once rebuilt on a release containing this change.

…k_event_name

Claude Code loads `hooks/hooks.json` on its own; `manifest.hooks` is only
for additional documents. The `claude` target has named `./hooks/hooks.json`
there since b256d44 and #450 added the same pointer to the unified
`plugin` bundle. Claude Code 2.1.259 records a `hook-load-failed` plugin
error for it: "Duplicate hooks file detected: ./hooks/hooks.json resolves
to already-loaded file ... The standard hooks/hooks.json is loaded
automatically, so manifest.hooks should only reference additional hook
files" (reproduced with an isolated CLAUDE_CONFIG_DIR against a built
Claude pack).

#450's stated root cause does not hold: Claude Code never scans `hooks/`
for other documents, so `hooks/hooks-cursor.json` is invisible to it. The
live "native hook_event_name must equal postToolUse" errors came from the
Claude wrapper path (`event-route-tool-after.mjs`, per the session
transcript) during a rebuild/reinstall window of a directory marketplace,
whose `${CLAUDE_PLUGIN_ROOT}` is the build output itself; only a
Cursor-built wrapper bakes that camelCase constant.

- claude.ts / plugin.ts: emit no `hooks` field on `.claude-plugin/plugin.json`.
- tests/claude-hook-event-name.test.ts: for every supported Claude event
  route, the planned wrapper bakes the pinned PascalCase name, the hooks
  document is keyed by it, and a real Claude envelope passes
  validateNativeEventEnvelope; the live PostToolUse:Bash envelope is
  accepted by the Claude wrapper and rejected with the exact observed
  message only under a Cursor-baked validation; the unified bundle keeps
  `.mjs`/`.cursor.mjs` spellings apart; no Claude manifest pointer.
- docs (en+zh hooks.mdx, installation.mdx) state the loader behavior.
@changeset-bot

changeset-bot Bot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fd94af0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agent-bundle Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Sep 3, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@470
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@470
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@470

commit: fd94af0

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-03T23:06:23.189960Z 4099c39 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@ScriptedAlchemy
ScriptedAlchemy merged commit 5775351 into main Sep 3, 2026
13 checks passed
@ScriptedAlchemy
ScriptedAlchemy deleted the fix/claude-hook-manifest-duplicate branch September 3, 2026 23:21
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
…stall (AB7325, AB7006); reject the auto-loaded hooks path in the Claude manifest schema (#479)

* fix(install): surface Claude plugin list --json errors in doctor and install; reject the auto-loaded hooks path in the manifest schema

Claude Code lists a plugin it refused to load with an `errors` array on its
`plugin list --json` row (the row stays `enabled: true`; healthy rows omit the
key). `doctor` and `install` read only id/installPath/scope/version from that
row, so a refused plugin — the cargo-hauler#48 case, where the manifest `hooks`
pointer at the auto-loaded `hooks/hooks.json` made Claude Code drop every
hook, MCP server, and skill — was reported `installed` / `current` (#464).

- install.ts: `claudePluginRowErrors` reads the array into
  `PublicHostInstalledEntry.errors`; `installBundle` throws `AB7006` when the
  byte-identical existing copy carries errors (reinstalling cannot help) and
  re-reads the listing after `claude plugin install` (which exits 0 for a
  plugin Claude Code then refuses) to fail the same way.
- doctor.ts: inventory entries with errors are `failed`; the `--from`
  comparison is `load-failed` (`AB7325`, error) instead of `current`/`stale`;
  the `--plugin-dir` registration proof is `failed` with the host's text.
- cli.ts: text report prints the `load-failed` comparison.
- schemas/claude/plugin.schema.json: `hooks` admits the documented
  additional-hook-file forms (`./` path, array, inline object) and rejects the
  literal `./hooks/hooks.json`; PROVENANCE and the Claude capability table
  record the evidence (refused on 2.1.250, 2.1.251, 2.1.257, 2.1.259; accepted
  by `plugin validate --strict`). Claude adapterRevision 1.25.0.
- tests: doctor/install fixtures use the verbatim 2.1.259 row shape;
  claude-plugin-validate-acceptance.test.ts emits the cargo-hauler shape and,
  when `claude` is on PATH, runs `plugin validate --strict --json` and
  `--plugin-dir plugin list --json` under an isolated CLAUDE_CONFIG_DIR.
- docs: diagnostics.md (`AB7006`, `AB7325`, comparison matrix), en/zh cli,
  installation, and validation pages.

The adapter half (#462/#463) landed in #470 (5775351).

* chore(changeset): reference #479

* fix(review): gate the Claude acceptance test in CI, version-gate validate --json, reject alias paths in the schema

- package.json: `test:host-install` (the CI job with the pinned Claude CLI on
  PATH) now runs claude-plugin-validate-acceptance.test.ts, so the real-host
  load verdict gates CI instead of skipping in the Verify pool.
- The acceptance test reads `claude --version` and uses `plugin validate
  --strict --json` only from 2.1.259; the pinned 2.1.250 rejects the flag, so
  older binaries run the textual `--strict` form and are held to its exit code
  and "Validation passed" line. Verified against 2.1.250, 2.1.259, and no
  binary (skips with the missing-evidence title).
- plugin.schema.json `additionalHooksPath`: the pattern admits only
  normalized `./`-prefixed paths (no `.`, `..`, or empty segments), so aliases
  of the auto-loaded file such as `./hooks/./hooks.json` or
  `./hooks/../hooks/hooks.json` fail the segment rule rather than slipping past
  the literal exclusion; PROVENANCE/metadata pins re-hashed, tests cover the
  aliases.
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