Skip to content

feat(claude-ops): per-session hook logging pipeline, default off - #3750

Merged
kyle-sexton merged 35 commits into
mainfrom
claude/hook-logging-pipeline-ji6rl5
Sep 5, 2026
Merged

feat(claude-ops): per-session hook logging pipeline, default off#3750
kyle-sexton merged 35 commits into
mainfrom
claude/hook-logging-pipeline-ji6rl5

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

No related issue: the logging pipeline was designed and allocated in the hook-logging-pipeline topic (operator brief 2026-09-04/05, relayed through the prompt-hooks session) and tracked in the topic's contract slice rather than an issue; #930 tracks the envelope follow-up it leaves.

Summary

Claude Code's hooks ran with no per-session record of what fired, what was blocked, or what each hook cost, and the only telemetry store was one shared hook-events.jsonl under .claude/observability/ that no session could be joined to. This PR is the third and last from the hook-logging-pipeline topic (after #3747, the verifier-lane fix, and #3749, the PostToolUse kill-switch hoist). It adds an opt-in, default-off per-session hook event log to claude-ops, moves the reference sink and the observability skill to one hook log root with per-session files, adds SessionEnd retention that fits the 1.5 s budget, gives setup an apply for the root's guard plus a retirement record for the old path, and generates the event list from the hooks reference rather than hardcoding it.

