Part of #6244
Problem
Every SessionStart row in the fleet has no matcher. SessionStart fires with source startup, resume, clear, compact or fork (code.claude.com/docs/en/hooks.md:308,1132), so with no matcher every row runs again on each of those. On origin/main there are 39 SessionStart rows across the hook plugins (counted over every hook in every group):
- 25 shell-form
node-notice rows, one per hook plugin (for example plugins/eol-normalizer/hooks/hooks.json), each running sh lib/prerequisites.sh node-notice ..., which is two processes (the shell Claude Code starts, then sh).
- 8 exec-form
prerequisites.mjs probe rows in the formatter plugins (actionlint, bash-format, biome-format, go-format, markdown-format, powershell-format, ruff-format, typos-format), each a node process.
- 6 others: three Python dependency installers (filed separately), harness-ops' gated event log, session-flow
observer-arm.sh, testing's judge start.
On compaction both notices are already latched for the session, so the re-fire shows nothing new and only spends processes:
node-notice keeps a latch keyed by session id in the temp directory (lib/prerequisites.sh:15-40), and exits at once when command -v node succeeds (:21).
probe keeps a once-per-session latch in ${CLAUDE_PLUGIN_DATA}/skip-notices/ (docs/conventions/prerequisites/README.md:121).
If compaction keeps the session id (inferred, not probed; see Evidence), then on compact every one of these 33 rows starts its processes and exits without output. Long sessions compact many times.
Evidence
Verified this pass (origin/main 26f98e156; hooks docs fetched 2026-10-04):
- Inventory:
jq over every plugins/*/hooks/hooks.json SessionStart group and every hook in it: 39 rows, 25 node-notice, 8 probe, none with a matcher.
hooks.md:1118: on startup, resume at launch, and /clear, SessionStart hooks run in the background, "Claude's first response still waits for the hooks to finish". compact is not in that sentence.
/clear starts a new conversation (sessions.md:232; SessionEnd fires with reason clear, hooks.md:3364), so a notice latched by session id likely fires again after /clear, and Claude's context, which /clear emptied, gets the warning back. That is a real effect, so clear stays in the matcher.
- Inferred, not probed: that
compact keeps the session id and /clear changes it. hooks.md:588 says SessionStart "fires again later in the session, after /clear or a compaction", which leaves the /clear half open. Keeping clear in the matcher is the safe choice either way. Before landing, confirm that the session_id in a compact SessionStart payload equals the session's startup one (a claude --debug-file run is enough). If it differs, excluding compact drops a re-notice, and the change needs the owner's call.
From the 2026-10-04 Windows audit (Claude Code 2.1.289; E2 aggregate with cause inferred, E3 inference):
- About 75 to 80 processes start on every SessionStart (startup, resume, /clear and compaction), 46 of them only confirming node exists (E3, from the hook inventory).
- About 4.6 s from settings load to the last SessionStart hook result in one startup debug log (E2).
- Spawn cost on that host: node 95 ms, Git Bash 53 ms (MEDIUM).
The item proposed matcher: "startup". That would also drop resume and fork, which start a new process where a missing tool should be reported, and clear, see above. Not adopted.
Proposed approach
Give the 25 node-notice rows and the 8 probe rows "matcher": "startup|resume|clear|fork", which excludes only compact.
- The node-notice row text is pinned as one canonical polyglot by
scripts/node-notice-rows.test.sh; extend the test to pin the matcher too. The testing copy is generated by plugins/testing/scripts/gen-hook-filters.sh.
- Update
docs/conventions/prerequisites/README.md ("Hook notices" table, the node-notice row at :145, and the probe row) to state the matcher and why compact is excluded.
- Leave the other six rows alone here: the Python installers are covered by the sibling issue for fix 4, and observer-arm, judge start and the event log have their own per-session semantics.
Alternatives considered:
- One combined node check for the whole fleet: rejected. Each plugin installs on its own and must report its own missing node; the shared latch already prints one notice per session (
docs/conventions/prerequisites/README.md:145).
startup only (the item's proposal): drops the post-/clear re-notice and resume/fork checks; see Evidence.
Acceptance criteria
Constraints and gotchas
Context
Source: local handoff item 20261004-150216-claude-perf-audit-plugin-fixes.md, fix 7. Related: #6130 and #6131 (node-notice row), #4240 (prerequisites check), #4373 (hook fan-out container). The Python dependency SessionStart rows are fix 4's issue (#6250).
Part of #6244
Problem
Every SessionStart row in the fleet has no
matcher. SessionStart fires with sourcestartup,resume,clear,compactorfork(code.claude.com/docs/en/hooks.md:308,1132), so with no matcher every row runs again on each of those. On origin/main there are 39 SessionStart rows across the hook plugins (counted over every hook in every group):node-noticerows, one per hook plugin (for exampleplugins/eol-normalizer/hooks/hooks.json), each runningsh lib/prerequisites.sh node-notice ..., which is two processes (the shell Claude Code starts, thensh).prerequisites.mjs proberows in the formatter plugins (actionlint, bash-format, biome-format, go-format, markdown-format, powershell-format, ruff-format, typos-format), each a node process.observer-arm.sh, testing's judge start.On compaction both notices are already latched for the session, so the re-fire shows nothing new and only spends processes:
node-noticekeeps a latch keyed by session id in the temp directory (lib/prerequisites.sh:15-40), and exits at once whencommand -v nodesucceeds (:21).probekeeps a once-per-session latch in${CLAUDE_PLUGIN_DATA}/skip-notices/(docs/conventions/prerequisites/README.md:121).If compaction keeps the session id (inferred, not probed; see Evidence), then on
compactevery one of these 33 rows starts its processes and exits without output. Long sessions compact many times.Evidence
Verified this pass (origin/main
26f98e156; hooks docs fetched 2026-10-04):jqover everyplugins/*/hooks/hooks.jsonSessionStart group and every hook in it: 39 rows, 25 node-notice, 8 probe, none with a matcher.hooks.md:1118: on startup, resume at launch, and/clear, SessionStart hooks run in the background, "Claude's first response still waits for the hooks to finish".compactis not in that sentence./clearstarts a new conversation (sessions.md:232; SessionEnd fires with reasonclear,hooks.md:3364), so a notice latched by session id likely fires again after/clear, and Claude's context, which/clearemptied, gets the warning back. That is a real effect, soclearstays in the matcher.compactkeeps the session id and/clearchanges it.hooks.md:588says SessionStart "fires again later in the session, after/clearor a compaction", which leaves the/clearhalf open. Keepingclearin the matcher is the safe choice either way. Before landing, confirm that thesession_idin acompactSessionStart payload equals the session'sstartupone (aclaude --debug-filerun is enough). If it differs, excludingcompactdrops a re-notice, and the change needs the owner's call.From the 2026-10-04 Windows audit (Claude Code 2.1.289; E2 aggregate with cause inferred, E3 inference):
The item proposed
matcher: "startup". That would also dropresumeandfork, which start a new process where a missing tool should be reported, andclear, see above. Not adopted.Proposed approach
Give the 25 node-notice rows and the 8 probe rows
"matcher": "startup|resume|clear|fork", which excludes onlycompact.scripts/node-notice-rows.test.sh; extend the test to pin the matcher too. Thetestingcopy is generated byplugins/testing/scripts/gen-hook-filters.sh.docs/conventions/prerequisites/README.md("Hook notices" table, the node-notice row at:145, and theproberow) to state the matcher and whycompactis excluded.Alternatives considered:
docs/conventions/prerequisites/README.md:145).startuponly (the item's proposal): drops the post-/clearre-notice and resume/fork checks; see Evidence.Acceptance criteria
compactSessionStart carries the samesession_idas the session'sstartup. If it does not, stop and ask the owner before excludingcompact."matcher": "startup|resume|clear|fork"; no other SessionStart row changes.scripts/node-notice-rows.test.shfails when a node-notice row lacks that matcher, and passes on the updated tree.plugins/testing/scripts/gen-hook-filters.shemits the matcher, and its test (gen-hook-filters.test.sh) passes.compactis excluded.Constraints and gotchas
${PPID:+exit}so dash skips the PowerShell half; hooks: the node-notice row exits 127 on hosts where /bin/sh is not bash, one hook error per plugin at every session start (25 plugins) #6130). Keep that text byte-identical; only add the matcher.docs/conventions/hook-budget/README.md, "Scope").Context
Source: local handoff item 20261004-150216-claude-perf-audit-plugin-fixes.md, fix 7. Related: #6130 and #6131 (node-notice row), #4240 (prerequisites check), #4373 (hook fan-out container). The Python dependency SessionStart rows are fix 4's issue (#6250).