[BUG] hookify: `event: stop` / `event: prompt` simple-pattern rules never fire (re: #32153, two fix PRs open since March)
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
A hookify rule using the documented simple pattern: form with event: stop (or event: prompt) silently never matches. No error, no warning — the rule loads, parses, reports as enabled via /hookify:list, and does nothing.
This is #32153, still present. That issue was auto-closed as not_planned for inactivity on 2026-04-07 and locked on 2026-04-14, so I can't comment there — filing fresh per the stale-bot's own instruction to reference the original. Two PRs fixing it, #32751 (2026-03-10) and #42807 (2026-04-03), are both still open and unmerged.
Root cause. core/config_loader.py maps the legacy pattern field to a condition field by event:
event = frontmatter.get('event', 'all')
if event == 'bash':
field = 'command'
elif event == 'file':
field = 'new_text'
else:
field = 'content' # <-- stop and prompt land here
core/rule_engine.py::_extract_field() has no content handler for Stop payloads — it handles reason and transcript — so it returns None, and _check_condition() bails at the if field_value is None: return False guard. The rule can never match.
Why this survived since March: no shipped example can surface it. The four files in examples/:
| file | event | enabled | form |
|---|---|---|---|
| console-log-warning.local.md | file | true | pattern: |
| dangerous-rm.local.md | bash | true | pattern: |
| sensitive-files-warning.local.md | file | true | conditions: |
| require-tests-stop.local.md | stop | false | conditions: |
The only two examples using the simple pattern: form target event: file and event: bash — the two mappings that work. The only event: stop example ships disabled and is written in the conditions: form, so even enabling it would not hit the broken path. Every fixture passes while the documented shape is broken.
The broken shape is what the plugin's own docs teach:
plugins/hookify/commands/help.mdlines 104-113 — "Require tests before stopping" example,event: stop+pattern: .*plugins/hookify/skills/writing-rules/SKILL.mdlines 186-196 — "stop Events" section, same shapeplugins/hookify/README.mdline 126 — "Event Types" lists `- **stop**: Triggers when Claude wants to stop (for completion checks)` as supported, without noting the simple form doesn't work for it
As noted in #32153's first comment, anyone relying on stop or prompt rules as an enforcement layer has been running without that protection and had no way to know.
What Should Happen?
A rule declaring event: stop with a simple pattern: should evaluate that pattern against the Stop payload and fire — matching, and blocking when action: block is set — exactly as event: bash and event: file rules do with the same syntax.
Concretely: pattern: .* on event: stop should match every stop event. Instead evaluate_rules() returns {}.
Failing that, it should at minimum surface a diagnostic. Today the rule parses cleanly, reports as enabled via /hookify:list, and silently does nothing — which is the worst outcome for a feature people use as a safety gate.
Both open PRs implement the same four-line fix in Rule.from_dict():
elif event == 'stop':
field = 'reason'
elif event == 'prompt':
field = 'user_prompt'
Those two field names are already handled correctly by _extract_field(). #42807 additionally adds regression tests covering all simple event mappings. Either PR would close this.
Error Messages/Logs
There are none, and that is the defect.
evaluate_rules() returns an empty dict:
{}
No exception is raised, nothing is written to stderr, no systemMessage is emitted, and /hookify:list continues to report the rule as enabled. A user has no signal that their stop-gate is inert.
Steps to Reproduce
Matched pair: two rules identical in event, action, and pattern semantics, differing only in the condition field each resolves to. Anything that differs between the two outcomes is therefore attributable to the field mapping and nothing else.
- Save this as
probe.py, adjusting the path to your installed hookify:
import sys
HOOKIFY = r"<your>\.claude\plugins\marketplaces\claude-plugins-official\plugins\hookify"
sys.path.insert(0, HOOKIFY)
from core.config_loader import Rule, extract_frontmatter
from core.rule_engine import RuleEngine
CASE_A = """---
name: matched-pair-simple
enabled: true
event: stop
action: block
pattern: .*
---
Case A body.
"""
CASE_B = """---
name: matched-pair-explicit
enabled: true
event: stop
action: block
conditions:
- field: reason
operator: regex_match
pattern: .*
---
Case B body.
"""
# 'reason' is populated, so neither case can fail merely for want of a value.
STOP_INPUT = {"hook_event_name": "Stop", "reason": "task completed"}
engine = RuleEngine()
for label, text in (("A simple `pattern:` (as documented)", CASE_A),
("B explicit `conditions:` field: reason", CASE_B)):
fm, msg = extract_frontmatter(text)
rule = Rule.from_dict(fm, msg)
out = engine.evaluate_rules([rule], STOP_INPUT)
print(f"--- {label} ---")
print(f" action declared : {fm.get('action')!r} (held constant)")
print(f" resolved field : {[c.field for c in rule.conditions]} <-- the ONLY variable")
print(f" engine result : {out}")
print(f" matched? : {bool(out)}\n")
- Run it:
python probe.py
- Observed output:
--- A simple `pattern:` (as documented) ---
action declared : 'block' (held constant)
resolved field : ['content'] <-- the ONLY variable
engine result : {}
matched? : False
--- B explicit `conditions:` field: reason ---
action declared : 'block' (held constant)
resolved field : ['reason'] <-- the ONLY variable
engine result : {'decision': 'block', 'reason': '**[matched-pair-explicit]**\nCase B body.',
'systemMessage': '**[matched-pair-explicit]**\nCase B body.'}
matched? : True
Case A — the shape the docs teach — returns an empty dict. Case B blocks correctly.
Notes on method, so the result is not over-read:
- This drives
RuleEnginedirectly rather than going throughload_rules(), so theenabled:flag and the event filter are not exercised here — only the field mapping inRule.from_dict(). - Direct import also keeps the result independent of plugin-discovery problems, which have their own open issues (e.g. #81448).
- The output above was produced against the marketplace-installed copy, not a repo checkout, so this is what ships to users. It reproduces identically against
plugins/hookifyat7ef6eec. The two trees differ only in the import root, on two lines ofcore/rule_engine.py(from core.config_loader ...installed vsfrom hookify.core.config_loader ...in-repo — line 10, and the same substitution in the__main__block at line 278). No logic differs.
Claude Model
None
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.220 (Claude Code)
Platform
Anthropic API
Operating System
Windows
Terminal/Shell
Other
Additional Information
Two disclosures, so the preflight checkboxes aren't read as stronger than they are:
- This has been reported before — as #32153. I have not ticked "hasn't been reported" lightly: #32153 is closed (
not_planned, 2026-04-07) and locked (2026-04-14), so it cannot be commented on or reopened by a non-maintainer, and its own closing bot instructed filing a new issue that references it. That is what this is. If you would rather reopen #32153 and close this, that is entirely reasonable and I'd prefer it.
- I am on 2.1.220; npm latest is 2.1.221 at time of filing. I have not re-verified on .221. This defect is in the bundled
hookifyplugin'sconfig_loader.py, which is versioned independently of Claude Code and has noversionfield in its installed manifest, so the Claude Code version is likely orthogonal — but I'm flagging it rather than implying I tested on the newest release.
Plugin version: hookify as installed from the official marketplace. Its .claude-plugin/plugin.json carries no version field (only name, description, author), so I can't pin an installed version number. The in-repo manifest at 7ef6eec declares "version": "0.1.0".
Prior art, all still open and unmerged:
- #32153 — the original bug report (closed as
not_planned, then locked) - #32751 — fix, opened 2026-03-10
- #42807 — same fix plus regression tests, opened 2026-04-03
Environment note: reproduced on Windows 11, Python 3.13.14, driving RuleEngine directly rather than through a live hook invocation — see the method notes under Steps to Reproduce for exactly what that does and does not exercise.
3 Comments
Correction to the Environment section, plus re-verification against current
main.1. Version correction. I reported
2.1.220 (Claude Code). That was wrong — it's the version of a second Claude Code binary on my PATH, not the one that produced these results. This session ran under the Claude Desktop app's bundled copy:So the correct value is 2.1.219, and I was two releases behind npm's
2.1.221at filing, not one. Apologies for the noise — flagging it rather than leaving a wrong number in the report.2. Still present on
mainas of today. I've since re-checked against a fresh sparse clone atdd79613(2026-08-04), rather than the7ef6eecthe original report referenced. The mapping is unchanged:diffbetween my marketplace-installed copy and upstreammain:core/config_loader.py— identical, 9,690 bytes both sides.core/rule_engine.py— differs only on the two import lines noted in the report (10,711 installed vs 10,727 upstream, the 16-byte delta being two occurrences of thehookify.prefix). No logic differs.3. Documentation citations re-confirmed against
dd79613, not just against my installed copy:commands/help.mdlines 104-113 — therequire-testsexample,event: stop+pattern: .*, unchangedskills/writing-rules/SKILL.mdlines 186-196 — "stop Events" section, same shape, unchangedREADME.mdline 126 (heading at 122) — `- **stop**: Triggers when Claude wants to stop (for completion checks)`4. The examples table in the report also still holds on
main:Still no fixture that exercises
pattern:+event: stop, which remains the best explanation for why this has survived since March.End-to-end reproduction through the real hook path — this removes the method caveat in the original report.
The repro above drove
RuleEnginedirectly, and I flagged thatload_rules(), theenabled:flag and the event filter were therefore not exercised. I've now run the same matched pair through the shipping path instead: a real rule file written to.claude/hookify.*.local.md, andhooks/stop.pyinvoked as a subprocess with a real Stop payload on stdin.Setup — identical rules except the condition field each resolves to, both
event: stop, bothaction: block:.claude/hookify.e2e.local.md(documented simple form):Control (explicit
conditions:), same file path, run in a separate temp dir:Invocation (per case,
cwd= the temp dir soload_rules()'s relative.claude/glob resolves,CLAUDE_PLUGIN_ROOTset to the installed plugin):Result:
The documented form returns an empty dict and exit 0 through the full path — rule discovered,
enabled: truehonoured, event filter passed, and it still never matches. The control rule, differing only in resolving tofield: reasoninstead offield: content, blocks correctly.So the defect is not an artifact of direct import. It reproduces exactly as reported when the plugin is exercised the way a user actually hits it, which also confirms
load_rules()and the event filter are not masking or contributing to it — the fault is isolated to the field mapping inRule.from_dict().One incidental note from this run: the rule file must be named
hookify.<name>.local.mdforload_rules()'s glob to find it. The shippedexamples/*.local.mdfiles lack thathookify.prefix, so copying an example into.claude/verbatim produces a rule that is never loaded at all — silently, with no error. That is a separate issue and appears already reported (#79143 / #79148 / #79636), but it compounds this one: both failure modes are silent, so a user debugging a non-firing stop rule gets no signal from either.Confirmed / reproduced with the current hookify from claude-plugins-official (installed via Claude Code 2.1.234, Linux), driving the plugin's hook scripts directly with Stop / UserPromptSubmit payloads:
.claude/hookify.x.local.mdwithevent: stop,action: block,pattern: .*(the exact shape/hookify:helpshows for "require-tests") → the Stop hook prints{}; nothing blocks.conditions: [{field: reason, operator: regex_match, pattern: .*}]→{"decision": "block", ...}as expected.event: prompt+pattern: deployagainst a prompt containing "deploy" →{}as well.event: bash+pattern: rm -rffires normally, so only the simplepattern:form onstop/promptevents is inert, silently.🤖 Generated with Claude Code
---
_Generated by Claude Code_