Context
A real /claude-ops:audit-performance run (Linux cloud container, 2026-09-08, CLI 2.1.263, 72 plugins enabled) reported fan_out.hooks.per_tool_call.count = 47 and PostToolUse = 33. On that machine 29 of the 33 PostToolUse rows carry an if: Edit(*.ext) gate, and the engine never reads the if field (flatten_hook_block pulls only command, args, timeout). The count is therefore a ceiling of registered rows, not the fan-out one tool call pays, and a reader cannot tell 29 rows from 10 distinct scripts without re-deriving it by hand.
A second, related defect: PER_TOOL_CALL_EVENTS is {PreToolUse, PostToolUse}, while the hooks reference names five tool events that accept if (PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied). The same machine had five command handlers on the other three events bucketed into other, so the true per-tool-call total was 52, not 47. Adding if fidelity onto that denominator fixes the numerator only.
Cost of leaving it: the one section of the report most audits have to reach (suspect 4, the fan-out layer) overstates by up to 3x on a fleet that follows this repo's own hook-budget convention, which relies on one if: Edit(*.ext) row per extension so that "a Write to any other file spawns nothing".
Official grounding (fetched 2026-09-11, code.claude.com/docs/en/hooks):
- "The hook command only runs if the tool call matches the pattern. … Only evaluated on tool events:
PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionDenied. On other events, a hook with if set never runs."
- "The
if field holds exactly one permission rule."
- Matcher evaluation is by character class:
*, empty, or absent matches all; only [A-Za-z0-9_\- ,|] is an exact string or alternation list; anything else is an unanchored JavaScript regex (Edit.* also matches NotebookEdit).
- "All matching hooks run in parallel."
- "When Claude Code can't determine which commands the Bash input runs, it runs your hook regardless of the pattern." (best-effort filter)
- Dedup applies only across settings files: "If you define the same handler in more than one settings file, it runs once. A plugin's or skill's copy of the same handler stays separate."
Two things are not authoritatively documented and must be treated as unclassifiable: Write(...) as an if pattern (permissions reference says a Write path rule is "accepted but never consulted"; CHANGELOG 2.1.176 implies if handles Write paths), and any Bash if the runtime cannot narrow.
Proposed work
flatten_hook_block reads the if field; classify_hooks reports, per event and matcher, rows, distinct_commands, and if_gated_rows.
PER_TOOL_CALL_EVENTS becomes the five if-accepting tool events.
- Matcher evaluation follows the documented character-class rule (match-all, exact alternation, unanchored regex).
- A projected fan-out per (tool, file kind) for a fixed representative set of file kinds (
.md, .py, .sh, .ts, .json, other), classifying only bare-filename Edit(*.ext) gates by extension. Rows the engine cannot classify (Bash patterns, Write(...), directory-anchored patterns such as Edit(**/.github/workflows/*.yml)) count as fire-always and are listed in unclassified_rows.
- The report states two modelling limits:
if rules match only under their anchor, so an edit outside cwd never matches and the projection over-counts there; cross-settings-file dedup is not modelled.
reference/known-performance-issues.md carries the premise that Edit(*.ext) gates a Write call as a four-part drift record (claim, basis, as-of date, recheck trigger), since the hooks page does not state it and the fleet's budget accounting already depends on it.
- Unit tests for each behaviour in
test_audit_performance.py; the report-shape wrapper updated; CHANGELOG entry and minor version bump in the 0.38.0 / 0.42.0 shape.
- SKILL.md changes limited to one sentence per surface; mechanism detail goes to the reference doc.
Acceptance criteria
fan_out.hooks reports, per event and matcher, row count, distinct command count, and if-gated row count.
PER_TOOL_CALL_EVENTS equals the five events that accept if, and a hooks.json with handlers on PermissionDenied, PostToolUseFailure, and PermissionRequest counts them as per-tool-call.
- A projected fan-out is emitted per (tool, file kind) for the fixed file-kind set, and a matcher of
Edit.* is treated as a regex that also matches NotebookEdit while Write|Edit is treated as an exact alternation.
- IF a hooks.json row carries an
if the engine cannot classify, THEN it counts fire-always and appears in unclassified_rows with its pattern.
- The report text states the anchor caveat and the unmodelled cross-settings-file dedup.
- The Edit-gates-Write premise appears in the reference doc as a four-part drift record.
- Both co-located suites pass under
scripts/affected-tests.sh --run.
- The engine still executes no discovered hook and stays Python 3.11 stdlib only.
References
- Engine:
plugins/claude-ops/skills/audit-performance/scripts/audit_performance.py (flatten_hook_block, classify_hooks, PER_TOOL_CALL_EVENTS)
- Convention that depends on the premise:
docs/conventions/hook-budget/README.md (reference figures section)
- Hooks reference: https://code.claude.com/docs/en/hooks
- Permissions reference: https://code.claude.com/docs/en/permissions
- Claude Code CHANGELOG 2.1.85 (adds
if), 2.1.176 (Read/Edit/Write if paths), 2.1.214 (single-segment anchoring)
- Interview Brief: held in the topic's memory slice (contract tier local, because the contract-slice prune gate keeps
docs/topics/ out of PR diffs); the acceptance criteria above are the Brief's PR 2 section verbatim
Metadata
- Source: dogfood observation from one audit-performance run; raw intake, not a verified diagnosis
- Kind: enhancement to report fidelity
- Related report-fidelity fixes (operator
--note, kernel-thread exclusion, CLI probe provenance, orphan candidate note) ship in a separate PR with no issue, per the fix-not-file posture
Context
A real
/claude-ops:audit-performancerun (Linux cloud container, 2026-09-08, CLI 2.1.263, 72 plugins enabled) reportedfan_out.hooks.per_tool_call.count = 47andPostToolUse = 33. On that machine 29 of the 33 PostToolUse rows carry anif: Edit(*.ext)gate, and the engine never reads theiffield (flatten_hook_blockpulls onlycommand,args,timeout). The count is therefore a ceiling of registered rows, not the fan-out one tool call pays, and a reader cannot tell 29 rows from 10 distinct scripts without re-deriving it by hand.A second, related defect:
PER_TOOL_CALL_EVENTSis{PreToolUse, PostToolUse}, while the hooks reference names five tool events that acceptif(PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied). The same machine had five command handlers on the other three events bucketed intoother, so the true per-tool-call total was 52, not 47. Addingiffidelity onto that denominator fixes the numerator only.Cost of leaving it: the one section of the report most audits have to reach (suspect 4, the fan-out layer) overstates by up to 3x on a fleet that follows this repo's own hook-budget convention, which relies on one
if: Edit(*.ext)row per extension so that "a Write to any other file spawns nothing".Official grounding (fetched 2026-09-11, code.claude.com/docs/en/hooks):
PreToolUse,PostToolUse,PostToolUseFailure,PermissionRequest, andPermissionDenied. On other events, a hook withifset never runs."iffield holds exactly one permission rule."*, empty, or absent matches all; only[A-Za-z0-9_\- ,|]is an exact string or alternation list; anything else is an unanchored JavaScript regex (Edit.*also matchesNotebookEdit).Two things are not authoritatively documented and must be treated as unclassifiable:
Write(...)as anifpattern (permissions reference says aWritepath rule is "accepted but never consulted"; CHANGELOG 2.1.176 impliesifhandles Write paths), and any Bashifthe runtime cannot narrow.Proposed work
flatten_hook_blockreads theiffield;classify_hooksreports, per event and matcher,rows,distinct_commands, andif_gated_rows.PER_TOOL_CALL_EVENTSbecomes the fiveif-accepting tool events..md,.py,.sh,.ts,.json, other), classifying only bare-filenameEdit(*.ext)gates by extension. Rows the engine cannot classify (Bash patterns,Write(...), directory-anchored patterns such asEdit(**/.github/workflows/*.yml)) count as fire-always and are listed inunclassified_rows.ifrules match only under their anchor, so an edit outside cwd never matches and the projection over-counts there; cross-settings-file dedup is not modelled.reference/known-performance-issues.mdcarries the premise thatEdit(*.ext)gates a Write call as a four-part drift record (claim, basis, as-of date, recheck trigger), since the hooks page does not state it and the fleet's budget accounting already depends on it.test_audit_performance.py; the report-shape wrapper updated; CHANGELOG entry and minor version bump in the 0.38.0 / 0.42.0 shape.Acceptance criteria
fan_out.hooksreports, per event and matcher, row count, distinct command count, and if-gated row count.PER_TOOL_CALL_EVENTSequals the five events that acceptif, and a hooks.json with handlers on PermissionDenied, PostToolUseFailure, and PermissionRequest counts them as per-tool-call.Edit.*is treated as a regex that also matchesNotebookEditwhileWrite|Editis treated as an exact alternation.ifthe engine cannot classify, THEN it counts fire-always and appears inunclassified_rowswith its pattern.scripts/affected-tests.sh --run.References
plugins/claude-ops/skills/audit-performance/scripts/audit_performance.py(flatten_hook_block,classify_hooks,PER_TOOL_CALL_EVENTS)docs/conventions/hook-budget/README.md(reference figures section)if), 2.1.176 (Read/Edit/Writeifpaths), 2.1.214 (single-segment anchoring)docs/topics/out of PR diffs); the acceptance criteria above are the Brief's PR 2 section verbatimMetadata
--note, kernel-thread exclusion, CLI probe provenance, orphan candidate note) ship in a separate PR with no issue, per the fix-not-file posture