feat(claude-ops): per-session hook logging pipeline, default off - #3750
Conversation
…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
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 finished @kyle-sexton's task in 3m 46s —— View job Security review (
|
|
Last security-reviewed head: |
There was a problem hiding this comment.
💡 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".
|
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
… 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>
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>
… 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>
… 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>
…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>

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.jsonlunder.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, addsSessionEndretention that fits the 1.5 s budget, givessetupanapplyfor 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
hooks/session-log-lib.sh(sourced, nohook-utils.sh): root resolution fromsession_event_log_dir(default.observability/claude) with containment checked lexically and physically (the nearest existing ancestor is resolved withcd -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.gitignoreguard (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.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.hooks/session-retention.sh(SessionEnd, notimeout, no stdin): keep newestsession_log_keep_sessionsOR younger thansession_log_keep_days, four spawns; withsession_log_pre_prune_command, doomed files move toprune-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.scripts/gen-hook-event-registry.sh:--fetch | --from <file> | --check; writeshooks/hook-events.registry.json(33 events, 30 observable;WorktreeCreate,MessageDisplay,FileChangedexcluded 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 inhooks.jsonpreserving the nine audit handlers.--checkis offline.data.session_idroutes tosessions/<id>.jsonlin the spine shape (source: "envelope",changedwhen sent); any other envelope to<root>/hook-events.jsonlin the legacy shape under its lock. The nine audit hooks senddata.session_id; seven data schemas gain the optional key.setup:check | apply. Probe 5 reports the root, containment, root-equivalence refusal, and the guard; probe 6 is the fixed retired-conventions line.applywrites exactly<root>/.gitignoreand reads back the tracked-versus-ignored pair.retirements.yamlgainsclaude-ops-r001(.claude/observability/hook-events.jsonl,migrate); the helper copy is enrolled inscripts/sync-check-retirements.sh; one eval per record plus the guard-only and root-refusal evals.observability: every whole-root query readssessions/*.jsonlplus the shared file through oneHOOK_NORMprelude;session(newest by mtime) andsession:<id>render a per-session report (hooks fired, blocked, rewrote, per-hook duration, event timeline); every report ends with the six lines ofprobe-observability-state.sh --pipeline(root, guard, sessions, shared, prune-pending, toggles). The probe gains--rootand--pipeline(the rendered options arrive as flags because a skill subprocess inherits noCLAUDE_PLUGIN_OPTION_*);clean.shgains--hook-root, prunes the root's shared file, removes session files untouched for the window, and sweeps staleprune-pending/sets whether or not the switch is on.userConfigkeys and the regenerated README options table;.gitignoregains.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.mdanddocs/SKILL-CHEAT-SHEET.mdregenerated.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):
session-event-logdisabled (the default)tool_responsesession_idSessionEnd).gitignoreandsessions/cycle-1.jsonlwith 20 parsing lines;git statuscleanSuites (all beside their scripts):
session-event-log.test.sh53,session-retention.test.sh20,hook-telemetry-sink.test.sh38 plus the repo-local drift check,audit-session-id.test.sh27,gen-hook-event-registry.test.sh25,probe-observability-state.test.sh48,claude-observability.test.sh57 (was 33), the guardrailsskill-reference-verifysuite 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 issession-flow'ssave_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 onorigin/mainin 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, andscripts/validate-plugins.shall 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
PostToolUserows 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 atSessionEnd; the pre-prune command runs detached; the guard heals on first write inside the plugin-owned root and the project root is refused; onlyhook-events.jsonlmigrates, the skill-usage and OTEL stores stay.Phases
data.session_idon the nine audit hooks, the six options.--checkin the suite, the regeneratedhooks.json.SessionEndhook, the prune-pending move-aside,clean's sweep.applyfor the guard, the retirement record and its helper copy, the evals.Follow-ups this PR leaves
>>appends to one session file at 4 KB lines;ls -ttie order on NTFS for retention's "newest N" and the reader'ssessionscope; the late-EOF slice cost against the 262 ms Linux figure; the peer-reported 11 s coldcli-flag-verifyrun.data.session_idon every producer and the envelope 1.1 spine promotion; until then per-session hook duration covers the nine claude-ops audit hooks.changedis a defined per-session key no formatter emits yet; the "rewrote" block of the per-session report stays empty until one does.Related
data.session_idon every producer and theschema_version1.1 spine promotiondocs/conventions/hook-observability/README.mdanddocs/conventions/hook-telemetry/README.md, both amended heredocs/conventions/retired-conventions/README.md, whose two fixed setup lines and eval-per-record rule the setup change followslib/hook-utils.shand its 17 vendored copies are untouched🤖 Generated with Claude Code
https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme