Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/claude-ops/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "claude-ops",
"version": "0.44.0",
"version": "0.44.1",
"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 \u2014 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 \u2014 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 \u2014 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 \u2014 built-in CLI commands, bundled skills, plugin-backed built-ins, session-provided skills \u2014 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 \u2014 OTEL store, collector, the per-session hook event log and hook-event JSONL, ccusage \u2014 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 \u2014 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 \u2014 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 \u2014 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 <root>/sessions/<session_id>.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 \u2014 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",
Expand Down
27 changes: 27 additions & 0 deletions plugins/claude-ops/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,33 @@
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.44.1]

### Changed

- **`hook-failure-audit` costs half the processes it did on a turn with no hook failure.** The
Stop-event detector timed out 113 times in the #3508 window at a 21.9 s average, and the cause was
redirection placement, not the per-field `jq` forks the parent issue blamed (shard #3520 and PR
#3788 established that twice). Bash runs the command of a command substitution in the
substitution's own subshell and skips the extra fork only when that command carries no redirection
of its own, so `$(wc -c <file 2>/dev/null)` and `$(cat -- file 2>/dev/null)` each paid a whole
process for a redirect. Six sites in `hook-failure-audit.sh` changed and no shared library did:
`wc` now names the file and `read` drops the filename column; the `read_window` helper is gone and
`grep` opens the transcript directly under the tail cap instead of being fed by `cat` through a
function call that was a second subshell; the marker read is `$(<file)`, which forks nothing at
all; the marker write strips carriage returns in the shell rather than through a `tr` pipeline;
the marker directory is probed with `-d` before `mkdir -p` spawns; and the two payload fields come
from one `hook::jq_fields` pass instead of two `hook::jq_field` calls. Over the tail cap the
`tail | sed | grep` pipeline and its redirect placement are untouched, because a pipeline element
forks either way and hoisting the redirect would newly silence `sed` and `grep`. The common path
went from **18 process creations and 6 execs to 9 and 4**, measured with `strace -ff -e
trace=clone,clone3,fork,vfork,execve`; the equal-work `execve` drop is one batched `jq` and two
removed helper processes, not removed detection. Wall clock is not reported: a Linux runner says
nothing about the Windows spawn tax the budget binds to, and on the #3508 host one creation costs
180-2,841 ms. `hook-failure-audit.test.sh` gains an strace budget assertion on both counts, since
xtrace cannot see these forks (it reads command positions), and the plugin README states the
measured share per hook-budget Rule 1.

## [0.44.0]

### Fixed
Expand Down
19 changes: 19 additions & 0 deletions plugins/claude-ops/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,25 @@ plugin, not in the plugin it might report on, deliberately: an in-plugin
detector shares its plugin's registration form and dies with it, which is
exactly how disk-hygiene's guard monitor missed the #1416 incident class.

Its budget share is stated as a **process count**, not a duration, and the host
is the reason. The [hook-budget
convention](../../docs/conventions/hook-budget/README.md) gives the whole
always-on per-turn set 500 ms of parallel wall, and on the host in #3508 one
process creation costs 180-2,841 ms (median 1,108 ms at 501 concurrent
processes), so the count is what decides whether the set fits and a duration
measured anywhere else does not transfer. On a turn with **no** hook failure
recorded, the common case, the hook costs **9 process creations and 4 program
execs**: one `wc` for the tail-cap decision, one `grep` pre-filter that opens
the transcript directly, and two `jq` passes inside the synced `hook-utils.sh`.
The creations above that are subshell forks inside the same shared library. It
was 18 creations and 6 execs before #3512. Counts are measured with `strace -ff
-e trace=clone,clone3,fork,vfork,execve`, not `bash -x`: a command substitution
whose command carries its own redirection forks a subshell that xtrace cannot
see, and those forks were most of the cost. `hook-failure-audit.test.sh`
asserts both ceilings. Windows Git Bash, the host the convention binds to,
stays unmeasured for this row, so the parallel-wall figure there and the
comparison against 500 ms it feeds are still owed.

`skill-usage-audit` is captured by two disjoint producers so both invocation
paths are measured: the model-invoked `Skill` tool (`PostToolUse`) and the
user-typed slash command (`UserPromptExpansion`, which bypasses the `Skill`
Expand Down
85 changes: 69 additions & 16 deletions plugins/claude-ops/hooks/hook-failure-audit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,15 @@ hook::buffer_stdin_to INPUT || exit 0
# Advisory finding -> fail open, with the standard once-per-session notice.
hook::require_jq Stop claude-ops "$INPUT"

TRANSCRIPT=$(hook::jq_field "$INPUT" '.transcript_path') || exit 0
[[ -f "$TRANSCRIPT" ]] || exit 0
SESSION=$(hook::jq_field "$INPUT" '.session_id') || SESSION="no-session"
# Both payload fields in ONE jq process (hook::jq_fields), not two: a jq spawn is
# a process, and two hook::jq_field calls read the same envelope twice for it.
# An absent field arrives as the empty string here rather than as a non-zero
# return, so each guard below is spelled out instead of riding on `||`.
hook::jq_fields "$INPUT" '.transcript_path' '.session_id' || exit 0
TRANSCRIPT="${HOOK_JQ_FIELDS[0]}"
[[ -n "$TRANSCRIPT" && -f "$TRANSCRIPT" ]] || exit 0
SESSION="${HOOK_JQ_FIELDS[1]}"
[[ -n "$SESSION" ]] || SESSION="no-session"
# data.session_id (additive, hook-telemetry rule 1): the sink routes an
# envelope carrying one into the per-session log beside session-event-log.sh.
SESSION_ID=""
Expand All @@ -81,14 +87,16 @@ SESSION="${SESSION//[^A-Za-z0-9_-]/-}"
# the cap truncates, the first in-window line is likely partial — drop it, as
# guard_launch_monitor.py does. The override exists for the contract test.
TAIL_BYTES="${HOOK_FAILURE_AUDIT_TAIL_BYTES:-2000000}"
SIZE=$(wc -c <"$TRANSCRIPT" 2>/dev/null) || exit 0
read_window() {
if ((SIZE > TAIL_BYTES)); then
tail -c "$TAIL_BYTES" -- "$TRANSCRIPT" 2>/dev/null | sed '1d'
else
cat -- "$TRANSCRIPT" 2>/dev/null
fi
}
# `wc -c -- <file>`, not `wc -c <file>`: bash runs the command of a command
# substitution in the substitution's own subshell and skips the extra fork ONLY
# when that command carries no redirection of its own, so the `<` bought a whole
# second process for one byte count (#3779). Naming the file instead adds a
# filename column, which `read` drops along with the leading padding some `wc`
# builds emit. The `2>/dev/null` rides on the surrounding single-command group,
# where it silences the same stream without re-arming the fork.
SIZE=""
{ read -r SIZE _ < <(wc -c -- "$TRANSCRIPT"); } 2>/dev/null
[[ -n "$SIZE" ]] || exit 0

# grep is a cheap pre-filter only; the structural jq selection decides. The
# no-match common case exits on the pre-filter's emptiness, before paying for
Expand Down Expand Up @@ -143,8 +151,40 @@ read_window() {
# in a narrower shape. The per-class counts below keep every class present in a
# group visible, and the message flags are computed from those counts, never
# from a single collapsed value.
RECORDS=$(read_window | grep -F '"hook_non_blocking_error"')
#
# The window is read INTO grep, not through a `read_window` helper: a function
# call inside `$( )` is a second subshell on top of the substitution's own, and
# `cat -- file | grep` paid a further process to hand grep bytes it can open
# itself. Under the cap, grep now opens the transcript directly and the whole
# pre-filter is one process. Over the cap the pipeline is unchanged — the
# truncated first in-window line is likely partial and `sed '1d'` drops it, as
# guard_launch_monitor.py does — and its `2>/dev/null` stays exactly where it
# was, on `tail`, because a pipeline element forks either way and moving the
# redirect out would newly silence sed and grep for no saving.
if ((SIZE > TAIL_BYTES)); then
RECORDS=$(tail -c "$TAIL_BYTES" -- "$TRANSCRIPT" 2>/dev/null | sed '1d' |
grep -F '"hook_non_blocking_error"')
else
# Group-scoped redirect: `grep … 2>/dev/null` inside the substitution would
# cost the extra fork the file-argument form just saved (#3779). The group
# holds one command, so nothing beyond grep's own stderr is silenced — and
# that stream was already discarded before, by `cat`'s own `2>/dev/null`.
{ RECORDS=$(grep -F '"hook_non_blocking_error"' -- "$TRANSCRIPT"); } 2>/dev/null
fi
[[ -n "$RECORDS" ]] || exit 0
# `printf | jq` and NOT a here-string, even though the pipeline costs a process
# the here-string would not. What is known, stated as known: hook::jq_field in
# the shared library documents this hazard and refuses the here-string form for
# it — bash fills a here-string's pipe itself, so a payload at or above the pipe
# capacity can block before jq is exec'd — and the reproduction behind that note
# is from this repo's Windows Git Bash hosts (#1587: 65536 bytes hung
# indefinitely, 65000 returned at once). It does NOT reproduce on Linux bash
# 5.2: 65535, 65536, 65537, 200 kB and 2 MB each return immediately, including
# under an unwritable TMPDIR. `$RECORDS` is every matching transcript record in
# the window and routinely clears that capacity, so this call keeps the
# library's conservative form rather than bet the hazard is Linux-only. The
# forgone saving is one fork on the warning path only: a turn with no failure
# record exits above, before this line.
SUMMARY=$(printf '%s' "$RECORDS" |
jq -cRs '[
split("\n")[] | fromjson?
Expand Down Expand Up @@ -177,10 +217,17 @@ MARKER=""
WARNED=""
if [[ -n "${CLAUDE_PLUGIN_DATA:-}" ]]; then
MARKER_DIR="${CLAUDE_PLUGIN_DATA}/hook-failure-audit"
if mkdir -p "$MARKER_DIR" 2>/dev/null; then
# `-d` first: after the first warned turn the directory always exists, and
# `mkdir -p` on an existing directory is a whole process to reach the same
# no-op. A directory that exists but is unwritable fell through `mkdir -p`
# successfully before too, and still fails at the marker write below.
if [[ -d "$MARKER_DIR" ]] || mkdir -p "$MARKER_DIR" 2>/dev/null; then
find "$MARKER_DIR" -type f -mtime +7 -delete 2>/dev/null
MARKER="$MARKER_DIR/${SESSION}"
[[ -f "$MARKER" ]] && WARNED=$(cat -- "$MARKER" 2>/dev/null)
# `$(<file)`, not `$(cat -- file)`: bash reads the file itself here, with no
# subshell and no exec at all. Same trailing-newline stripping as the
# substitution around `cat` had.
[[ -f "$MARKER" ]] && { WARNED=$(<"$MARKER"); } 2>/dev/null
fi
fi

Expand Down Expand Up @@ -259,9 +306,15 @@ hook::emit_system_message "$MSG"

# Record what was warned about before telemetry: the warning is the contract,
# the envelope is best-effort.
# `tr -d '\r'` is gone, not its effect: the CRs come from a Windows jq build
# writing stdout in text mode, and bash strips them from the captured string
# for free. The pipeline cost a process to delete one byte class from a string
# bash can rewrite in place, and the substitution around a bare jq carries no
# redirection, so it costs one process rather than two.
if [[ -n "$MARKER" ]]; then
jq -rn --argjson new "$NEW" '$new[] | .hookName + " " + .command' 2>/dev/null |
tr -d '\r' >>"$MARKER" || true
FINGERPRINTS=""
{ FINGERPRINTS=$(jq -rn --argjson new "$NEW" '$new[] | .hookName + " " + .command'); } 2>/dev/null
[[ -n "$FINGERPRINTS" ]] && printf '%s\n' "${FINGERPRINTS//$'\r'/}" >>"$MARKER" 2>/dev/null
fi

# Telemetry subjects stay hookName-only (privacy-safe); the command detail is
Expand Down
Loading