You cannot change what loads, only record it
You set up rules files hoping Claude reads them. Each time a CLAUDE.md or a file under .claude/rules/ actually enters Claude’s context, InstructionsLoaded fires. That covers the eager loads at startup and the lazy ones later, such as a nested CLAUDE.md picked up when the working directory moves into a subproject. The hook runs asynchronously, meaning Claude Code does not wait for it. Whatever your hook does, the file still loads.
Five reasons a file loads
The matcher filters on load_reason: session_start, nested_traversal, path_glob_match, include, or compact. A matcher of compact fires only for the reload after context compaction, not for the startup load of the same file. The payload carries file_path, memory_type, and load_reason, and adds globs, trigger_file_path, and parent_file_path when the reason supplies them.
Every exit code is ignored, and so is every JSON field
Nothing you return changes the load. Exit 0 records it. Exit 1 and other codes cannot affect it. Exit 2 is ignored outright. On every code, systemMessage, continue, and every other JSON output field is discarded. The hooks reference does not say where stderr from a failing hook goes on this event.
That leaves one use. A hook here can write a record of which instruction files loaded, and why, to disk. It cannot change what Claude reads.
You want Claude to know when a nested rules file has loaded, so the hook returns {"hookSpecificOutput": {"additionalContext": "rules from packages/api are in effect"}}. Claude never sees it. The field is discarded with every other JSON output field, and nothing reports the discard. If the hook also wrote a log line to disk, that line is the only evidence it ran.
Keep the log. For the message Claude should read, put it in the rules file itself. That file is what loads into context.
Module 03 names this event where it covers how rule files load, and module 09 lists it in the inventory.