Fix

  1. hooks/session-log-lib.sh (sourced, no hook-utils.sh): root resolution from session_event_log_dir (default .observability/claude) with containment checked lexically and physically (the nearest existing ancestor is resolved with cd -P, a builtin, and must sit below the physical project; a symlinked component out of the project, or back to the project root, is refused), the self-ignoring .gitignore guard (healed on first write; an operator-edited guard refuses the write; an empty file is healed, which closed a race the 33-parallel-fires case caught), id validation, process-free timestamps, the category table.
  2. hooks/session-event-log.sh: kill switch first, bounded 4 KB slice read to a 64 KB cap that stops early only when the buffer ends in }, carries the event name and has balanced braces (the brace characters come from variables, because bash ends a ${...} expansion at a literal } inside a bracket class; the Win32 late-EOF stall costs one idle slice; a writer that pauses after a nested } is read to the bound), bash-regex field extraction, one line {ts, session_id, hook_event_name, category, status, source: "event-log", duration_ms, prompt_id?, tool_use_id?, agent_id?, tool_name?, file_path? (repo-relative, or the last segment after either separator), reason?, traceparent?} to <root>/sessions/<session_id>.jsonl.
  3. hooks/session-retention.sh (SessionEnd, no timeout, no stdin): keep newest session_log_keep_sessions OR younger than session_log_keep_days, four spawns; with session_log_pre_prune_command, doomed files move to prune-pending/<epoch>-<pid>/ and the command runs detached (nohup, stdin closed) with that directory; sets older than 24 h are deleted on the next run.
  4. scripts/gen-hook-event-registry.sh: --fetch | --from <file> | --check; writes hooks/hook-events.registry.json (33 events, 30 observable; WorktreeCreate, MessageDisplay, FileChanged excluded because each replaces or holds native behavior when registered; unknown names excluded with a warning; under 25 rows refuses) with upstream-drift stamps, and regenerates the producer rows and the retention row in hooks.json preserving the nine audit handlers. --check is offline.
  5. Reference sink (and the repo-local copy): an envelope carrying a well-formed data.session_id routes to sessions/<id>.jsonl in the spine shape (source: "envelope", changed when sent); any other envelope to <root>/hook-events.jsonl in the legacy shape under its lock. The nine audit hooks send data.session_id; seven data schemas gain the optional key.
  6. setup: check | apply. Probe 5 reports the root, containment, root-equivalence refusal, and the guard; probe 6 is the fixed retired-conventions line. apply writes exactly <root>/.gitignore and reads back the tracked-versus-ignored pair. retirements.yaml gains claude-ops-r001 (.claude/observability/hook-events.jsonl, migrate); the helper copy is enrolled in scripts/sync-check-retirements.sh; one eval per record plus the guard-only and root-refusal evals.
  7. observability: every whole-root query reads sessions/*.jsonl plus the shared file through one HOOK_NORM prelude; session (newest by mtime) and session:<id> render a per-session report (hooks fired, blocked, rewrote, per-hook duration, event timeline); every report ends with the six lines of probe-observability-state.sh --pipeline (root, guard, sessions, shared, prune-pending, toggles). The probe gains --root and --pipeline (the rendered options arrive as flags because a skill subprocess inherits no CLAUDE_PLUGIN_OPTION_*); clean.sh gains --hook-root, prunes the root's shared file, removes session files untouched for the window, and sweeps stale prune-pending/ sets whether or not the switch is on.
  8. claude-ops to 0.42.6 with the CHANGELOG entry (main took 0.42.4 for perf(hooks): PostToolUse kill-switch hoist and gate widening #3749 and 0.42.5 for feat(claude-ops): project --ids from a saved report, journal sync runs, split install/enable steps into a spoke #3748 while this PR was open; both are merged in); six new userConfig keys and the regenerated README options table; .gitignore gains .observability/; the hook-observability convention gains the # silent-skip-ok: paragraph and the hook-telemetry convention the sink-routing note with the feat(conventions): add prompt_id correlation to the hook-telemetry envelope #930 pointer; docs/CATALOG.md and docs/SKILL-CHEAT-SHEET.md regenerated.

Verification

Measured on the Linux CI host, N = 15 (raw captures in the topic's memory slice; the distilled rows lived in the branch's FINDINGS.md until the prune commit):

Row Result
session-event-log disabled (the default) 2.42 ms against a 2.08 ms bare spawn floor (1.16 S; acceptance bound 1.5 S)
enabled, 2 KB payload 4.5 to 5.75 ms
enabled, 512 KB tool_response 35.7 ms
sink, envelope with session_id 26.7 ms, off the critical path
retention, 40 files nothing doomed / 100 files 70 pruned 4.3 ms / 24 ms
held-open stdin (late-EOF shape) producer returns in 262 ms (asserted under 700; a broken early stop measured 1262); retention under 500 ms
toggle cycle (on 10, off 10, on 10, then SessionEnd) root holds only .gitignore and sessions/cycle-1.jsonl with 20 parsing lines; git status clean

Suites (all beside their scripts): session-event-log.test.sh 53, session-retention.test.sh 20, hook-telemetry-sink.test.sh 38 plus the repo-local drift check, audit-session-id.test.sh 27, gen-hook-event-registry.test.sh 25, probe-observability-state.test.sh 48, claude-observability.test.sh 57 (was 33), the guardrails skill-reference-verify suite 139 after the merge from main. Four review findings were verified by reproduction and fixed with repro-first cases (each fails on the previous script): the early stop firing on a nested } during a mid-message pause (a fresh-context review), a Windows path outside the project not reduced to its last segment (same review), the brace-count class that bash could not parse as written (the Claude review lane), and a configured root escaping through a symlinked component (the Codex lane). The empty-guard race case likewise fails before and passes after. scripts/affected-tests.sh --run: 160 shell suites pass; the one failure is session-flow's save_point.test.sh (test_new_origin_falls_back_to_directory_name, a directory-name assertion in a suite this branch does not touch, failing identically on origin/main in this container). scripts/check-changelog-parity.sh --check-bump origin/main, scripts/sync-check-retirements.sh --check, scripts/gen-hook-event-registry.sh --check, scripts/check-silent-skips.sh, scripts/sync-plugin-options-docs.py --check, scripts/check-changed-skills.sh origin/main, and scripts/validate-plugins.sh all exit 0; shellcheck at info severity is clean on every changed script. CI is green on the head, the Windows test lane included.

Windows Git Bash is the binding host for the hook-budget parallel-wall figure and unmeasured here; the README says so, the switch stays off by default until it is taken, and the Codex thread asking for it is left open for the operator, who has the host.

The topic's contract slice (docs/topics/hook-logging-pipeline/) rode this branch for review and is pruned on this head, per the topic-docs convention; the Brief and the phases this PR ships are summarized below.

Brief (TLDR, Goal, locked decisions) and Phases 3 to 8 of the plan

TLDR

Design the logging and telemetry pipeline for the marketplace's hooks, and settle the upstream decisions that determine what it instruments. Evidence base is FINDINGS.md, a measured read-only audit of the 26 wired PostToolUse rows plus a doc-alignment pass. Interview complete: five rounds, 21 questions, 19 answered and 2 deferred with named arbiters.

Goal

Observability across every hook event, defaulting to off, costing effectively nothing when off and as close to nothing as measurable when on, with no surface left as a black box and every toggle reachable by Claude on the operator's behalf rather than by hand.

Decisions locked (the ones this PR implements)

Logging hooks stay in claude-ops (no plugin per hook); every documented event, plugin default-OFF, from a generated registry with upstream-drift stamps; storage at .observability/claude/, configurable, one file per session; three toggle levels (sink unconfigured, per-producer switch, category filter); five correlation keys as a hierarchy (session_id, prompt_id, tool_use_id, agent_id, TRACEPARENT); a fixed spine with payload only where a decision was made; the producer sources no library; retention keeps the newer of 30 sessions or 14 days at SessionEnd; the pre-prune command runs detached; the guard heals on first write inside the plugin-owned root and the project root is refused; only hook-events.jsonl migrates, the skill-usage and OTEL stores stay.

Phases

  • 3, integration slice: the library, the producer, the sink routes, data.session_id on the nine audit hooks, the six options.
  • 4, registry: the generator, the fixture, --check in the suite, the regenerated hooks.json.
  • 5, retention: the SessionEnd hook, the prune-pending move-aside, clean's sweep.
  • 6, setup: apply for the guard, the retirement record and its helper copy, the evals.
  • 7, reader: the root-wide queries, the per-session report, the pipeline probe, the path migration across the skill's docs, README and manifest.
  • 8, docs and PR: the CHANGELOG entry and version, the two convention notes, the toggle cycle, the Windows recheck table, the affected suites, the code review, this PR, and the prune commit before the ready flip.

Follow-ups this PR leaves

  • Windows Git Bash recheck: the hook-budget parallel-wall figure for the always-on kill-switch read; same-second >> appends to one session file at 4 KB lines; ls -t tie order on NTFS for retention's "newest N" and the reader's session scope; the late-EOF slice cost against the 262 ms Linux figure; the peer-reported 11 s cold cli-flag-verify run.
  • feat(conventions): add prompt_id correlation to the hook-telemetry envelope #930: data.session_id on every producer and the envelope 1.1 spine promotion; until then per-session hook duration covers the nine claude-ops audit hooks.
  • changed is a defined per-session key no formatter emits yet; the "rewrote" block of the per-session report stays empty until one does.

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme

…line brief

Read-only audit of the fleet's 26 wired PostToolUse handler rows, with every
figure measured in-container rather than inherited, plus a doc-alignment pass
against a same-day raw fetch of the official hooks and CLI references. No hook,
plugin, or convention file is modified by this commit.

Headline findings:

- skill-reference-verify is the most expensive PostToolUse hook in the fleet at
  622 ms (352 spawn-equivalents) on markdown carrying /plugin:skill references.
  Its lazy plugin-index build runs 2 jq + 2 tr per manifest across 74 manifests;
  one batched jq does the same work in 5.7 ms, recovering ~542 ms, 87% of the
  hook. This revises the prior assumption that markdown-format dominates.
- A disabled hook is not free. Every hook sources the 2,766-line
  lib/hook-utils.sh before reading its own kill switch, so a switched-off hook
  still costs 5.5-6.1 ms against a 2.1 ms no-op floor, across ~44 switch sites.
- Telemetry emission measured free: sink wired vs unset is within noise.
- Three PostToolUse rows carry no if: predicate and pay near-full cost on files
  they have nothing to say about.

Doc alignment confirmed the repo's existing conventions are accurate rather than
stale, including the systemMessage/additionalContext channel split and the
--include-hook-events flag, which is real and documented in the CLI reference.
Records one refuted peer finding (typos-format.sh:700) and the reason: the
2,000-character shared cap governs the auto-mode classifier note, not
additionalContext, which is capped at 10,000 per value with no shared pool.

The brief locks interview round 1: guard-precision measurement as the log's
primary consumer, PostToolBatch over async for the formatter family, and
telemetry staying in claude-ops per docs/MIGRATION-PLAYBOOK.md's rule against
shipping a plugin per hook. The Q3/Q4 recommendations were made in the opposite
direction earlier and withdrawn on discovery of that policy; the reversal is
recorded rather than erased.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
…ring tally

Follow-up to b043bb1, in response to verified peer-session review. Docs and one
throwaway diagnostic script; no hook, plugin, or convention file is modified.

Adds harness/measure-posttooluse.sh, which reproduces every figure in
FINDINGS.md rather than leaving them to be cited. shellcheck and shfmt clean;
smoke-run reproduces the headline numbers. It encodes the audit's two method
defects as rules at the top of the file, because both were found by measuring
rather than reading and both would silently invalidate a successor harness:

  Rule 1. A PostToolUse hook reads tool_input.content from the payload, not the
  file on disk, so a stub payload makes a content-scanning hook short-circuit
  and the harness reports a no-op as the hook's cost. Measured error on
  skill-reference-verify: 33.9 ms against a real 622.1 ms, 18x.

  Rule 2. Spawn-equivalents survive a host change only for spawn-dominated
  hooks. A work-dominated hook's S-ratio inflates as the measuring host gets
  faster, so converting it to a slower reference host overstates the cost.

Corrects the block-hook-bypass tally upward, from two false positives across two
sessions to four firings across three, still with zero true positives and none
of those sessions hunting for it. One firing was triggered by text that merely
quoted the guard's own scope note. Directly observed versus reported firings are
now distinguished by attribution rather than merged. Records the disposition the
PreToolUse lane settled on: ADR-0003 clause 4 re-files a sound oracle with wrong
scope rather than deleting it, so the verdict is withdraw-from-current-scope and
re-file for rescoping, not default-off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
… brief

Records eleven further decisions on the hook logging pipeline, each grounded in
a measurement taken this session or a raw fetch of the official reference, with
the reversals from earlier recommendations kept visible rather than erased.

Round 2: native routes first where they reach, envelope where they cannot,
because --include-hook-events is headless-only and cannot serve an interactive
session. Logging hooks on every documented event, plugin default-OFF. The event
list is a generated registry stamped per docs/conventions/upstream-drift and
phrased per docs/conventions/native-references, never a hardcoded list. Storage
at .observability/claude/, configurable. Three toggle levels with the kill
switch read before any library load. Five correlation keys as a hierarchy:
session_id, prompt_id, tool_use_id, agent_id, TRACEPARENT. This corrects an
earlier recommendation that tool_use_id supersedes prompt_id; the docs state
prompt_id is the OTEL join key and #930's premise was right.

Round 3: setup writes the ignore rule and the sink refuses to log until it is
present. A fixed spine on every event with per-event payload only where a
decision was made. The logging producer ships its own minimal emitter and does
not source lib/hook-utils.sh, measured as the difference between 3.4 ms and
7.5 ms per fire, or 31 ms versus 62 ms at full concurrent coverage.

New constraints recorded: SessionEnd's 1.5 s budget is not raised by plugin
timeouts; concurrent no-op hooks scale sublinearly (33 cost 19.5 ms, not 33x).

Round 4 (file granularity, retention, concurrent-append safety) stays open.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
…ound 5

Locks the final six decisions and the full acceptance-criteria list. Register
gate clean: 21 questions registered, 19 answered, 2 deferred with named
arbiters, 0 open; the --brief cross-check confirms both deferred rows reached
the Brief's Deferred questions section.

Round 4: one file per session under .observability/claude/sessions/, which
removes the concurrent-append question by construction. That question is
recorded as a measured fact rather than a decision: with 33 parallel appenders
a shared file under plain >> showed zero corruption at 64 B, 512 B and 4 KB
lines and 376 of 990 lines corrupt at 16 KB, matching the host's 4096-byte
PIPE_BUF; per-session files never corrupt. Windows/Git Bash is unmeasured and
recorded as a recheck item. Retention keeps the newer of 30 sessions or 14
days, enforced at SessionEnd inside its 1.5 s budget, both knobs configurable.

Round 5: prune deletes, with one optional consumer pre-prune command as the
extensibility point, run detached so it can never exhaust the SessionEnd
budget; no built-in archive tier. The reader is claude-ops:observability,
extended to take per-session files as its primary source and to report toggles
and retention in effect. Handoffs and observability stay separate; both carry
session_id and the join is free.

Ten acceptance criteria, each testable, including the measured disabled-path
cost floor, the no-library rule for logging producers, the generated-registry
rule, and the SessionEnd budget under a 100-file directory. Two deferred
questions carry arbiter tags: Q8 kill-switch hoist ownership is USER-RESERVED
(it allocates work across sessions); Q9 the silent-skip-ok convention-doc
defect routes to /planning:plan in this lane.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
Replaces the loose "roughly 44 switch sites" with a count anchored on the
hook::check_enabled call and the first source line anywhere in the file:
40 call-sites, 40 of 40 after the source line, 0 before, plus 21 scripts that
read the raw CLAUDE_PLUGIN_OPTION_<NAME>_ENABLED variable directly. A peer
lane's independent 43 of 43 is consistent with 40 call-sites plus a few
raw-variable readers. Adds the directly measured disabled-path pair (2.54 ms
gate-first vs 5.66 ms gate-last) next to the count so the 55% recovery figure
sits beside its evidence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
… lane

A sibling lane holds plugins/claude-ops at 0.41.13 on its own branch. Any bump
from this lane takes 0.41.14 and places its CHANGELOG entry above theirs, so
whichever merges first the other rebases cleanly rather than colliding on the
version line and the CHANGELOG head.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
…eline brief

The operator confirmed two allocations after the interview closed. Q8, the
kill-switch hoist, moves from deferred to decided: split by plugin directory,
this lane taking the PostToolUse formatters, normalizers and verifiers and the
PreToolUse lane taking its 17 guard scripts, neither touching lib/hook-utils.sh.
A scripts/check-killswitch-hoist.sh CI gate ships with this lane's PR because
sync-hook-utils.sh does not cover entry scripts and the fix would drift back.

The verifier-lane performance fix is also recorded as this lane's work, with the
four patch shapes the prompt-hooks lane measured and this lane partly
corroborated: the batched-jq plugin index (547.7 ms to 5.7 ms here), the
cli-flag-verify pre-gate, in-parent cache reads, and three cache-hit-path
micro-fixes in verify-cli-flag.sh whose line numbers were verified here. The
cold-cache and PATH-tax figures stay marked peer-reported until re-measured.

Register gate and brief cross-check both clean: 21 registered, 20 answered,
1 deferred, 0 open.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
Fills the Brief's empty Plan section with nine phases across three draft PRs
(verifier-lane fix, PostToolUse kill-switch hoist with the gate widened, the
per-session pipeline), each phase ending in a mechanically verifiable sanity
check, plus the gate-passed decisions table and a Tier B design early-exit
record with the record and registry type sketches. Draft pending the
fresh-context plan review and devils-advocate pass; their findings land in
the stress-test summary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
…ipeline plan

Rewrites the Plan section after a fresh-context plan review and a
devils-advocate pass, both verified against the files before applying:
versions become "next patch above origin/main at cut time" with each PR
opened only after the previous merged; the event registry gains a producer
field that excludes WorktreeCreate, MessageDisplay and FileChanged; the
producer mirrors the library's slice-and-brace-tail stdin read and the
retention hook reads no stdin; the ignore guard heals on first write; the
nine claude-ops audit hooks carry data.session_id so their envelope rows
join a session; the topic slice follows the contract-tier prune lifecycle.
Five Brief corrections are recorded in a dated subsection rather than by
editing the locked text. Branch merged origin/main at 3ea592b.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
Harness re-run after merging main (#3732 changed the library startup),
plus direct cold-cache, warm, and execve probes of cli-flag-verify: 980 ms
cold, ~120 ms warm, 88 failed execve per warm run, all the env PATH walk
for bash behind five #!/usr/bin/env execs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
… cache hits in-process

skill-reference-verify builds its plugin index with one jq over every
manifest (296 processes to 1 on this marketplace; 468.5 ms to 5.7 ms for the
loop), falling back to the per-manifest read only for manifests jq could
not parse. cli-flag-verify gates on a scanned bin name before the fragment
pipeline and matches cached --help text in-process, spawning the verifier
only on a miss; verify-cli-flag.sh drops three processes on its own cache
path. Every new case fails against the unmodified hooks and passes now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
CHANGELOG entry gains the whole-hook and warm-probe after numbers; FINDINGS
gains the before/after table; PLAN advances Phase 0 to DONE and Phase 1 to
DOING.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
…batched-index fields

Review of the previous commit found two regressions and their root cause.
The hook's hand-copied flag pattern had dropped the `[` terminator, so a
flag documented as --flag[=VALUE] was known on a cold cache and unknown on
a warm one; the batched manifest read joined fields with tabs, which
`read` collapses, so a nameless manifest was filed under its skills path,
and @TSV doubled any backslash in a declared path. The cache directory,
key, window and pattern now live once in lib/verification/cli-flag-cache.sh
and both scripts source it; the batch uses raw fields on non-whitespace
separators; the fresh-cache find runs lazily on the first installed bin;
the suite counts manifests with the hook's own glob. Every new case fails
on the previous commit and passes here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
Every PostToolUse-registered shell hook (ten formatters and normalizers,
the three guardrails verifiers, source-control's worktree claim gate,
claude-ops's skill-usage audit) reads its kill switch above the first
source line, in the one shape scripts/check-killswitch-hoist.sh pins to
hook::is_enabled; the gate's discovery widens from PreToolUse to PreToolUse
and PostToolUse (30 scripts), its suite inverts the out-of-scope case and
adds a launcher case, and each of the 13 plugins takes a patch bump with a
CHANGELOG entry stating the measured recovery for its shape: 6.1 to 6.5 ms
down to 3.1 to 3.2 ms on the disabled path of a standalone hook, about
0.05 ms for a verifier sourced under run-guards.sh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
…ork too

The hoisted switch sat below hook_dir="$(dirname ...)", so a disabled
index-drift hook still paid one command substitution (a fork on Windows
Git Bash) before exiting. The switch is now the first statement after
set -uo pipefail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
…failures

The batched-index record separator is spelled as the U+001E escape (the
raw byte made editorconfig-checker read the hook as binary); the sourced
cli-flag-cache.sh drops its shebang like every other sourced library here
(the exec-bit gate keys on shebangs); the fresh-cache index is updated after
a verifier miss so later candidates on the same (bin, chain) are answered
in-process; the shared library is sourced lazily on the first installed
bin; the corruption notice names whichever bundled file is absent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
(cherry picked from commit c34262f)
…shared log library

session-event-log.sh appends one spine-shaped JSON line per hook event to
.observability/claude/sessions/<session_id>.jsonl, default off, sourcing a
small session-log-lib.sh (root containment, self-ignoring guard healed on
first write, category table) and never hook-utils.sh; stdin is read in
bounded slices so a late-EOF pipe costs one slice, not the timeout. The
telemetry sink moves to the same root and routes an envelope whose
data.session_id is well-formed into the session file (source: envelope),
keeping the legacy shared-file shape for the rest; the nine audit hooks put
the payload's session_id into data (additive), with the optional property
declared on their seven schemas. Suites: producer 43, sink 38, drift and
the nine audit suites unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
… retention

session_event_log_enabled (default false), session_event_log_dir,
session_event_log_categories, session_log_keep_sessions,
session_log_keep_days, session_log_pre_prune_command; README options table
regenerated by scripts/sync-plugin-options-docs.py.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
…onEnd retention

scripts/gen-hook-event-registry.sh parses the Hooks reference lifecycle
table (33 events on the saved 2026-09-05 copy), stamps every entry with the
four-part upstream-drift record and a producer verdict (WorktreeCreate,
MessageDisplay and FileChanged excluded with their reason; unknown names
excluded as unclassified), writes plugins/claude-ops/hooks/hook-events.registry.json,
and regenerates the session-event-log rows in hooks.json (30 observable
events) plus the SessionEnd retention row, preserving the nine audit
handlers; --check re-derives the rows offline for CI. session-retention.sh
keeps the newer of the last 30 sessions or 14 days in three processes,
reads no stdin, and hands a configured archiver a moved-aside directory
detached. The nine audit hooks' data.session_id is pinned by one suite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
…vance plan tags

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
… write

Two producers firing on one event race to create the root's self-ignoring
.gitignore: the second reads the file the first has opened but not yet
written, takes the empty file for an operator's, and refuses its write. The
33-parallel-fires case caught 32 lines. An empty guard file is now healed to
`*` like an absent one (two writers of the same two bytes cannot disagree);
a file with content but no `*` line is still refused. A deterministic case
pins the empty-file path; it fails on the previous library.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
…nts retirement

Phase 6 of the hook-logging-pipeline plan. The setup skill leaves the
check-only carve-out: probe 5 reads session_event_log_dir, reports the guard
(`*` first non-comment line inside the root), FAILs a root-equivalent or
uncontained dir without writing, and INFOs an absent guard while logging is
off; `apply` writes exactly that one file, announced and idempotent, then
reads back the tracked-versus-ignored pair. The consumer's root .gitignore
is never touched.

retirements.yaml gains claude-ops-r001 (kind file, .claude/observability/
hook-events.jsonl, action migrate) for the sink's move to the hook log root;
the helper copy is enrolled in scripts/sync-check-retirements.sh, the two
fixed setup lines are in place, and the evals carry one case per record
plus the guard-only and root-refusal cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
…t PR's bump

Main took claude-ops 0.42.3 while PR B was open, so B moved to 0.42.4 and
this branch's release becomes 0.42.5; the record and the Phase 8 line say so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
Phase 7 of the hook-logging-pipeline plan. The reader follows the sink to
the hook log root: every whole-root query reads sessions/*.jsonl plus the
shared hook-events.jsonl through one HOOK_NORM prelude (legacy rows carry
`event`, per-session rows `hook_event_name`), the `session` and
`session:<id>` scopes render a per-session report (hooks fired, blocked,
rewrote, per-hook duration, the event timeline), and every report ends with
the six probe lines naming the toggles, retention, guard state and stale
prune sets in effect.

probe-observability-state.sh gains `--root` (the rendered option passed as a
flag, since a skill subprocess inherits no CLAUDE_PLUGIN_OPTION_*) and a
read-only `--pipeline` mode; its suite moves to the new root (48 cases).
clean.sh gains `--hook-root` and prunes the root's shared file line by line,
removes session files untouched for the window, and sweeps prune-pending
sets older than 24 h whether or not the logging switch is on (57 reader
cases, was 33). read-routing, privacy, output-format, the README and the
manifest description follow the path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
Takes main's guardrails verifier and changelogs (PRs #3747 and #3749) and
main's claude-ops changelog (0.42.3 fleet-state fix, 0.42.4 hoist); keeps
this branch's claude-ops manifest and moves its version to 0.42.5, the next
patch above main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
… logging pipeline

Phase 8 of the hook-logging-pipeline plan: the 0.42.5 entry at the head of
the claude-ops changelog, a README section for the per-session hook event
log, the `# silent-skip-ok:` annotation paragraph in the hook-observability
convention (where it goes and that check-silent-skips.sh reads it), the
sink-routing note and the #930 follow-up pointer in the hook-telemetry
convention, and the Windows recheck table in the topic's findings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
…he ready flip

The slice rode the branch for review and is summarized in the PR body; the
topic-docs convention commits it on the branch only. The memory tier under
.work/ keeps the raw captures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 46s —— View job


Security review (/review:security-review)

  • Gather context (PR diff, changed files, custom instructions)
  • Skip-gate check (PR open, has security-relevant surface: new shell hooks parsing untrusted stdin, path/session-id handling, detached command execution)
  • Hunt for vulnerabilities in the diff
  • Adversarial validation of candidate findings
  • Report

Result: no security issues found

I reviewed the diff at 8b64c52 end to end, focused on the parts of this change with real attacker-influenced input or trust-boundary crossings:

  • plugins/claude-ops/hooks/session-log-lib.shslog_valid_id constrains session_id/prompt_id/tool_use_id/agent_id to [A-Za-z0-9._-]+ before any of them name a file. No /, \, or leading ~/drive letter is admitted, and every write path appends a fixed .jsonl suffix, so a crafted id (including strings of just dots) can't escape sessions/. slog_contained/slog_root_to reject absolute, UNC, drive, and ..-bearing configured roots, and refuse a root that resolves to the project root itself.
  • session-event-log.sh — the bounded-read producer extracts session_id and validates it with slog_valid_id before it's used as a filename (L124-L127). Free-text fields (tool_name, file_path, reason) are copied from the payload's own already-escaped JSON string bodies via a regex that only admits [^"\\] or \. sequences (L117-L123), so re-emitting them verbatim into a hand-built JSON line can't break out of the string — a raw unescaped " can never be captured. This also matches the two defects the PR description says were found and fixed pre-merge (early-stop on a nested } during a mid-message pause, and the Windows path not being reduced to its last segment); both are visibly fixed in this diff.
  • hook-telemetry-sink.sh — routes on data.session_id, but only after slog_valid_id passes (L111); the JSON line itself is built entirely with jq --arg, not string concatenation, so injection isn't reachable here.
  • session-retention.sh — the detached session_log_pre_prune_command is invoked as bash -c "$pre_prune" bash "$pending" (L92) — the pending-directory argument is passed positionally via "$@", never interpolated into the command string, so even though $pending is script-generated (epoch + pid, both numeric) it couldn't inject even if it weren't. The command itself is operator-supplied userConfig (not attacker-reachable — the plugin manifest and PR description both call out that current Claude Code releases ignore project/local pluginConfigs for this key, so only the user's own settings can set it).
  • lib/check-retirements.sh — path handling for --clean re-resolves the target with pwd -P and re-checks containment at the moment of use before any rm -f/rm -rf (L561-L569, L589-L604), which correctly defeats a symlink swapped in after the syntactic path check. retirements.yaml's path field is plugin-shipped (trusted), not consumer- or attacker-supplied.
  • scripts/gen-hook-event-registry.sh — fetches a fixed, hardcoded code.claude.com URL; not attacker-influenced, and it's a maintainer-run generator, not runtime/CI-triggered on untrusted input.
  • The nine audit hooks' new SESSION_ID extraction is a consistent, narrow regex match ([A-Za-z0-9._-]+) feeding jq --arg, repeated identically across all nine files — no injection surface.
  • No GitHub Actions workflow files are touched by this PR, so there's no pull_request_target/workflow_run/permissions surface to review here.

Nothing rises to CRITICAL/IMPORTANT/SUGGESTION. This PR's own review note (a fresh-context high-effort review that found and fixed two defects pre-merge) already covered the correctness-adjacent edge cases in the bounded-read parser; from a security-logic and trust-boundary standpoint the id-validation, containment, and detached-execution patterns here are consistently applied and I found no way to defeat them with attacker-controlled hook payload content.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Last security-reviewed head: 8b64c520f98d9bc2a5d227cd469150d0dd8c85a8. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

Comment thread plugins/claude-ops/hooks/session-event-log.sh Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8b64c520f9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/claude-ops/hooks/session-log-lib.sh Outdated
Comment thread plugins/claude-ops/README.md Outdated
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

…root physically

Two review findings on the pipeline PR. The early stop's closing-brace
count was written as `[^}]` inside a `${...}` expansion, which bash ends at
that brace, so the class the file carried was `[^/}]` and every payload
with a `/` in it read to the idle bound: the held-open case now asserts the
quarter-bound return (262 ms, was 1262) and the brace characters come from
variables. A configured root whose existing component is a symlink out of
the project passed the lexical check, and retention deletes under the root;
the nearest existing ancestor is now resolved with `cd -P` (no process) and
must sit below the physical project, and a root that resolves to the project
itself is refused like `.`. Five cases pin both (53, was 48). The README
says plainly that the Windows parallel-wall figure is owed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
docs/CATALOG.md follows the claude-ops description change (the catalog gate
diffs the generated block against the manifests). The hooks-lifecycle
fixture loses the upstream page's in-page link fragment and its trailing
double blank line, the two markdownlint findings on the branch; the
generator parses table rows only, and its suite and --check are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
Main took claude-ops 0.42.5 for the plugins skill (#3748) while this PR was
open, so this branch's release becomes 0.42.6: the manifest keeps this
branch's description at the new version, the changelog carries main's
0.42.5 entry under this branch's entry re-labeled 0.42.6, and the
retirement record and the hook-telemetry convention note follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
The observability skill's metadata summary changed with the per-session
report, and docs/SKILL-CHEAT-SHEET.md is generated from it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
@kyle-sexton
kyle-sexton merged commit 032708b into main Sep 5, 2026
20 checks passed
@kyle-sexton
kyle-sexton deleted the claude/hook-logging-pipeline-ji6rl5 branch September 5, 2026 14:56
kyle-sexton added a commit that referenced this pull request Sep 5, 2026
… hooks-reference audit (#3761)

Closes #3754

## Summary

The 2026-09-04 doc-alignment audit confirmed
`docs/conventions/hook-observability/README.md` accurate on its channel
claims but found three cap facts the hooks reference states and the
convention did not carry. This PR records them, docs only.

## Fix

A new `### Output caps stated by the reference` subsection under the
three surfaces, re-read 2026-09-05 by the rung-1 route (raw `hooks.md`,
317,632 bytes, first heading and `llms.txt` slug checked, SHA-256
recorded), carrying one four-part upstream-drift record per delta with
the quoted basis:

1. Output over 10,000 characters overflows to a file with a preview and
path; it is not truncated. The typos-format self-cap is named as the
adopting reference.
2. The `additionalContext` cap is per value, with no pool shared across
hooks.
3. The 2,000-character shared cap belongs to `classifierContext`
(PostToolUse only, ignored for background hooks, discarded on read-only
lookups), not to either channel this doc owns. The peer misreading that
filed the typos-format 8,000-character self-cap as a bug is recorded so
the next reader finds the answer here.

Three consequential edits:

- `classifierContext` fired the "Not a UI feature" bullet's recheck
trigger as worded ("adds a hook-output field beyond the three"). Per
upstream-drift's firing procedure the firing is recorded in place, the
rule re-derived unchanged (the field reaches the classifier, not the
user or the model), and the clause narrowed to fields addressed to the
user or the model.
- The `prompt_id` paragraph pointed at #930, which is closed; it now
points at #3750 (the per-session event log that records `prompt_id`) and
#3758 (the envelope-spine promotion).
- The same stale #930 pointer in
`docs/conventions/hook-telemetry/README.md`'s routing note now names
#3758 (Codex finding).

## Verification

- `npx --no-install markdownlint-cli2 --config .markdownlint-cli2.jsonc`
on both files: 0 issues.
- `typos` on both files: clean.
- Acceptance from #3754: `grep -c '10,000'` prints 5, `grep -c
'classifierContext'` prints 2, each occurrence under a dated stamp.
- `scripts/affected-tests.sh --explain`: both files are a recorded
no-suite class (hygiene lane covers them); no shell suite selected.
- No em dash added; the two in the diff's added lines are re-wrapped
pre-existing text.

## Related

- Refs #3750 (the audit's FINDINGS, in the branch history before the
prune commit) and #3758.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme

---------

Co-authored-by: Claude <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Sep 5, 2026
Closes #3752

## Summary

The plugins reference gives `hooks/hooks.json` an optional top-level
`description`, the one place a plugin labels its hooks as a set. The
issue counted it absent in 19 of 20 plugins, but #3727 landed a
description in every file after the issue was filed, so the descriptions
half is already on `main`. What was still missing is the gate that stops
the count drifting back, and this PR adds it. No plugin file changes and
no version bumps.

## Fix

- `scripts/check-hooks-description.sh`: scans
`plugins/*/hooks/hooks.json`, skips a plugin with no hooks, and fails a
file whose top-level `description` is absent, not a string, blank, or
multi-line. A file that does not parse fails closed. Basis and recheck
trigger are in the script header.
- `scripts/check-hooks-description.test.sh`: one fixture per shape
(missing, blank, whitespace, number, null, multi-line, unparsable), a
CRLF-terminated file, a hook-less plugin, a mixed tree naming every
failing file with the summary count, and the live tree.
- `.github/workflows/ci.yml`: the lint lane runs the suite, then the
gate, and reports it into `ci-status` beside the sibling hook gates.

## Verification

- `bash scripts/check-hooks-description.test.sh`: PASS=12 FAIL=0.
`scripts/check-hooks-description.sh` on the live tree: every hooks.json
(20) carries a one-line description.
- `scripts/affected-tests.sh --run` (the ci.yml change selects the whole
`scripts/*.test.sh` set): 149 shell suites passed or were skipped, exit
0.
- actionlint, `scripts/check-lane-coverage.sh --check`,
`scripts/check-discriminating-test-skips.sh`, shellcheck, shfmt, typos:
clean.

## Related

- Refs #3727 (the descriptions) and #3750 (the audit that counted them).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme

---
_Generated by [Claude
Code](https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Sep 5, 2026
… predicate per extension (#3762)

Closes #3751

## Summary

The guardrails PostToolUse `Write|Edit` row spawned its dispatcher,
loaded the library and parsed the payload on every write, and all three
verifiers early-exited unless the file carried one of five extensions.
The row now spawns only for those files. Guardrails 0.32.4 to 0.32.5.

## Fix

- `hooks.json`: the `Write|Edit` PostToolUse row carries one handler per
extension the verifiers accept (`.md`, `.sh`, `.bash`, `.ps1`, `.psm1`),
each with an `if: Edit(*.<ext>)` predicate. The `if` field holds exactly
one rule, so one row per extension is the documented shape, the same one
the six formatter plugins already use. Command, timeout and
`statusMessage` are identical across rows.
- `run-guards.test.sh`: pins the predicate set to the union of the three
verifiers' own `case "$FILE"` gates, read from their source, in both
directions. An extension added to a gate without an `if` row, or an `if`
row with no gate, fails the suite.
- README budget entry, CHANGELOG entry, patch bump.

**The issue's other two rows take no predicate, and the README entry
records why.** `typos-format` scans every file type including
extensionless ones, and its README already records the absence of an
extension gate as deliberate; `eol-normalizer` resolves every path
through the consuming repository's `.gitattributes` with no extension
list. Their cost on a `.txt` is work they are meant to do, not a spawn
that early-exits, so the issue's "nothing to say about" premise holds
only for the trio.

## Verification

- `scripts/affected-tests.sh --run`: `run-guards.test.sh` PASS=77
FAIL=0. Against the un-gated `hooks.json` the new cases fail (5 FAILs,
checked by stashing the file), so the pin is live.
- Measured on this host, N = 15, spawn floor 3.2 ms, real file text in
every payload:

| Per tool call | before | after |
|---|---|---|
| PostToolUse `Write` of an in-repo `.txt` | 86.1 ms (26.9 S) | 0
processes |
| PostToolUse `Write` of an in-repo `.md` | 95.8 ms (29.9 S) | unchanged
|

The after figure is not a faster process: the predicate is evaluated
before the spawn ("The hook command only runs if the tool call matches
the pattern", hooks reference `if` field, raw `hooks.md` fetched
2026-09-05). The issue's acceptance asked for a PATH-shim spawn counter
per hook; a shell suite cannot observe Claude Code's pre-spawn
evaluation, so the pin is on the predicate set instead, and the no-spawn
rests on the quoted documented semantics and the fleet's existing
measured use of the same shape.
- `scripts/check-changelog-parity.sh --check-bump origin/main`,
`scripts/check-killswitch-hoist.sh`, `scripts/check-hook-exec-form.sh`,
`scripts/check-hook-userconfig-argv.sh`, `scripts/validate-plugins.sh`:
all pass. shellcheck, shfmt, markdownlint, typos clean on the changed
files.

### Renumbered 0.32.4 to 0.32.5

#3770 landed a fleet-wide prompt audit that bumped 64 plugins, taking
guardrails 0.32.4 for itself. `main` was merged in, this change
renumbered above it, and the CHANGELOG conflict resolved so #3770's
released 0.32.4 entry stays intact with this change's entry above it.
The README budget heading moved with it. All gates re-run on the merged
head.

## Related

- Refs #3685 (the trio's remaining cost once gated), #3749 and #3750
(where the audit figures were taken), #3770 (took 0.32.4).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme

---
_Generated by [Claude
Code](https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Sep 5, 2026
… fills (#3765)

Closes #3755

## Summary

The per-session observability report's "Rewrote" block reads a `changed`
boolean that the reference sink already copies from `data.changed`, and
no producer sent it, so the block rendered the no-data line in every
report. The eight rewriting formatters now send it.

## Fix

- **`lib/rewrite-guard.sh`** (synced into its six carriers): the take
records a byte verdict in `HOOK_REWRITE_CHANGED`: `true` when the file
differs from the snapshot, `false` when identical or when no rewrite was
attempted (begin never ran), and empty when begin could not snapshot.
The first take's verdict survives a later take, so a producer that emits
after its take still reads it. Five new cases in
`lib/rewrite-guard.test.sh` (22/22).
- **Six carriers** (`bash-format`, `biome-format`, `eol-normalizer`,
`go-format`, `powershell-format`, `ruff-format`): `build_data_json` adds
`changed` from the verdict and omits the key when it is unknown, never
guessing. Every arm that emitted telemetry before taking the disclosure
(biome, go and ruff clean arms; go, powershell and ruff findings and
tool-break arms) now takes first, with stdout unchanged. `bash-format`
takes unconditionally before its emit so a run where shfmt never ran (no
`.editorconfig` opt-in) reports `false` rather than nothing.
- **`markdown-format`** derives it from the same "Attempted: N fixes"
count line that drives its user-channel disclosure; **`typos-format`**
from whether `applied` is non-empty, and its clean arm sends `false`.
Both omit the key on skip arms where the tool never ran.
- **Eight `docs/conventions/hook-telemetry/data/*.schema.json`** gain
the optional `changed` boolean (additive rule).
- **`claude-ops`**: the observability context now renders a row per
`changed == true` envelope, `_nothing rewritten_` when rows carry the
key and every value is false, and the no-data line only when no row
carries it; the suite fixture carries one `changed: true` and one
`changed: false` row, and the case that asserted the block empty now
asserts the file is named (60/60).
- Nine patch bumps with CHANGELOG entries: bash-format 0.7.37,
biome-format 0.6.35, eol-normalizer 0.6.36, go-format 0.3.39,
powershell-format 0.7.38, ruff-format 0.6.36, markdown-format 0.11.45,
typos-format 0.6.43, claude-ops 0.42.10.

`actionlint` is named in the issue but does not rewrite files, so it is
left alone.

## Fix (2): a description regression on `main`

Merging `main` after #3770 surfaced a defect that is not this change's.
#3770 reverted `plugins/claude-ops/.claude-plugin/plugin.json`'s
`description` to a pre-#3750 state, dropping 730 characters: the
per-session hook event log, the setup action's log-root and
self-ignoring-guard wording, the sink's per-session routing, and
`audit-performance`'s Windows kernel-object census. Sentence-level
comparison of the pre-#3770 and post-#3770 descriptions shows #3770
contributed **no new wording** to this field, so it is a clobber rather
than an edit.

The conflict resolution restores the full text and regenerates
`docs/CATALOG.md` from it, since the catalog is generated from plugin
descriptions and would otherwise fail `scripts/validate-plugins.sh`.

## Verification

- `scripts/affected-tests.sh --run`: all 14 selected suites passed or
were skipped. Suites run with their tool on this host: bash-format
54/54, eol-normalizer 54/54, ruff-format 63/63, typos-format 148/148,
markdown-format 174/174, rewrite-guard 22/22, claude-observability
60/60. Each pins `changed` on a rewriting run and a no-op run.
- Re-run on the merged head after the #3770 renumber: rewrite-guard
22/22, claude-observability 60/60, typos-format 148/148.
- Not verified on this host: biome-format and go-format skip (no
`biome`, no `goimports`), and powershell-format's behavioral cases skip
(no PSScriptAnalyzer). Their new cases assert `false` on the existing
lint-finding fixtures (already-formatted input) and `true` on a new
unformatted fixture for biome and go; powershell asserts the key is a
boolean, since its formatter settings decide the value. The `test-linux`
lane is where those run.
- `scripts/sync-rewrite-guard.sh --check`,
`scripts/check-changelog-parity.sh --check-bump origin/main`, `--check`
, `scripts/check-shell-portability.sh origin/main`,
`scripts/validate-plugins.sh`: all pass on the merged head. shellcheck,
shfmt, markdownlint, typos clean on the changed files (the one shfmt
note in `markdown-format.test.sh` is a pre-existing line this PR does
not touch).

### Renumbered 0.42.9 to 0.42.10

#3770 bumped 64 plugins and took claude-ops 0.42.9. `main` was merged
in, this change renumbered above it, and the CHANGELOG conflict resolved
so #3770's released 0.42.9 entry stays intact with this change's entry
above it. None of the eight formatter plugins were touched by #3770, so
their numbers are unchanged.

## Related

- Refs #3750 (the report and the `changed` route), #3408 (the formatter
prologue where this emission would otherwise be repeated; landing it in
the shared rewrite guard is the same consolidation for the six
carriers), #3410 (the schema additions are hand-maintained until that
lands), #3770 (took 0.42.9 and dropped the description restored here).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme

---------

Co-authored-by: Claude <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Sep 5, 2026
…e at 1.1 (#3769)

Closes #3758

Its two bases (#3765, #3762) have merged, `main` is merged in, and the
diff is reduced to this change alone.

## Summary

The reference sink files an envelope per session only when the envelope
carries a session id, and only the nine claude-ops audit hooks sent one,
so the per-session report's "hooks fired" table never listed the
formatters or the guards. The library now puts the payload's correlation
keys on the envelope spine for every producer, at contract 1.1.

## Fix

- **`lib/hook-utils.sh`** (synced into its 17 carriers):
`hook::emit_telemetry` reads the payload the producer buffered
(`HOOK_TELEMETRY_PAYLOAD` when set, else `INPUT`, the variable every
fleet hook assigns from `hook::buffer_stdin`) and copies `session_id`,
`prompt_id`, `tool_use_id` and `agent_id` onto the envelope between
`duration_ms` and `data`, each only when present as a plain id
(`[A-Za-z0-9._-]+`), on both the builtin and the jq path. No jq, no
subprocess, no producer change. `schema_version` reads `1.1`.
- **`docs/conventions/hook-telemetry`**: `envelope.schema.json` gains
the four optional properties; the README gains a "Correlation keys"
section and rewrites the sink-routing note; the contract CHANGELOG
records 1.1 as an additive minor.
- **Sink** (`plugins/claude-ops/hooks/hook-telemetry-sink.sh` and the
repo-local `.claude/hooks/` copy): routes on the spine `session_id`
first and falls back to `data.session_id`, so envelopes from 1.0
producers keep their route.
- **Seventeen carrier bumps** with CHANGELOG entries: actionlint 0.8.37,
autonomy 0.22.28, bash-format 0.7.38, biome-format 0.6.36, claude-ops
0.42.11, context-guard 0.7.44, desktop-notification 0.6.31,
eol-normalizer 0.6.37, go-format 0.3.40, guardrails 0.32.7,
instruction-placement 0.11.28, markdown-format 0.11.46,
powershell-format 0.7.39, rate-limit-guard 0.7.35, ruff-format 0.6.37,
source-control 0.55.54, typos-format 0.6.44.

## The keys are selected by depth

Review found that searching the raw payload takes the leftmost match
anywhere in it. Two failures, both reproduced against the first version
of this branch:

-
`{"session_id":"sess-real","tool_input":{"options":{"prompt_id":"NESTED-WRONG"}}}`
emitted `"prompt_id":"NESTED-WRONG"`.
-
`{"tool_input":{"n":{"session_id":"NESTED-WRONG"}},"session_id":"sess-real"}`
emitted `"session_id":"NESTED-WRONG"` — not a mis-join, the row lands in
**another session's** `sessions/<id>.jsonl`.

Cutting the search at the first nested container fixed those and broke
something else: the documented payload places `tool_use_id` **after**
`tool_input` (`session-event-log.sh` says so in its early-stop note,
"tool_input closed, tool_use_id still to come"), so the cut dropped it
on every tool event, and the fixture added alongside listed the four ids
up front so it passed anyway.

So the keys are selected by depth instead. Escapes are neutralized, the
payload is split on the quote character, and the alternating fields are
walked — even fields structure, odd fields string bodies. A string body
is kept only at depth 1, so nested objects collapse to brace-and-colon
rubble carrying no quotes and no nested key can match, while a root key
after a container is still reached. The walk costs one step per string,
not one per byte, and what it renders is short, so the four searches run
over a small string whatever the payload size.

## Bounded, and the gap is filed

The neutralizing passes are superlinear in escape count. Per emit,
escape-bearing payload, this container:

| payload | ungated walk | shipped |
|---|---|---|
| 16 KiB | 4 ms | 4 ms |
| 64 KiB | 13 ms | 4 ms |
| 128 KiB | 38 ms | 6 ms |
| 512 KiB | **486 ms** | **22 ms** |

The walk is gated at 65536 bytes; past it the payload takes the head
cut. That stays safe at any size — nothing nested is reachable — but it
is not complete: a root key after the first container is omitted above
the gate, so `tool_use_id` is dropped on payloads over 64 KiB.
`session_id` and `prompt_id` lead the payload, so routing is unaffected.
**#3784** carries that gap with these measurements and two candidate
approaches, and the code comment points at it.

## Verification

- `lib/hook-utils.test.sh` **303/303**. The suite discriminates against
both wrong versions: **3 failures against the un-anchored original** and
**3 against the truncating fix**. New cases cover the documented key
order (`tool_use_id` after `tool_input`), a nested decoy with no root
key, a nested key ahead of the root one, decoys inside a root array, a
multi-megabyte payload, and the size-gate boundary.
- `hook-telemetry-sink.test.sh` 41/41, `api-error-audit.test.sh` 11/11,
`run-guards.test.sh` 99/99, `claude-observability.test.sh` 60/60,
`lib/rewrite-guard.test.sh` 22/22.
- `scripts/affected-tests.sh --run`: one failure, not this PR's —
`block-hook-bypass.test.sh`, "symlink: a genuine temp write in the same
root stays allowed". Reproduced identically on unmodified `main` at
`73eb4d98` in a clean worktree (PASS=601 FAIL=1).
- `scripts/sync-hook-utils.sh --check`, `scripts/sync-rewrite-guard.sh
--check`, `scripts/check-changelog-parity.sh --check-bump origin/main`,
`--check`, `--check-preserved origin/main`,
`scripts/validate-plugins.sh`, `scripts/check-shell-portability.sh
origin/main`, `scripts/check-killswitch-hoist.sh`: all pass. shellcheck
clean; the one shfmt hunk is pre-existing (`main` carries 49, this
branch 1, and it is not in the changed region).

## Renumbered three times

#3770 (fleet-wide prompt audit) bumped 64 plugins and took eight of the
numbers this branch claimed; #3762 and #3765 then took guardrails 0.32.5
and claude-ops 0.42.10; #3781 then took guardrails 0.32.6. Every one of
the seventeen was re-derived as the next patch above `origin/main` and
verified against `git show
origin/main:plugins/<p>/.claude-plugin/plugin.json` after each move.
Each CHANGELOG conflict was resolved by keeping the released entry at
its own heading and lifting this change's entry above it;
`--check-preserved origin/main` passing is what proves no released entry
was dropped.

## Related

- Refs #930 (closed, the thread this finishes), #3750 (the nine-hook
step and the sink route), #3765 and #3762 (the bases, now merged), #3770
/ #3781 (took claimed numbers), #3784 (the size-gate gap), #3410, #3408.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants