[BUG] hookify: `event: stop` / `event: prompt` simple-pattern rules never fire (re: #32153, two fix PRs open since March)

Status Open
Reported on v2.1.220
Maintainer reply ✓ Yes — claude[bot]
Activity 3 comments · opened Aug 4, 2026
💡 Likely answer: A maintainer (claude[bot], contributor) responded on this thread — see the highlighted reply below.

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.md lines 104-113 — "Require tests before stopping" example, event: stop + pattern: .*
  • plugins/hookify/skills/writing-rules/SKILL.md lines 186-196 — "stop Events" section, same shape
  • plugins/hookify/README.md line 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.

  1. 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")
  1. Run it: python probe.py
  1. 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 RuleEngine directly rather than going through load_rules(), so the enabled: flag and the event filter are not exercised here — only the field mapping in Rule.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/hookify at 7ef6eec. The two trees differ only in the import root, on two lines of core/rule_engine.py (from core.config_loader ... installed vs from 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:

  1. 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.
  1. 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 hookify plugin's config_loader.py, which is versioned independently of Claude Code and has no version field 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.

View original on GitHub ↗

3 Comments

qasimsethi1-svg · 26 days ago

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:

.claude/sessions/<pid>.json  ->  "version":"2.1.219", "entrypoint":"claude-desktop"
AppData/Roaming/Claude/claude-code/  ->  2.1.219
.local/bin/claude.exe --version      ->  2.1.220   (a separate install, not what ran)

So the correct value is 2.1.219, and I was two releases behind npm's 2.1.221 at filing, not one. Apologies for the noise — flagging it rather than leaving a wrong number in the report.

2. Still present on main as of today. I've since re-checked against a fresh sparse clone at dd79613 (2026-08-04), rather than the 7ef6eec the original report referenced. The mapping is unchanged:

plugins/hookify/core/config_loader.py
  62:  if event == 'bash':
  63:      field = 'command'
  64:  elif event == 'file':
  65:      field = 'new_text'
  66:  else:
  67:      field = 'content'          <-- stop and prompt still land here

diff between my marketplace-installed copy and upstream main:

  • core/config_loader.pyidentical, 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 the hookify. prefix). No logic differs.

3. Documentation citations re-confirmed against dd79613, not just against my installed copy:

  • commands/help.md lines 104-113 — the require-tests example, event: stop + pattern: .*, unchanged
  • skills/writing-rules/SKILL.md lines 186-196 — "stop Events" section, same shape, unchanged
  • README.md line 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:

console-log-warning.local.md       event=file   enabled=true   form=pattern:
dangerous-rm.local.md              event=bash   enabled=true   form=pattern:
require-tests-stop.local.md        event=stop   enabled=false  form=conditions:
sensitive-files-warning.local.md   event=file   enabled=true   form=conditions:

Still no fixture that exercises pattern: + event: stop, which remains the best explanation for why this has survived since March.

qasimsethi1-svg · 26 days ago

End-to-end reproduction through the real hook path — this removes the method caveat in the original report.

The repro above drove RuleEngine directly, and I flagged that load_rules(), the enabled: 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, and hooks/stop.py invoked as a subprocess with a real Stop payload on stdin.

Setup — identical rules except the condition field each resolves to, both event: stop, both action: block:

.claude/hookify.e2e.local.md (documented simple form):

---
name: e2e-documented
enabled: true
event: stop
action: block
pattern: .*
---

DOCUMENTED FORM FIRED

Control (explicit conditions:), same file path, run in a separate temp dir:

---
name: e2e-control
enabled: true
event: stop
action: block
conditions:
  - field: reason
    operator: regex_match
    pattern: .*
---

CONTROL FORM FIRED

Invocation (per case, cwd = the temp dir so load_rules()'s relative .claude/ glob resolves, CLAUDE_PLUGIN_ROOT set to the installed plugin):

echo '{"hook_event_name":"Stop","reason":"task completed",
       "transcript_path":"<tmp>/transcript.jsonl","stop_hook_active":false}' \
  | python3 <hookify>/hooks/stop.py

Result:

--- DOCUMENTED simple pattern: ---
  exit code : 0
  stdout    : '{}'
  BLOCKS?   : False

--- CONTROL conditions/reason ---
  exit code : 0
  stdout    : '{"decision": "block", "reason": "**[e2e-control]**\nCONTROL FORM FIRED",
               "systemMessage": "**[e2e-control]**\nCONTROL FORM FIRED"}'
  BLOCKS?   : True

The documented form returns an empty dict and exit 0 through the full path — rule discovered, enabled: true honoured, event filter passed, and it still never matches. The control rule, differing only in resolving to field: reason instead of field: 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 in Rule.from_dict().

One incidental note from this run: the rule file must be named hookify.<name>.local.md for load_rules()'s glob to find it. The shipped examples/*.local.md files lack that hookify. 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.

claude[bot] contributor · 12 days ago

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.md with event: stop, action: block, pattern: .* (the exact shape /hookify:help shows for "require-tests") → the Stop hook prints {}; nothing blocks.
  • Same rule rewritten with conditions: [{field: reason, operator: regex_match, pattern: .*}]{"decision": "block", ...} as expected.
  • event: prompt + pattern: deploy against a prompt containing "deploy" → {} as well.
  • Control: event: bash + pattern: rm -rf fires normally, so only the simple pattern: form on stop/prompt events is inert, silently.

🤖 Generated with Claude Code

---
_Generated by Claude Code_