diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 84534e2bf..974338b75 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.56.15", + "version": "0.56.16", "description": "Claude Code operations toolkit. Twelve skills: audit-skill-visibility (audit whether each installed skill is actually VISIBLE to the model, and diagnose why most of a fleet never gets used: a skill is invisible when its description is dropped by Claude Code's skill-listing context budget, which sheds descriptions lowest-score-first so an unused skill loses the keywords that would let it be matched, from skills genuinely not wanted, from skills the run cannot observe at all; computes whether the listing overflows from documented settings, and withholds every cold verdict the data cannot support rather than reporting absence of data as absence of use), inventory (read-only enumeration of the complete invocable surface: every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json: full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow: CLI version, retention-sweep health including the silent unparsable-settings pause, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and the fan-out layer, which covers a load-labelled no-op spawn baseline, every hook that will fire bucketed per-tool-call versus per-turn with its invocation shape, the configured statusline, subagent concurrency and spawn-depth ceilings against documented defaults, whether running sessions predate the settings file they are judged by, and orphan attribution by parent liveness rather than age, plus on Windows a kernel-object census (Token objects against uptime, paged pool) that names a host-level leak beneath all four suspects; read against a bundled known-performance-issues reference that also records the causes tested and cleared; separates the four documented suspects of accumulated state, version regression, component bloat, and per-spawn fan-out cost, and routes remediation out; reports, never mutates, and never executes a discovered hook or statusline command), audit-native-overlap (map native Claude Code surfaces, namely built-in CLI commands, bundled skills, plugin-backed built-ins, and session-provided skills, against the current repo's plugin skills and agents, so a custom component never silently duplicates what Claude Code itself ships; bare invocation is a read-only overlap report carrying the extraction's integrity floors and a shared-listing-budget exposure section, verdicts are human-gated in a committed store rendered into a generated registry whose every row carries an observable recheck trigger, and only an explicit apply step bakes presence-gated native references into descriptions and Boundary sections), observability (read locally captured telemetry from the OTEL store, the collector, the per-session hook event log and hook-event JSONL, and ccusage, with trend reports, a per-session report of what fired, what was blocked and the event timeline, and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand: marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view: queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action, an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry, the skill-usage log and the hook log root live, places the root's self-ignoring guard, and detects retired conventions. Plus an opt-in, default-off per-session hook event log (one JSON line per hook event on every event the generated registry marks observable, written to /sessions/.jsonl, with SessionEnd retention by session count or age and an optional detached pre-prune command), a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures. The last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) that emit the shared hook-telemetry envelope, and a reference sink that routes envelopes under the same root: per session when the envelope carries a session id, else into the shared hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 2e1811894..fef81b5f0 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.56.16] + +### Changed + +- The 30 generated session-event-log rows are shell form and read the kill switch themselves (`[ "$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED" = true ] || exit 0; exec "${CLAUDE_PLUGIN_ROOT}"/hooks/session-event-log.sh`), so a disabled logger spawns no chain. Measured on Windows Git Bash with a job-object process census (n=5): switched off, 1 process creation per event instead of 3 (median wall 41 ms against 107 ms), which is the 9 to 12 creations a Bash tool call charged to this hook down to 3 to 4; switched on, unchanged at 3 creations (median 117 ms) since the row execs the script. `scripts/gen-hook-event-registry.sh` owns the row template and `--check` still re-derives every row from the committed registry. The script keeps its own line-41 switch for a direct invocation. Residual: a shell-form row still costs the one shell Claude Code runs the command in, because hooks.json cannot read a plugin option: `if` takes a single permission rule and is evaluated only on tool events, and the option reaches a hook only as an environment variable. +- Every generated row, the 30 producers and the SessionEnd retention row, pins `"shell": "bash"`. The Hooks reference documents that field as "Defaults to `bash`, or to `powershell` on Windows when Git Bash isn't installed" (https://code.claude.com/docs/en/hooks.md, the `shell` field, verified 2026-09-15), and under PowerShell the row's `[ ... ]`, `$VAR` and `exec` all error, so an unpinned row would error on every fire on such a host instead of gating. The sibling markdown-format and disk-hygiene hook configs pin the same field. + ## [0.56.15] ### Changed diff --git a/plugins/claude-ops/README.md b/plugins/claude-ops/README.md index 6c62cedd6..654f6e07b 100644 --- a/plugins/claude-ops/README.md +++ b/plugins/claude-ops/README.md @@ -240,13 +240,18 @@ producer row per observable hook event (30 events; the generated `MessageDisplay` and `FileChanged` are left out). Each fire appends one line to `/sessions/.jsonl`: the correlation keys the payload carries (`prompt_id`, `tool_use_id`, `agent_id`), the event and its category, the tool -and a repo-relative file path when present. A consumer who has not turned it on -pays the kill-switch read and nothing else (2.42 ms against a 2.08 ms spawn -floor on the Linux CI host); enabled, a 2 KB payload costs about 5 ms and a -512 KB one 36 ms. Windows Git Bash, the host the hook-budget convention binds -to, is unmeasured for these rows: the parallel-wall figure there, and the -budget comparison it feeds, are owed before the switch is recommended on by -default, and the default stays off until they are taken. `session_event_log_categories` narrows the set. At +and a repo-relative file path when present. Each row is SHELL FORM and reads +the kill switch itself, before it execs the script, so a consumer who has not +turned it on starts nothing beyond the shell Claude Code runs the command in: +measured on Windows Git Bash, 1 process creation per event against the 3 the +bare script path costs (median wall 41 ms against 107 ms, n=5). The script +keeps its own switch for a direct invocation (2.42 ms against a 2.08 ms spawn +floor on the Linux CI host). Enabled, the row execs the script and the chain is +the same three creations as before (median 117 ms); a 2 KB payload costs about +5 ms and a 512 KB one 36 ms. Those are serial per-event figures: the +hook-budget parallel-wall comparison for the ENABLED rows on Windows Git Bash +is still owed, and the default stays off until it is taken. +`session_event_log_categories` narrows the set. At `SessionEnd` the retention hook keeps the newest `session_log_keep_sessions` or the last `session_log_keep_days` days, and `session_log_pre_prune_command` hands an archiver the files about to go. The root carries its own `*` diff --git a/plugins/claude-ops/hooks/hooks.json b/plugins/claude-ops/hooks/hooks.json index 60f5f3a24..424df4819 100644 --- a/plugins/claude-ops/hooks/hooks.json +++ b/plugins/claude-ops/hooks/hooks.json @@ -16,7 +16,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the StopFailure event..." } @@ -39,7 +40,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the ConfigChange event..." } @@ -61,7 +63,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the InstructionsLoaded event..." } @@ -83,7 +86,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the PermissionDenied event..." } @@ -105,7 +109,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the PreCompact event..." } @@ -128,7 +133,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the PostToolUse event..." } @@ -150,7 +156,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the UserPromptExpansion event..." } @@ -173,7 +180,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the PostToolUseFailure event..." } @@ -195,7 +203,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the Stop event..." } @@ -207,7 +216,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the CwdChanged event..." } @@ -219,7 +229,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the DirectoryAdded event..." } @@ -231,7 +242,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the Elicitation event..." } @@ -243,7 +255,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the ElicitationResult event..." } @@ -255,7 +268,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the Notification event..." } @@ -267,7 +281,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the PermissionRequest event..." } @@ -279,7 +294,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the PostCompact event..." } @@ -291,7 +307,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the PostModelSwitch event..." } @@ -303,7 +320,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the PostToolBatch event..." } @@ -315,7 +333,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the PreModelSwitch event..." } @@ -327,7 +346,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the PreToolUse event..." } @@ -339,7 +359,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the SessionEnd event..." } @@ -350,6 +371,7 @@ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-retention.sh", + "shell": "bash", "statusMessage": "Pruning the session event log..." } ] @@ -360,7 +382,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the SessionStart event..." } @@ -372,7 +395,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the Setup event..." } @@ -384,7 +408,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the SubagentStart event..." } @@ -396,7 +421,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the SubagentStop event..." } @@ -408,7 +434,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the TaskCompleted event..." } @@ -420,7 +447,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the TaskCreated event..." } @@ -432,7 +460,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the TeammateIdle event..." } @@ -444,7 +473,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the UserPromptSubmit event..." } @@ -456,7 +486,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "command": "[ \"$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED\" = true ] || exit 0; exec \"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "shell": "bash", "timeout": 5, "statusMessage": "Logging the WorktreeRemove event..." } diff --git a/plugins/claude-ops/hooks/session-event-log.sh b/plugins/claude-ops/hooks/session-event-log.sh index 5b9011a76..143f50514 100755 --- a/plugins/claude-ops/hooks/session-event-log.sh +++ b/plugins/claude-ops/hooks/session-event-log.sh @@ -5,9 +5,11 @@ # registry marks observable (plugins/claude-ops/hooks/hook-events.registry.json; # scripts/gen-hook-event-registry.sh writes the hooks.json rows). # -# DEFAULT OFF. A consumer who has not set session_event_log_enabled pays the -# kill-switch read below and nothing else: no library is sourced and stdin is -# not read until the switch says so. +# DEFAULT OFF, and the generated rows carry the same switch in shell form, so a +# consumer who has not set session_event_log_enabled never starts this script at +# all: the row exits in the shell Claude Code already runs the command in. The +# read below is what a direct invocation pays, and what the row's own read would +# fall back on: no library is sourced and stdin is not read until it says so. # # This script sources session-log-lib.sh (a few functions, no process) and # NOT hook-utils.sh: a producer that fires on every event cannot afford the diff --git a/scripts/gen-hook-event-registry.sh b/scripts/gen-hook-event-registry.sh index 43f862c88..f35203309 100755 --- a/scripts/gen-hook-event-registry.sh +++ b/scripts/gen-hook-event-registry.sh @@ -91,8 +91,28 @@ URL="https://code.claude.com/docs/en/hooks.md" BASIS="https://code.claude.com/docs/en/hooks#hook-lifecycle" REGISTRY="$ROOT/plugins/claude-ops/hooks/hook-events.registry.json" HOOKS_JSON="$ROOT/plugins/claude-ops/hooks/hooks.json" +# The producer row is SHELL FORM carrying its own kill switch, so a consumer who +# has not turned the log on pays one process (the shell Claude Code runs the +# command in) per event instead of three: that shell, the `env` of the script's +# shebang, and the bash it execs. hooks.json has no other way to read the +# switch: `if` takes one permission rule and is evaluated only on tool events, +# so it cannot see a plugin option, and the option reaches a hook only as +# $CLAUDE_PLUGIN_OPTION_ in the environment. +# +# regen_rows pins every row it writes to `"shell": "bash"`. The Hooks reference +# documents that field as "Defaults to `bash`, or to `powershell` on Windows +# when Git Bash isn't installed" (https://code.claude.com/docs/en/hooks.md, the +# `shell` field, verified 2026-09-15), under which this command's `[ ... ]`, +# `$VAR` and `exec` all error: an unpinned row would error on every fire on +# such a host instead of gating. Pinning removes that fallthrough; the sibling +# markdown-format and disk-hygiene hooks.json files pin the same field for the +# same reason. The gate stays POSIX-compatible syntax regardless (valid under +# sh and under bash alike), and the option is spelled without braces +# so no `${...}` substitution pass can touch it, unlike ${CLAUDE_PLUGIN_ROOT}, +# which Claude Code is meant to expand. The script keeps its own line-41 +# switch: it is what a direct invocation reads. # shellcheck disable=SC2016 # the literal hooks.json command text; Claude Code expands it, not this script -PRODUCER='"${CLAUDE_PLUGIN_ROOT}"/hooks/session-event-log.sh' +PRODUCER='[ "$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED" = true ] || exit 0; exec "${CLAUDE_PLUGIN_ROOT}"/hooks/session-event-log.sh' # shellcheck disable=SC2016 RETENTION='"${CLAUDE_PLUGIN_ROOT}"/hooks/session-retention.sh' RECHECK="each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" @@ -180,9 +200,9 @@ regen_rows() { def strip: map(select(any(.hooks[]?; .command == $prod or .command == $ret) | not)); .hooks |= (with_entries(.value |= strip) | with_entries(select(.value | length > 0))) | reduce ($reg[0][] | select(.producer == "observe")) as $e (.; - .hooks[$e.name] = ((.hooks[$e.name] // []) + [{hooks: [{type: "command", command: $prod, timeout: 5, - statusMessage: ("Logging the " + $e.name + " event...")}]}])) - | .hooks.SessionEnd = ((.hooks.SessionEnd // []) + [{hooks: [{type: "command", command: $ret, + .hooks[$e.name] = ((.hooks[$e.name] // []) + [{hooks: [{type: "command", command: $prod, shell: "bash", + timeout: 5, statusMessage: ("Logging the " + $e.name + " event...")}]}])) + | .hooks.SessionEnd = ((.hooks.SessionEnd // []) + [{hooks: [{type: "command", command: $ret, shell: "bash", statusMessage: "Pruning the session event log..."}]}]) ' "$2" } diff --git a/scripts/gen-hook-event-registry.test.sh b/scripts/gen-hook-event-registry.test.sh index fdb0e9756..98a1a6485 100755 --- a/scripts/gen-hook-event-registry.test.sh +++ b/scripts/gen-hook-event-registry.test.sh @@ -23,7 +23,7 @@ LIB="$REPO/plugins/claude-ops/hooks/session-log-lib.sh" f="" # shellcheck disable=SC2016 # literal hooks.json command text, never expanded here -PRODUCER='"${CLAUDE_PLUGIN_ROOT}"/hooks/session-event-log.sh' +PRODUCER='[ "$CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED" = true ] || exit 0; exec "${CLAUDE_PLUGIN_ROOT}"/hooks/session-event-log.sh' # shellcheck disable=SC2016 RETENTION='"${CLAUDE_PLUGIN_ROOT}"/hooks/session-retention.sh' @@ -78,16 +78,20 @@ done observed=$(jq '[.[] | select(.producer == "observe")] | length' "$REG") if ((observed == 30)); then ok "30 events are observable"; else fail "expected 30 observable events, got $observed"; fi -# One producer row per observable event, with statusMessage and timeout. +# One producer row per observable event, with statusMessage, timeout, and the +# pinned shell: a shell-form row with no `shell` field defaults to PowerShell on +# a Windows host without Git Bash, where `[ ... ]`, `$VAR` and `exec` all error. missing=0 while IFS= read -r ev; do - c=$(jq -r --arg e "$ev" --arg prod "$PRODUCER" '[.hooks[$e][]? | .hooks[] | select(.command == $prod and .timeout == 5 and (.statusMessage | length > 0))] | length' "$HJ") + c=$(jq -r --arg e "$ev" --arg prod "$PRODUCER" '[.hooks[$e][]? | .hooks[] | select(.command == $prod and .shell == "bash" and .timeout == 5 and (.statusMessage | length > 0))] | length' "$HJ") [[ "$c" == 1 ]] || missing=$((missing + 1)) done < <(jq -r '.[] | select(.producer == "observe") | .name' "$REG") -if ((missing == 0)); then ok "every observable event has exactly one producer row"; else fail "$missing observable events lack their producer row"; fi +if ((missing == 0)); then ok "every observable event has exactly one producer row, pinned to bash"; else fail "$missing observable events lack their producer row"; fi ret=$(jq -r --arg ret "$RETENTION" '[.hooks.SessionEnd[]? | .hooks[] | select(.command == $ret)] | length' "$HJ") if [[ "$ret" == 1 ]]; then ok "SessionEnd carries the retention row once"; else fail "retention rows on SessionEnd: $ret"; fi +ret_shell=$(jq -r --arg ret "$RETENTION" '.hooks.SessionEnd[] | .hooks[] | select(.command == $ret) | .shell' "$HJ") +if [[ "$ret_shell" == bash ]]; then ok "the retention row is pinned to bash too"; else fail "retention row shell: $ret_shell"; fi ret_timeout=$(jq -r --arg ret "$RETENTION" '.hooks.SessionEnd[] | .hooks[] | select(.command == $ret) | has("timeout")' "$HJ") if [[ "$ret_timeout" == false ]]; then ok "the retention row carries no timeout (a plugin timeout only lowers the cap)"; else fail "retention row has a timeout"; fi