diff --git a/.claude/hooks/hook-telemetry-sink.sh b/.claude/hooks/hook-telemetry-sink.sh index 941be9a469..68c9d17be0 100755 --- a/.claude/hooks/hook-telemetry-sink.sh +++ b/.claude/hooks/hook-telemetry-sink.sh @@ -1,12 +1,27 @@ #!/usr/bin/env bash # Reference telemetry sink for claude-ops. Maps a hook-telemetry envelope (from -# ANY producer, per docs/conventions/hook-telemetry) into one JSONL line in -# .claude/observability/hook-events.jsonl — the shape the claude-observability -# skill reads ({ts, event, hook, tool, duration_ms, exit_code, subject, status}). +# ANY producer, per docs/conventions/hook-telemetry) into one JSONL line under +# the log root (.observability/claude by default, project-relative; the +# session_event_log_dir option moves it). +# +# Two routes, decided by the envelope's `data.session_id`: +# * present and well-formed: one spine-shaped line appended to +# sessions/.jsonl, beside the per-session event log +# (session-event-log.sh); `source: "envelope"` tells the reader which +# producer wrote it. No lock: one file per session removes the shared +# write. +# * absent: the legacy shape ({ts, event, hook, tool, duration_ms, exit_code, +# subject, status}) appended to hook-events.jsonl under the same root, the +# shared file the observability skill has always read, under its lock. # # Field mapping: ts<-timestamp, event<-hook_event, hook<-hook, tool<-data.tool, -# subject<-data.subject; status translates (ok->success) and exit_code derives -# from status (error/blocked->2, else 0), since the skill keys errors on it. +# subject<-data.subject, changed<-data.changed (when a producer sends one); +# status translates (ok->success) and exit_code derives from status +# (error/blocked->2, else 0), since the skill keys errors on it. +# +# The root carries a self-ignoring .gitignore inside a checkout, healed on the +# first write when absent (session-log-lib.sh); a guard an operator changed is +# respected and the write refused. # # Wire it by pointing HOOK_TELEMETRY_SINK at this script (relative path committed # in settings.json is the portable, team-shared form): @@ -27,6 +42,8 @@ set -uo pipefail # the sibling pr-linkage-mcp-gate.sh reaching shared sources by relative path). # shellcheck source=../../lib/hook-utils.sh source "$(dirname "${BASH_SOURCE[0]}")/../../lib/hook-utils.sh" +# shellcheck source=../../plugins/claude-ops/hooks/session-log-lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/../../plugins/claude-ops/hooks/session-log-lib.sh" INPUT=$(cat) [[ -n "$INPUT" ]] || exit 0 @@ -42,9 +59,11 @@ command -v jq >/dev/null 2>&1 || exit 0 mapfile -t FIELDS < <(printf '%s' "$INPUT" | jq -r ' if (.hook and .hook_event and (.duration_ms != null) and .status) then (.timestamp // ""), .hook_event, .hook, (.data.tool // ""), - (.duration_ms | tostring), (.data.subject // ""), .status + (.duration_ms | tostring), (.data.subject // ""), .status, + (.data.session_id // "" | tostring), + (.data.changed | if . == true then "true" elif . == false then "false" else "" end) else empty end' 2>/dev/null | tr -d '\r') -[[ "${#FIELDS[@]}" -eq 7 ]] || exit 0 +[[ "${#FIELDS[@]}" -eq 9 ]] || exit 0 TS="${FIELDS[0]}" EVENT="${FIELDS[1]}" @@ -53,6 +72,8 @@ TOOL="${FIELDS[3]}" DURATION_MS="${FIELDS[4]}" SUBJECT="${FIELDS[5]}" STATUS="${FIELDS[6]}" +SESSION_ID="${FIELDS[7]}" +CHANGED="${FIELDS[8]}" # Translate the envelope status to the record shape the skill reads. Known # values pass through (ok → success); an unrecognized value is treated as a @@ -85,8 +106,34 @@ blocked) esac project_dir=$(hook::repo_root "${CLAUDE_PROJECT_DIR:-.}") -log_dir="${project_dir%/}/.claude/observability" -mkdir -p "$log_dir" 2>/dev/null || exit 0 +root="" +slog_root_to root "$project_dir" +[[ -n "$root" ]] || exit 0 +slog_guard_ok "$root" "$project_dir" || exit 0 + +if [[ -n "$SESSION_ID" ]] && slog_valid_id "$SESSION_ID"; then + [[ -d "$root/sessions" ]] || mkdir -p "$root/sessions" 2>/dev/null || exit 0 + LINE=$(MSYS_NO_PATHCONV=1 jq -nc \ + --arg ts "$TS" \ + --arg session_id "$SESSION_ID" \ + --arg event "$EVENT" \ + --arg hook "$HOOK" \ + --arg tool "$TOOL" \ + --argjson duration_ms "${DURATION_MS:-0}" \ + --argjson exit_code "${EXIT_CODE:-0}" \ + --arg subject "$SUBJECT" \ + --arg status "$STATUS_OUT" \ + --arg changed "$CHANGED" \ + '{ts: $ts, session_id: $session_id, hook_event_name: $event, status: $status, + duration_ms: $duration_ms, source: "envelope", hook: $hook, + exit_code: $exit_code, subject: $subject, tool: $tool} + + (if $changed == "" then {} else {changed: ($changed == "true")} end)' 2>/dev/null) || exit 0 + [[ -n "$LINE" ]] || exit 0 + printf '%s\n' "$LINE" >>"$root/sessions/$SESSION_ID.jsonl" 2>/dev/null + exit 0 +fi + +mkdir -p "$root" 2>/dev/null || exit 0 LINE=$(MSYS_NO_PATHCONV=1 jq -nc \ --arg ts "$TS" \ @@ -102,6 +149,6 @@ LINE=$(MSYS_NO_PATHCONV=1 jq -nc \ subject: $subject, status: $status}' 2>/dev/null) || exit 0 [[ -n "$LINE" ]] || exit 0 -hook::append_jsonl "${log_dir}/hook-events.jsonl" "$LINE" +hook::append_jsonl "${root}/hook-events.jsonl" "$LINE" exit 0 diff --git a/.claude/hooks/hook-telemetry-sink.test.sh b/.claude/hooks/hook-telemetry-sink.test.sh index bce6b95b78..096c2964c4 100755 --- a/.claude/hooks/hook-telemetry-sink.test.sh +++ b/.claude/hooks/hook-telemetry-sink.test.sh @@ -16,8 +16,14 @@ repo_root="$(cd "$here/../.." && pwd)" local_copy="$here/hook-telemetry-sink.sh" upstream="$repo_root/plugins/claude-ops/hooks/hook-telemetry-sink.sh" -[[ -f "$local_copy" ]] || { echo "FAIL: missing $local_copy"; exit 1; } -[[ -f "$upstream" ]] || { echo "FAIL: missing $upstream"; exit 1; } +[[ -f "$local_copy" ]] || { + echo "FAIL: missing $local_copy" + exit 1 +} +[[ -f "$upstream" ]] || { + echo "FAIL: missing $upstream" + exit 1 +} # Strip the resolution block from both: the shellcheck source directive, the # source line itself, and the repo-copy-only comment lines that explain it. @@ -50,9 +56,9 @@ fi smoke_dir="$(mktemp -d)" trap 'rm -f "$norm_upstream" "$norm_local"; rm -rf "$smoke_dir"' EXIT -printf '%s' '{"schema":"hook-telemetry/v1","hook":"drift-test-smoke","hook_event":"PreToolUse","duration_ms":1,"status":"ok","data":{"tool":"Bash","subject":"smoke"}}' \ - | CLAUDE_PROJECT_DIR="$smoke_dir" bash "$local_copy" -if [[ -s "$smoke_dir/.claude/observability/hook-events.jsonl" ]]; then +printf '%s' '{"schema":"hook-telemetry/v1","hook":"drift-test-smoke","hook_event":"PreToolUse","duration_ms":1,"status":"ok","data":{"tool":"Bash","subject":"smoke"}}' | + CLAUDE_PROJECT_DIR="$smoke_dir" bash "$local_copy" +if [[ -s "$smoke_dir/.observability/claude/hook-events.jsonl" ]]; then echo "PASS: sink executes and appends a record (source target resolves)" else echo "FAIL: sink ran but appended no record — source target or mapping broken" diff --git a/.gitignore b/.gitignore index 9461a821ac..7a2fd0cfaa 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ Thumbs.db # Local-only Claude Code state .claude/settings.local.json .claude/observability/ +.observability/ .claude/worktrees/ # loop-lane escalation records (docs/conventions/loop-lane/README.md §2 — signal, not storage) .claude/lane-escalations/ diff --git a/docs/CATALOG.md b/docs/CATALOG.md index f7c74d6226..3c020d279d 100644 --- a/docs/CATALOG.md +++ b/docs/CATALOG.md @@ -82,7 +82,7 @@ plugin manifests and kept in sync by CI — never hand-edit it; the category voc - [`playbooks`](../plugins/playbooks) — Doctrine and knowledge playbooks as on-demand skills, plus a maintainer-facing update skill. boris — Boris Cherny's Claude Code workflow tips (howborisusesclaudecode.com); skill-authoring — Anthropic's internal skill-authoring playbook; fable-5 — Claude Fable 5's operating doctrine (self-authored, no upstream). The boris and skill-authoring packs vendor a verbatim upstream baseline; /playbooks:update drift-checks and syncs those baselines centrally (maintainers). - [`claude-config`](../plugins/claude-config) — Nine configuration-health skills (plus setup) for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), audit-automation-gaps (evidence-gated verdicts on automation gaps), audit-permission-grants (allow-rule / allowed-tools grants for auto-mode durability and portability), audit-permission-state (the permission rules actually in effect — every settings scope merged with per-rule provenance, what auto mode drops on entry, config written where nothing reads it, and which managed intents are enforced versus loosenable), draft-auto-mode-rules (interview and draft a paste-ready autoMode classifier block; prints only, never writes), audit-instructions (locally-owned instruction surfaces vs current model capability — proposes removals/rewrites of instructions the model no longer needs, and detects cross-surface instruction conflicts), audit-prompting-postures (the additive lane — posture guidance the prompting guide says a component's purpose needs but the component does not carry), audit-pass (one coordinated, ordered, resumable pass over a named target — three-scope inventory, run-time-derived exclusion set, stable finding identity, suppression memory, resume, one human gate — delegating every check to the plugin that owns it), and unhobble (the empirical bare-baseline experiment: reversibly strip a repo's standing instructions, log real stumbles against the current model, re-add only what evidence earns). - [`claude-memory`](../plugins/claude-memory) — Keeps a repo's Claude Code memory layer healthy and under your control, against criteria derived from official Claude Code documentation. The audit skill checks the instruction/memory layer (CLAUDE.md, CLAUDE.local.md, .claude/rules/, auto-memory) with a deterministic script-backed spine plus judgment-tier checks. The stateless skill inspects, disables, and (confirm-gated) purges Claude-written auto memory across all settings scopes. -- [`claude-ops`](../plugins/claude-ops) — Claude Code operations toolkit. Twelve skills: audit-skill-visibility (audit whether each installed skill is actually VISIBLE to the model, and diagnose why most of a fleet never gets used — a skill is invisible when its description is dropped by Claude Code's skill-listing context budget, which sheds descriptions lowest-score-first so an unused skill loses the keywords that would let it be matched, from skills genuinely not wanted, from skills the run cannot observe at all; computes whether the listing overflows from documented settings, and withholds every cold verdict the data cannot support rather than reporting absence of data as absence of use), inventory (read-only enumeration of the complete invocable surface — every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json — full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow: CLI version, retention-sweep health including the silent unparsable-settings pause, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and the fan-out layer, which covers a load-labelled no-op spawn baseline, every hook that will fire bucketed per-tool-call versus per-turn with its invocation shape, the configured statusline, subagent concurrency and spawn-depth ceilings against documented defaults, whether running sessions predate the settings file they are judged by, and orphan attribution by parent liveness rather than age, plus on Windows a kernel-object census (Token objects against uptime, paged pool) that names a host-level leak beneath all four suspects; read against a bundled known-performance-issues reference that also records the causes tested and cleared; separates the four documented suspects of accumulated state, version regression, component bloat, and per-spawn fan-out cost, and routes remediation out; reports, never mutates, and never executes a discovered hook or statusline command), audit-native-overlap (map native Claude Code surfaces — built-in CLI commands, bundled skills, plugin-backed built-ins, session-provided skills — against the current repo's plugin skills and agents, so a custom component never silently duplicates what Claude Code itself ships; bare invocation is a read-only overlap report carrying the extraction's integrity floors and a shared-listing-budget exposure section, verdicts are human-gated in a committed store rendered into a generated registry whose every row carries an observable recheck trigger, and only an explicit apply step bakes presence-gated native references into descriptions and Boundary sections), observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view — queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action — an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures — the last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads. +- [`claude-ops`](../plugins/claude-ops) — Claude Code operations toolkit. Twelve skills: audit-skill-visibility (audit whether each installed skill is actually VISIBLE to the model, and diagnose why most of a fleet never gets used — a skill is invisible when its description is dropped by Claude Code's skill-listing context budget, which sheds descriptions lowest-score-first so an unused skill loses the keywords that would let it be matched, from skills genuinely not wanted, from skills the run cannot observe at all; computes whether the listing overflows from documented settings, and withholds every cold verdict the data cannot support rather than reporting absence of data as absence of use), inventory (read-only enumeration of the complete invocable surface — every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json — full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow: CLI version, retention-sweep health including the silent unparsable-settings pause, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and the fan-out layer, which covers a load-labelled no-op spawn baseline, every hook that will fire bucketed per-tool-call versus per-turn with its invocation shape, the configured statusline, subagent concurrency and spawn-depth ceilings against documented defaults, whether running sessions predate the settings file they are judged by, and orphan attribution by parent liveness rather than age, plus on Windows a kernel-object census (Token objects against uptime, paged pool) that names a host-level leak beneath all four suspects; read against a bundled known-performance-issues reference that also records the causes tested and cleared; separates the four documented suspects of accumulated state, version regression, component bloat, and per-spawn fan-out cost, and routes remediation out; reports, never mutates, and never executes a discovered hook or statusline command), audit-native-overlap (map native Claude Code surfaces — built-in CLI commands, bundled skills, plugin-backed built-ins, session-provided skills — against the current repo's plugin skills and agents, so a custom component never silently duplicates what Claude Code itself ships; bare invocation is a read-only overlap report carrying the extraction's integrity floors and a shared-listing-budget exposure section, verdicts are human-gated in a committed store rendered into a generated registry whose every row carries an observable recheck trigger, and only an explicit apply step bakes presence-gated native references into descriptions and Boundary sections), observability (read locally captured telemetry — OTEL store, collector, the per-session hook event log and hook-event JSONL, ccusage — with trend reports, a per-session report of what fired, what was blocked and the event timeline, and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view — queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action — an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry, the skill-usage log and the hook log root live, places the root's self-ignoring guard, and detects retired conventions. Plus an opt-in, default-off per-session hook event log (one JSON line per hook event on every event the generated registry marks observable, written to /sessions/.jsonl, with SessionEnd retention by session count or age and an optional detached pre-prune command), a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures — the last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) that emit the shared hook-telemetry envelope, and a reference sink that routes envelopes under the same root: per session when the envelope carries a session id, else into the shared hook-events.jsonl the observability skill reads. - [`rate-limit-guard`](../plugins/rate-limit-guard) — Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume. - [`context-guard`](../plugins/context-guard) — Per-session context-window observability plus the first shipped consumer: a statusline wrapper tees each session's context_window fields to a per-session snapshot file, a zone resolver classifies usage into smart/acceptable/dumb bands (percentage bands plus window-class token bands, conservative-min combination, zones.json SSOT with shipped defaults), a reader contract fixes how consuming sessions interpret the snapshots, and zone-crossing hooks report once per transition into a worse zone across two channels — the continuation menu to the operator, who owns that choice, and to the model only the zone determination plus the counter-steer that a zone word is not a decay signal (advisory by default; an optional blocking mode gates new mutating work on a fresh dumb-zone snapshot with handoff-writing exempt), with a PostCompact hook persisting an evidence-degraded marker. - [`context-budget`](../plugins/context-budget) — Measure a Claude Code session's fixed startup context payload per item, on the consumer's machine at a pinned, version-stamped binary — including per-tool attribution of the built-in tool pools that /context reports only as lump sums, derived live by A/B bare-name-deny differencing with enforced comparability rules (skill-listing signature, one mode, one binary), an SDK-primary exact meter degrading to a version-aware headless /context parser and then to an honest structured error (never a wrong number), and a per-project measure-toggle-remeasure ledger under the plugin data directory recording every lever's real before/after delta. Report-only: prints exact config, applies nothing. diff --git a/docs/SKILL-CHEAT-SHEET.md b/docs/SKILL-CHEAT-SHEET.md index fb2d1d2532..aff66e4d2e 100644 --- a/docs/SKILL-CHEAT-SHEET.md +++ b/docs/SKILL-CHEAT-SHEET.md @@ -248,7 +248,7 @@ owned by [docs/CATALOG-TAXONOMY.md](CATALOG-TAXONOMY.md). | [`/claude-ops:inventory`](../plugins/claude-ops/skills/inventory/SKILL.md) | `claude-ops` | weekly | Enumerate every command, skill, agent, and plugin component this machine can invoke | | [`/claude-ops:lanes`](../plugins/claude-ops/skills/lanes/SKILL.md) | `claude-ops` | daily | Start, restart, stop, and check loop lanes as named background sessions | | [`/claude-ops:morning-brief`](../plugins/claude-ops/skills/morning-brief/SKILL.md) | `claude-ops` | daily | Print the operator's read-only morning view. Queues, merge-ready PRs, parked decisions | -| [`/claude-ops:observability`](../plugins/claude-ops/skills/observability/SKILL.md) | `claude-ops` | weekly | Report on locally captured telemetry. Token burn, cost, hook latency, trends | +| [`/claude-ops:observability`](../plugins/claude-ops/skills/observability/SKILL.md) | `claude-ops` | weekly | Report on locally captured telemetry. Token burn, cost, hook latency, per-session activity | | [`/claude-ops:plugins`](../plugins/claude-ops/skills/plugins/SKILL.md) | `claude-ops` | weekly | Bring the machine's plugin fleet current. Refresh, update, install per policy | | [`/repo-fleet-hygiene:apply`](../plugins/repo-fleet-hygiene/skills/apply/SKILL.md) | `repo-fleet-hygiene` | weekly | Execute a fleet action plan behind one confirmation gate | | [`/repo-fleet-hygiene:audit`](../plugins/repo-fleet-hygiene/skills/audit/SKILL.md) | `repo-fleet-hygiene` | weekly | Discover a repository fleet and coordinate read-only evidence handoffs | diff --git a/docs/conventions/hook-observability/README.md b/docs/conventions/hook-observability/README.md index 1dd7cd3d96..af6d784b01 100644 --- a/docs/conventions/hook-observability/README.md +++ b/docs/conventions/hook-observability/README.md @@ -134,6 +134,19 @@ issue #836) that converts the 9 fleet sites currently relying on that leniency regresses between them. Once corrected, a quiet skip must use one of the sanctioned helper calls or an explicit `# silent-skip-ok: ` annotation. +**The annotation, precisely.** `# silent-skip-ok: ` is a comment line placed directly +above the quiet exit it sanctions (the `exit 0` behind a `command -v` gate, or the +`|| exit 0` on a prerequisite test), with the reason stating why no notice channel exists or +why silence is the correct outcome. `scripts/check-silent-skips.sh` reads the annotation: a +gated skip it would otherwise reject passes when the line above it carries the marker, so the +reason is reviewed once, in the diff, rather than re-litigated on every gate run. Two shapes +use it today. A fire-and-forget process whose stdout and stderr the producer discards (the +claude-ops telemetry sink: "the producer side owns prerequisite visibility"), and a hook that +is off by design for the consumer who has not enabled it (the per-session event log's +`session_event_log_enabled: false` default, where a notice would fire on every event of every +session that never asked for logging). A hook that could speak and simply does not is not a +candidate; give it a helper call. + ### 3. OTel-style telemetry envelope Every wired producer hook emits one envelope per meaningful-outcome run via `hook::emit_telemetry` diff --git a/docs/conventions/hook-telemetry/README.md b/docs/conventions/hook-telemetry/README.md index ada85f64ce..30f2b08249 100644 --- a/docs/conventions/hook-telemetry/README.md +++ b/docs/conventions/hook-telemetry/README.md @@ -151,6 +151,17 @@ pretty-printed form. A sink must parse the document as JSON, never by line or by That is the whole consumer contract: any number of independently-written sinks can subscribe to the same producers without coordinating with them or each other. +**Sink routing by session (reference sink, claude-ops 0.42.6).** The envelope's common fields carry +no session identity, and hooks receive none from their environment (only the payload's +`session_id`), so a producer that wants its rows filed per session adds `data.session_id` (the +payload value, verbatim) under the additive rule above. The claude-ops reference sink routes on it: +an envelope carrying a well-formed `data.session_id` is appended to +`/sessions/.jsonl` beside the per-session event log, and an envelope without one +goes to the shared `/hook-events.jsonl` in the legacy shape. Today the nine claude-ops audit +hooks send it. The fleet-wide addition to every producer, and promoting the key into the envelope +spine as `schema_version` 1.1, are the follow-up tracked in #930; until then a whole-root report +covers every producer and a per-session report covers the producers that send the key. + ## Implementers | Producer | `hook` value | Data schema | diff --git a/docs/conventions/hook-telemetry/data/api-error-audit.schema.json b/docs/conventions/hook-telemetry/data/api-error-audit.schema.json index 8a21904c12..d0ea7f4d2e 100644 --- a/docs/conventions/hook-telemetry/data/api-error-audit.schema.json +++ b/docs/conventions/hook-telemetry/data/api-error-audit.schema.json @@ -4,12 +4,19 @@ "title": "api-error-audit telemetry data", "description": "Per-hook `data` payload for the api-error-audit StopFailure emitter. Discovered from the envelope `hook` value \"api-error-audit\". Evolves additive-only. Privacy-safe: carries the StopFailure `error` type label only — never `error_details`, which may contain prompt fragments or session metadata.", "type": "object", - "required": ["subject"], + "required": [ + "subject" + ], "additionalProperties": true, "properties": { "subject": { "type": "string", "description": "The API turn-failure error type from the StopFailure `error` field (e.g. \"rate_limit\", \"billing_error\", \"server_error\")." + }, + "session_id": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "The Claude Code session id from the hook payload, when the payload carried one. The claude-ops reference sink routes an envelope carrying it into the per-session log (sessions/.jsonl under the log root) instead of the shared hook-events.jsonl. Additive since claude-ops 0.42.4; a sink that predates it ignores the key." } } } diff --git a/docs/conventions/hook-telemetry/data/config-change-audit.schema.json b/docs/conventions/hook-telemetry/data/config-change-audit.schema.json index 266c69f468..df3c60f692 100644 --- a/docs/conventions/hook-telemetry/data/config-change-audit.schema.json +++ b/docs/conventions/hook-telemetry/data/config-change-audit.schema.json @@ -4,12 +4,19 @@ "title": "config-change-audit telemetry data", "description": "Per-hook `data` payload for the config-change-audit ConfigChange emitter. Discovered from the envelope `hook` value \"config-change-audit\". Evolves additive-only. Privacy-safe: carries the ConfigChange `source` identifier only (never the changed file's contents).", "type": "object", - "required": ["subject"], + "required": [ + "subject" + ], "additionalProperties": true, "properties": { "subject": { "type": "string", "description": "The mutated configuration source from the ConfigChange `source` field (e.g. \"user_settings\", \"project_settings\", \"local_settings\", \"skills\")." + }, + "session_id": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "The Claude Code session id from the hook payload, when the payload carried one. The claude-ops reference sink routes an envelope carrying it into the per-session log (sessions/.jsonl under the log root) instead of the shared hook-events.jsonl. Additive since claude-ops 0.42.4; a sink that predates it ignores the key." } } } diff --git a/docs/conventions/hook-telemetry/data/instructions-loaded-audit.schema.json b/docs/conventions/hook-telemetry/data/instructions-loaded-audit.schema.json index 5b1e3ca9c1..70a4f362ed 100644 --- a/docs/conventions/hook-telemetry/data/instructions-loaded-audit.schema.json +++ b/docs/conventions/hook-telemetry/data/instructions-loaded-audit.schema.json @@ -4,12 +4,19 @@ "title": "instructions-loaded-audit telemetry data", "description": "Per-hook `data` payload for the instructions-loaded-audit InstructionsLoaded emitter. Discovered from the envelope `hook` value \"instructions-loaded-audit\". Evolves additive-only.", "type": "object", - "required": ["subject"], + "required": [ + "subject" + ], "additionalProperties": true, "properties": { "subject": { "type": "string", "description": "\":\" for the loaded rule/instruction file, so consumers can group loads by reason (e.g. \"path_glob_match\", \"include\") without parsing extra fields. Privacy-safe: the absolute path prefix is stripped to a repo-relative path (or basename for files outside the project root). session_start loads are filtered out at the producer by default." + }, + "session_id": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "The Claude Code session id from the hook payload, when the payload carried one. The claude-ops reference sink routes an envelope carrying it into the per-session log (sessions/.jsonl under the log root) instead of the shared hook-events.jsonl. Additive since claude-ops 0.42.4; a sink that predates it ignores the key." } } } diff --git a/docs/conventions/hook-telemetry/data/permission-denied-audit.schema.json b/docs/conventions/hook-telemetry/data/permission-denied-audit.schema.json index 50012f8379..1f3b8e3f06 100644 --- a/docs/conventions/hook-telemetry/data/permission-denied-audit.schema.json +++ b/docs/conventions/hook-telemetry/data/permission-denied-audit.schema.json @@ -4,7 +4,10 @@ "title": "permission-denied-audit telemetry data", "description": "Per-hook `data` payload for the permission-denied-audit PermissionDenied emitter. Discovered from the envelope `hook` value \"permission-denied-audit\". Evolves additive-only. Privacy-safe: never carries the full command, file path, or tool_input body.", "type": "object", - "required": ["subject", "tool"], + "required": [ + "subject", + "tool" + ], "additionalProperties": true, "properties": { "subject": { @@ -14,6 +17,11 @@ "tool": { "type": "string", "description": "The denied Claude Code tool_name (e.g. \"Bash\", \"Write\", \"Edit\")." + }, + "session_id": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "The Claude Code session id from the hook payload, when the payload carried one. The claude-ops reference sink routes an envelope carrying it into the per-session log (sessions/.jsonl under the log root) instead of the shared hook-events.jsonl. Additive since claude-ops 0.42.4; a sink that predates it ignores the key." } } } diff --git a/docs/conventions/hook-telemetry/data/pre-compact-audit.schema.json b/docs/conventions/hook-telemetry/data/pre-compact-audit.schema.json index 5e9107c6f0..3223e7b45c 100644 --- a/docs/conventions/hook-telemetry/data/pre-compact-audit.schema.json +++ b/docs/conventions/hook-telemetry/data/pre-compact-audit.schema.json @@ -4,12 +4,19 @@ "title": "pre-compact-audit telemetry data", "description": "Per-hook `data` payload for the pre-compact-audit PreCompact emitter. Discovered from the envelope `hook` value \"pre-compact-audit\". Evolves additive-only.", "type": "object", - "required": ["subject"], + "required": [ + "subject" + ], "additionalProperties": true, "properties": { "subject": { "type": "string", "description": "The compaction trigger (\"manual\" or \"auto\")." + }, + "session_id": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "The Claude Code session id from the hook payload, when the payload carried one. The claude-ops reference sink routes an envelope carrying it into the per-session log (sessions/.jsonl under the log root) instead of the shared hook-events.jsonl. Additive since claude-ops 0.42.4; a sink that predates it ignores the key." } } } diff --git a/docs/conventions/hook-telemetry/data/skill-usage-audit.schema.json b/docs/conventions/hook-telemetry/data/skill-usage-audit.schema.json index 70f0fa5e5c..5c9a977b90 100644 --- a/docs/conventions/hook-telemetry/data/skill-usage-audit.schema.json +++ b/docs/conventions/hook-telemetry/data/skill-usage-audit.schema.json @@ -4,7 +4,10 @@ "title": "skill-usage-audit telemetry data", "description": "Per-hook `data` payload for the skill-usage-audit signal. Discovered from the envelope `hook` value \"skill-usage-audit\". Two producers emit it under this one `hook` id: the PostToolUse/Skill emitter (model-invoked Skill tool, `hook_event` PostToolUse) and the UserPromptExpansion emitter (user-typed slash-command / MCP-prompt, `hook_event` UserPromptExpansion); the `source` field tells them apart. Evolves additive-only. Captures the skill/command name only — never the arguments body. Independent of the hook's bespoke skill-usage.jsonl second store.", "type": "object", - "required": ["subject", "skill"], + "required": [ + "subject", + "skill" + ], "additionalProperties": true, "properties": { "subject": { @@ -22,6 +25,11 @@ "expansion_type": { "type": "string", "description": "Expansion kind for the `expansion` source: \"slash_command\" (skill/command) or \"mcp_prompt\". Optional — absent on the `tool` source, and absent on `expansion` events from a CC build that does not supply it." + }, + "session_id": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "The Claude Code session id from the hook payload, when the payload carried one. The claude-ops reference sink routes an envelope carrying it into the per-session log (sessions/.jsonl under the log root) instead of the shared hook-events.jsonl. Additive since claude-ops 0.42.4; a sink that predates it ignores the key." } } } diff --git a/docs/conventions/hook-telemetry/data/tool-failure-audit.schema.json b/docs/conventions/hook-telemetry/data/tool-failure-audit.schema.json index 82c203faa8..fef0e439ed 100644 --- a/docs/conventions/hook-telemetry/data/tool-failure-audit.schema.json +++ b/docs/conventions/hook-telemetry/data/tool-failure-audit.schema.json @@ -4,7 +4,10 @@ "title": "tool-failure-audit telemetry data", "description": "Per-hook `data` payload for the tool-failure-audit PostToolUseFailure emitter. Discovered from the envelope `hook` value \"tool-failure-audit\". Evolves additive-only. Privacy-safe: never carries the full command, error message, file path, or stdin.", "type": "object", - "required": ["subject", "tool"], + "required": [ + "subject", + "tool" + ], "additionalProperties": true, "properties": { "subject": { @@ -14,6 +17,11 @@ "tool": { "type": "string", "description": "The failed Claude Code tool_name (\"Write\", \"Edit\", or \"Bash\")." + }, + "session_id": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "The Claude Code session id from the hook payload, when the payload carried one. The claude-ops reference sink routes an envelope carrying it into the per-session log (sessions/.jsonl under the log root) instead of the shared hook-events.jsonl. Additive since claude-ops 0.42.4; a sink that predates it ignores the key." } } } diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 03e52e1b22..f9ec64c8d4 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.42.5", - "description": "Claude Code operations toolkit. Twelve skills: audit-skill-visibility (audit whether each installed skill is actually VISIBLE to the model, and diagnose why most of a fleet never gets used \u2014 a skill is invisible when its description is dropped by Claude Code's skill-listing context budget, which sheds descriptions lowest-score-first so an unused skill loses the keywords that would let it be matched, from skills genuinely not wanted, from skills the run cannot observe at all; computes whether the listing overflows from documented settings, and withholds every cold verdict the data cannot support rather than reporting absence of data as absence of use), inventory (read-only enumeration of the complete invocable surface \u2014 every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json \u2014 full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow: CLI version, retention-sweep health including the silent unparsable-settings pause, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and the fan-out layer, which covers a load-labelled no-op spawn baseline, every hook that will fire bucketed per-tool-call versus per-turn with its invocation shape, the configured statusline, subagent concurrency and spawn-depth ceilings against documented defaults, whether running sessions predate the settings file they are judged by, and orphan attribution by parent liveness rather than age, plus on Windows a kernel-object census (Token objects against uptime, paged pool) that names a host-level leak beneath all four suspects; read against a bundled known-performance-issues reference that also records the causes tested and cleared; separates the four documented suspects of accumulated state, version regression, component bloat, and per-spawn fan-out cost, and routes remediation out; reports, never mutates, and never executes a discovered hook or statusline command), audit-native-overlap (map native Claude Code surfaces \u2014 built-in CLI commands, bundled skills, plugin-backed built-ins, session-provided skills \u2014 against the current repo's plugin skills and agents, so a custom component never silently duplicates what Claude Code itself ships; bare invocation is a read-only overlap report carrying the extraction's integrity floors and a shared-listing-budget exposure section, verdicts are human-gated in a committed store rendered into a generated registry whose every row carries an observable recheck trigger, and only an explicit apply step bakes presence-gated native references into descriptions and Boundary sections), observability (read locally captured telemetry \u2014 OTEL store, collector, hook-event JSONL, ccusage \u2014 with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand \u2014 marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view \u2014 queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action \u2014 an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures \u2014 the last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", + "version": "0.42.6", + "description": "Claude Code operations toolkit. Twelve skills: audit-skill-visibility (audit whether each installed skill is actually VISIBLE to the model, and diagnose why most of a fleet never gets used \u2014 a skill is invisible when its description is dropped by Claude Code's skill-listing context budget, which sheds descriptions lowest-score-first so an unused skill loses the keywords that would let it be matched, from skills genuinely not wanted, from skills the run cannot observe at all; computes whether the listing overflows from documented settings, and withholds every cold verdict the data cannot support rather than reporting absence of data as absence of use), inventory (read-only enumeration of the complete invocable surface \u2014 every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json \u2014 full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow: CLI version, retention-sweep health including the silent unparsable-settings pause, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and the fan-out layer, which covers a load-labelled no-op spawn baseline, every hook that will fire bucketed per-tool-call versus per-turn with its invocation shape, the configured statusline, subagent concurrency and spawn-depth ceilings against documented defaults, whether running sessions predate the settings file they are judged by, and orphan attribution by parent liveness rather than age, plus on Windows a kernel-object census (Token objects against uptime, paged pool) that names a host-level leak beneath all four suspects; read against a bundled known-performance-issues reference that also records the causes tested and cleared; separates the four documented suspects of accumulated state, version regression, component bloat, and per-spawn fan-out cost, and routes remediation out; reports, never mutates, and never executes a discovered hook or statusline command), audit-native-overlap (map native Claude Code surfaces \u2014 built-in CLI commands, bundled skills, plugin-backed built-ins, session-provided skills \u2014 against the current repo's plugin skills and agents, so a custom component never silently duplicates what Claude Code itself ships; bare invocation is a read-only overlap report carrying the extraction's integrity floors and a shared-listing-budget exposure section, verdicts are human-gated in a committed store rendered into a generated registry whose every row carries an observable recheck trigger, and only an explicit apply step bakes presence-gated native references into descriptions and Boundary sections), observability (read locally captured telemetry \u2014 OTEL store, collector, the per-session hook event log and hook-event JSONL, ccusage \u2014 with trend reports, a per-session report of what fired, what was blocked and the event timeline, and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand \u2014 marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view \u2014 queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action \u2014 an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry, the skill-usage log and the hook log root live, places the root's self-ignoring guard, and detects retired conventions. Plus an opt-in, default-off per-session hook event log (one JSON line per hook event on every event the generated registry marks observable, written to /sessions/.jsonl, with SessionEnd retention by session count or age and an optional detached pre-prune command), a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures \u2014 the last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) that emit the shared hook-telemetry envelope, and a reference sink that routes envelopes under the same root: per session when the envelope carries a session id, else into the shared hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", "email": "info@melodicsoftware.com" @@ -114,6 +114,44 @@ "description": "Idle bound on reading the hook payload from stdin \u2014 how long the pipe may go silent before the hook gives up and fails open", "default": 2, "min": 1 + }, + "session_event_log_enabled": { + "type": "boolean", + "title": "session-event-log hook (per-session hook event log)", + "description": "Append one JSON line per hook event to /sessions/.jsonl, on every documented event the generated registry marks observable. Off by default: a consumer who has not turned it on pays the kill-switch read and nothing else. The same switch gates the SessionEnd retention hook.", + "default": false + }, + "session_event_log_dir": { + "type": "string", + "title": "Hook log root (project-relative)", + "description": "Contained project-relative directory holding the per-session hook event log (sessions/) and the telemetry sink's hook-events.jsonl. Absolute, drive, UNC, traversal and escaping paths are invalid, and the project root itself is refused. Inside a checkout the directory carries a self-ignoring .gitignore, created on the first write. Leave unset to use .observability/claude.", + "default": ".observability/claude" + }, + "session_event_log_categories": { + "type": "string", + "title": "session-event-log categories", + "description": "Comma-separated event categories to record (session, prompt, tool, permission, agent, task, turn, config, worktree, compaction, model, mcp, display, other). Empty records every category the registry marks observable.", + "default": "" + }, + "session_log_keep_sessions": { + "type": "number", + "title": "Retention: sessions to keep", + "description": "At SessionEnd, keep the newest N session files regardless of age (a file is kept when it is among the newest N OR younger than session_log_keep_days).", + "default": 30, + "min": 1 + }, + "session_log_keep_days": { + "type": "number", + "title": "Retention: days to keep", + "description": "At SessionEnd, keep every session file younger than N days regardless of count (a file is kept when it is younger than N days OR among the newest session_log_keep_sessions).", + "default": 14, + "min": 1 + }, + "session_log_pre_prune_command": { + "type": "string", + "title": "Retention: pre-prune command", + "description": "Optional command run detached at SessionEnd with one argument, a directory the session files about to be pruned were moved into; the physical delete of that directory happens on the next retention run after 24 hours, so an archiver has a stable set to read. Executed through `bash -c`, so it is trusted configuration: on current releases project and local pluginConfigs are ignored and only the user's own settings supply it (recheck: the plugins reference's user-configuration section). Leave unset to delete directly.", + "default": "" } } } diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 1117bb9c49..07fc5e84ef 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,82 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.42.6] + +### Added + +- **A per-session hook event log, off by default.** `hooks/session-event-log.sh` + appends one JSON line per hook event to + `/sessions/.jsonl` (default root + `.observability/claude`, project-relative) on every one of the 30 events the + generated registry marks observable: a fixed spine (`ts`, `session_id`, + `hook_event_name`, `category`, `status`, `source`, `duration_ms`) plus the + correlation keys the payload carries (`prompt_id`, `tool_use_id`, `agent_id`, + `tool_name`, a repo-relative `file_path`, `reason`, `traceparent`). It sources + no library, reads stdin in bounded 4 KB slices and stops early only when the + buffer ends in `}`, carries the event name and has balanced braces (the + Win32 late-EOF stall costs one idle slice, not the read bound; a writer that + pauses after a nested `}` is read to the bound, never cut short), records a + file outside the project by its last segment after either separator, and the switch + `session_event_log_enabled` is read before anything else. Measured on the + Linux CI host, N = 15: 2.42 ms disabled against a 2.08 ms bare spawn floor, + 4.5 to 5.75 ms enabled on a 2 KB payload, 35.7 ms on a 512 KB `tool_response`. + `session_event_log_categories` filters by category; `hooks/hook-events.registry.json` + is generated from a live fetch of the hooks reference by + `scripts/gen-hook-event-registry.sh`, which also regenerates the producer rows + in `hooks.json` and excludes `WorktreeCreate`, `MessageDisplay` and `FileChanged` + (each replaces or holds native behavior when registered). +- **`SessionEnd` retention inside the 1.5 s budget.** `hooks/session-retention.sh` + keeps a session file when it is among the newest `session_log_keep_sessions` + (30) OR younger than `session_log_keep_days` (14), in four processes and no + stdin read (4.3 ms over 40 files, 24 ms over 100 with 70 pruned). With + `session_log_pre_prune_command` set, doomed files are moved to + `prune-pending/-/` and the command runs detached with that + directory as its argument; the set is deleted on the first run after 24 h. +- **`setup apply` writes the root's guard; `retirements.yaml` gains `claude-ops-r001`.** + The setup skill leaves the check-only carve-out: probe 5 reports the hook log + root, its containment (checked lexically and physically: a root whose + existing component is a symlink out of the project, or back to the project + root, is refused by every hook that writes or deletes under it), and its + self-ignoring `.gitignore` (`*`), and `apply` + writes exactly that one file, then reads back the tracked-versus-ignored pair. + The hooks heal an absent guard on their first write too, so a fresh clone or + worktree needs no setup run; a guard an operator edited is respected and the + write refused. The retirement record names the old shared-file location + (`.claude/observability/hook-events.jsonl`, `migrate`) with a successor that + appends the old rows under the new root; one eval per record, helper copy + synced by `scripts/sync-check-retirements.sh`. +- **The observability skill reads the root and reports per session.** Every + whole-root query reads `sessions/*.jsonl` plus the shared `hook-events.jsonl` + through one normalizing prelude; `session` (newest file by mtime) and + `session:` render a per-session report (hooks fired, blocked, rewrote, + per-hook duration, the event timeline); every report ends with the toggles, + retention, guard state and stale prune sets in effect, from + `probe-observability-state.sh --pipeline`. `clean` gains `--hook-root`, removes + session files untouched for the window, and sweeps stale `prune-pending/` + sets whether or not the logging switch is on. + +### Changed + +- **The reference sink routes by session.** `hooks/hook-telemetry-sink.sh` (and + the repo-local copy) writes under the hook log root: an envelope carrying a + well-formed `data.session_id` goes to `sessions/.jsonl` in the + spine shape (`source: "envelope"`, a `changed` boolean when a producer sends + one), any other envelope to the shared `hook-events.jsonl` in the legacy shape + under its lock. The nine audit hooks now send `data.session_id` (their data + schemas gain the optional key); the fleet-wide addition is #930's follow-up. +- **`hooks.json`** carries 40 handlers: the nine audit hooks, 30 generated + producer rows and the retention row (no `timeout`: a plugin cannot raise the + `SessionEnd` budget and would only lower it). + +### Fixed + +- **A racing guard heal no longer drops a line.** Two producers on one event + raced to create the root's `.gitignore`; the second read the empty file the + first had opened, took it for an operator's, and refused its write (the + 33-parallel-fires case caught 32). An empty guard file is healed like an + absent one. + ## [0.42.5] ### Added diff --git a/plugins/claude-ops/README.md b/plugins/claude-ops/README.md index 0919502a07..f26741d628 100644 --- a/plugins/claude-ops/README.md +++ b/plugins/claude-ops/README.md @@ -43,7 +43,7 @@ Claude Code's native OTEL cannot see. | `/claude-ops:plugins` | Brings a machine's plugin fleet current on demand: marketplace refresh, updates for the plugins that actually load (including in-repo project/local-scope installs), new-catalog-plugin install per policy, and scope-divergence detection. Actions: `sync` (default, CLI-mediated mutations only), `audit` (read-only dry run), `converge` (the one action that can touch a committed `.claude/settings.json`. Previews and confirms per plugin first). | | `/claude-ops:morning-brief` | Prints the read-only, `gh`-based operator morning view for the current repo in one pass: open counts per queue label (`priority: needs-triage`, `status: ready`, `status: needs-decision`, `needs-human`), the gh-native merge-ready PR list (non-draft + `mergeStateStatus=CLEAN`), parked `status: needs-decision` issues with their RECOMMENDED lines, and loop-lane telemetry freshness (per-lane `last-cycle` age + `flags:`). Never mutates anything; the authoritative PR merge gate stays `/source-control:babysit-prs`. | | `/claude-ops:lanes` | Starts, restarts, stops, and reports loop lanes as named background Claude Code sessions seeded from canonical prompt files. `start` (default) / `restart` pull the repo and refresh the plugin marketplace, then launch each configured lane (`claude --bg -n `) with its per-lane `model`/`effort`; `status` shows per-lane running state and live sessionId; `stop` ends a lane via `claude stop`; `consume-restarts` is the OS-schedulable restart-request consumer. It reads each configured lane's telemetry `restart_request` and relaunches the stopped lanes that asked, through the same launcher (#1653). Acts only on sessions whose name is a configured lane. Lanes come from a JSON config (`--config`, else `$CLAUDE_OPS_LANES_CONFIG`, else `/.work/lanes/lanes.json`, with a temporary default-only fallback to the pre-move `/.work/lanes.json` under a deprecation warning); config and prompts live in the reserved `lanes/` concern home under a hardcoded `.work` root, which is a sanctioned placement but still session-local, so a durable cross-machine home stays #480's job. | -| `/claude-ops:setup` | Check-only: reports the effective known-issues-registry and skill-usage-log destinations, their defaults, and path containment, and prints the guidance for routing personal option changes through Claude Code's plugin configuration prompt. | +| `/claude-ops:setup` | `check` reports the effective known-issues-registry, skill-usage-log and hook-log-root destinations, their defaults, path containment, the hook log root's self-ignoring guard, and retired conventions (`retirements.yaml`), and prints the guidance for routing personal option changes through Claude Code's plugin configuration prompt; `apply` writes exactly one file, the guard inside the hook log root, and runs the gated retirement cleanup. | ## The audit hooks @@ -151,9 +151,16 @@ per "How to set these" below. ### Wiring the reference sink A migrated emitter is inert without a consumer. `hooks/hook-telemetry-sink.sh` -is a **reference** sink: it reads an envelope on stdin and appends one line to -`/.claude/observability/hook-events.jsonl`. Exactly the shape the -`observability` skill reads. +is a **reference** sink: it reads an envelope on stdin and appends one line under +the hook log root, `/.observability/claude` by default (the +`session_event_log_dir` option moves it). An envelope carrying +`data.session_id` lands in `sessions/.jsonl`, beside the +per-session event log; one without lands in the shared `hook-events.jsonl` +in the legacy shape. Both are what the `observability` skill reads. The root +carries a self-ignoring `.gitignore`, created on the first write (or by +`/claude-ops:setup apply`); rows left at the old +`.claude/observability/hook-events.jsonl` location are detected by setup as +retirement `claude-ops-r001` and migrated on request. Wire it by pointing `HOOK_TELEMETRY_SINK` at an **executable that exists at resolution time**. A *relative* value resolves against the **consuming repo @@ -182,6 +189,28 @@ flows into the same store. The sink is fire-and-forget and best-effort, a slow or absent sink silently drops the event; it is for observability, not audit-of-record. +### The per-session hook event log (off by default) + +Independently of any sink, `session_event_log_enabled=true` turns on one +producer row per observable hook event (30 events; the generated +`hooks/hook-events.registry.json` says which, and why `WorktreeCreate`, +`MessageDisplay` and `FileChanged` are left out). Each fire appends one line to +`/sessions/.jsonl`: the correlation keys the payload carries +(`prompt_id`, `tool_use_id`, `agent_id`), the event and its category, the tool +and a repo-relative file path when present. A consumer who has not turned it on +pays the kill-switch read and nothing else (2.42 ms against a 2.08 ms spawn +floor on the Linux CI host); enabled, a 2 KB payload costs about 5 ms and a +512 KB one 36 ms. Windows Git Bash, the host the hook-budget convention binds +to, is unmeasured for these rows: the parallel-wall figure there, and the +budget comparison it feeds, are owed before the switch is recommended on by +default, and the default stays off until they are taken. `session_event_log_categories` narrows the set. At +`SessionEnd` the retention hook keeps the newest `session_log_keep_sessions` +or the last `session_log_keep_days` days, and `session_log_pre_prune_command` +hands an archiver the files about to go. The root carries its own `*` +`.gitignore`, so nothing under it reaches `git status`; `/claude-ops:setup` +reports the toggles and the guard, `/claude-ops:observability session` reads +the result. + ## Install ```shell @@ -198,8 +227,11 @@ your own repository's context: `/.claude/observability/otel` and is overridable via the `CC_OTEL_STORE` env var (retention windows via `CC_OTEL_RETENTION_DAYS` / `CC_OTEL_BODY_RETENTION_DAYS`). The hook-event JSONL source is read from - `/.claude/observability/hook-events.jsonl` only when your own - hooks emit it; every source degrades gracefully when absent. + the hook log root (`/.observability/claude` by default, the + `session_event_log_dir` option moves it): `sessions/.jsonl` + when the per-session event log is on or the sink is wired, and the shared + `hook-events.jsonl` for envelopes without a session id; every source + degrades gracefully when absent. - **Persistent state** defaults to the plugin's own per-machine data directory (`${CLAUDE_PLUGIN_DATA}`): the known-issues registry (`registry.json`), `check-all` output, `--write` observability reports, and @@ -264,9 +296,11 @@ options tune the skills: `${CLAUDE_PLUGIN_DATA}/skill-usage/`. Plugin-owned, update-safe, never in any repo tree. Prose-validated (no `enum` in the manifest schema); an unknown value falls back to `repo` with a one-time advisory. The default - stays `repo` deliberately: the store sits beside `hook-events.jsonl`, matching + stays `repo` deliberately: the store stays in the project tree, matching the observability posture that telemetry is project-local, and the exclude - entry removes the status noise that motivated the scope knob. + entry removes the status noise that motivated the scope knob. (The hook log + root is a separate tree with its own self-ignoring guard; see "Wiring the + reference sink".) - **`skill_usage_git_exclude`** (boolean, default `true`). Repo scope only: idempotently exclude the store dir via `.git/info/exclude` (never touches `.gitignore` or tracked files). Set `false` when your team deliberately @@ -314,6 +348,12 @@ reads it from. | `hook_failure_audit_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_HOOK_FAILURE_AUDIT_ENABLED` | Warn once per session per hook when the transcript records hook launch/exec failures Claude Code never surfaced | | `instructions_loaded_audit_log_session_start` | boolean | `false` | `CLAUDE_PLUGIN_OPTION_INSTRUCTIONS_LOADED_AUDIT_LOG_SESSION_START` | Opt back into logging session_start instruction loads (dropped by default as deterministic and high-volume) | | `stdin_read_timeout` | number
*min 1* | `2` | `CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT` | Idle bound on reading the hook payload from stdin — how long the pipe may go silent before the hook gives up and fails open | +| `session_event_log_enabled` | boolean | `false` | `CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED` | Append one JSON line per hook event to /sessions/.jsonl, on every documented event the generated registry marks observable. Off by default: a consumer who has not turned it on pays the kill-switch read and nothing else. The same switch gates the SessionEnd retention hook. | +| `session_event_log_dir` | string | `".observability/claude"` | `CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_DIR` | Contained project-relative directory holding the per-session hook event log (sessions/) and the telemetry sink's hook-events.jsonl. Absolute, drive, UNC, traversal and escaping paths are invalid, and the project root itself is refused. Inside a checkout the directory carries a self-ignoring .gitignore, created on the first write. Leave unset to use .observability/claude. | +| `session_event_log_categories` | string | *(none)* | `CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_CATEGORIES` | Comma-separated event categories to record (session, prompt, tool, permission, agent, task, turn, config, worktree, compaction, model, mcp, display, other). Empty records every category the registry marks observable. | +| `session_log_keep_sessions` | number
*min 1* | `30` | `CLAUDE_PLUGIN_OPTION_SESSION_LOG_KEEP_SESSIONS` | At SessionEnd, keep the newest N session files regardless of age (a file is kept when it is among the newest N OR younger than session_log_keep_days). | +| `session_log_keep_days` | number
*min 1* | `14` | `CLAUDE_PLUGIN_OPTION_SESSION_LOG_KEEP_DAYS` | At SessionEnd, keep every session file younger than N days regardless of count (a file is kept when it is younger than N days OR among the newest session_log_keep_sessions). | +| `session_log_pre_prune_command` | string | *(none)* | `CLAUDE_PLUGIN_OPTION_SESSION_LOG_PRE_PRUNE_COMMAND` | Optional command run detached at SessionEnd with one argument, a directory the session files about to be pruned were moved into; the physical delete of that directory happens on the next retention run after 24 hours, so an archiver has a stable set to read. Executed through `bash -c`, so it is trusted configuration: on current releases project and local pluginConfigs are ignored and only the user's own settings supply it (recheck: the plugins reference's user-configuration section). Leave unset to delete directly. | ### How to set these diff --git a/plugins/claude-ops/hooks/api-error-audit.sh b/plugins/claude-ops/hooks/api-error-audit.sh index 0232897518..1b971b9952 100755 --- a/plugins/claude-ops/hooks/api-error-audit.sh +++ b/plugins/claude-ops/hooks/api-error-audit.sh @@ -21,9 +21,16 @@ START=${EPOCHREALTIME:-} INPUT=$(hook::buffer_stdin) || exit 0 +# data.session_id (additive, hook-telemetry rule 1): the sink routes an +# envelope carrying one into the per-session log beside session-event-log.sh. +# A bash match over the buffered payload, no extra process; empty when the +# payload carries none, and the key is then left out of data. +SESSION_ID="" +[[ "$INPUT" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9._-]+)\" ]] && SESSION_ID="${BASH_REMATCH[1]}" + ERROR_TYPE=$(hook::jq_field "$INPUT" '.error') || exit 0 -DATA=$(jq -nc --arg subject "$ERROR_TYPE" '{subject: $subject}') +DATA=$(jq -nc --arg session_id "$SESSION_ID" --arg subject "$ERROR_TYPE" '{subject: $subject} + (if $session_id == "" then {} else {session_id: $session_id} end)') hook::emit_telemetry "api-error-audit" "StopFailure" "error" \ "$START" "$DATA" "${CLAUDE_PROJECT_DIR:-}" diff --git a/plugins/claude-ops/hooks/audit-session-id.test.sh b/plugins/claude-ops/hooks/audit-session-id.test.sh new file mode 100755 index 0000000000..9b7c356e01 --- /dev/null +++ b/plugins/claude-ops/hooks/audit-session-id.test.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# The nine claude-ops audit hooks put the payload's session_id into their +# envelope `data` (additive, docs/conventions/hook-telemetry rule 1) so the +# reference sink can route the line into the per-session log. One suite for +# all of them: each hook is driven black-box with a minimal payload that +# carries a session_id, and once without one, and the captured envelope is +# read back. Covers: api-error-audit.sh config-change-audit.sh +# instructions-loaded-audit.sh permission-denied-audit.sh pre-compact-audit.sh +# skill-usage-audit.sh skill-usage-expansion-audit.sh tool-failure-audit.sh +# hook-failure-audit.sh +set -uo pipefail + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +# shellcheck source=claude-ops-test-helpers.sh +source "$HOOK_DIR/claude-ops-test-helpers.sh" + +PROJ="$TEST_TMPDIR/proj" +mkdir -p "$PROJ/.claude/rules" +: >"$PROJ/.claude/rules/x.md" +DATA_DIR="$TEST_TMPDIR/data" +mkdir -p "$DATA_DIR" + +# A transcript for hook-failure-audit carrying one unsurfaced failure. +TRANSCRIPT="$TEST_TMPDIR/transcript.jsonl" +printf '{"attachment":{"type":"hook_non_blocking_error","hookName":"PreToolUse:demo","toolUseID":"toolu_x","hookEvent":"PreToolUse","stderr":"boom","stdout":"","exitCode":1,"command":"bash demo.sh","durationMs":2},"type":"attachment","uuid":"u","session_id":"s"}\n' >"$TRANSCRIPT" + +# hook -> the payload members (without session_id) that make it emit +members() { + case "$1" in + api-error-audit) printf '"error":"rate_limit"' ;; + config-change-audit) printf '"source":"project_settings"' ;; + instructions-loaded-audit) printf '"file_path":"%s/.claude/rules/x.md","load_reason":"path_glob_match"' "$PROJ" ;; + permission-denied-audit) printf '"tool_name":"Bash","tool_input":{"command":"git push"}' ;; + pre-compact-audit) printf '"trigger":"auto"' ;; + skill-usage-audit) printf '"tool_name":"Skill","tool_input":{"skill":"/research"}' ;; + skill-usage-expansion-audit) printf '"command_name":"/research","expansion_type":"slash_command"' ;; + tool-failure-audit) printf '"tool_name":"Bash","tool_input":{"command":"dotnet build"}' ;; + hook-failure-audit) printf '"transcript_path":"%s","hook_event_name":"Stop"' "$TRANSCRIPT" ;; + *) printf '' ;; + esac +} + +# drive +drive() { + local sink + : >"$3" + sink="$(make_sink "$3")" + env HOOK_TELEMETRY_SINK="$sink" CLAUDE_PROJECT_DIR="$PROJ" CLAUDE_PLUGIN_DATA="$DATA_DIR" \ + CLAUDE_PLUGIN_OPTION_SKILL_USAGE_SCOPE=data-dir \ + bash "$HOOK_DIR/$1.sh" <<<"$2" >/dev/null 2>&1 +} + +for hook in api-error-audit config-change-audit instructions-loaded-audit permission-denied-audit \ + pre-compact-audit skill-usage-audit skill-usage-expansion-audit tool-failure-audit hook-failure-audit; do + TEL="$TEST_TMPDIR/$hook.with.json" + drive "$hook" "{\"session_id\":\"sess-$hook\",$(members "$hook")}" "$TEL" + if wait_for_sink "$TEL"; then + assert_eq "$hook: data.session_id carried" "sess-$hook" "$(jq -r '.data.session_id' "$TEL")" + else + bad "$hook: no envelope captured with a session_id in the payload" + fi + + TEL2="$TEST_TMPDIR/$hook.without.json" + drive "$hook" "{$(members "$hook")}" "$TEL2" + if wait_for_sink "$TEL2"; then + assert_eq "$hook: no session_id → key absent from data" "false" "$(jq '.data | has("session_id")' "$TEL2")" + else + bad "$hook: no envelope captured without a session_id" + fi + + TEL3="$TEST_TMPDIR/$hook.hostile.json" + drive "$hook" "{\"session_id\":\"../x\",$(members "$hook")}" "$TEL3" + if wait_for_sink "$TEL3"; then + assert_eq "$hook: a non-id session_id is not carried" "false" "$(jq '.data | has("session_id")' "$TEL3")" + else + bad "$hook: no envelope captured with a hostile session_id" + fi +done + +report diff --git a/plugins/claude-ops/hooks/config-change-audit.sh b/plugins/claude-ops/hooks/config-change-audit.sh index 81bb28fec0..1720779ec6 100755 --- a/plugins/claude-ops/hooks/config-change-audit.sh +++ b/plugins/claude-ops/hooks/config-change-audit.sh @@ -20,9 +20,16 @@ START=${EPOCHREALTIME:-} INPUT=$(hook::buffer_stdin) || exit 0 +# data.session_id (additive, hook-telemetry rule 1): the sink routes an +# envelope carrying one into the per-session log beside session-event-log.sh. +# A bash match over the buffered payload, no extra process; empty when the +# payload carries none, and the key is then left out of data. +SESSION_ID="" +[[ "$INPUT" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9._-]+)\" ]] && SESSION_ID="${BASH_REMATCH[1]}" + CONFIG_SOURCE=$(hook::jq_field "$INPUT" '.source') || exit 0 -DATA=$(jq -nc --arg subject "$CONFIG_SOURCE" '{subject: $subject}') +DATA=$(jq -nc --arg session_id "$SESSION_ID" --arg subject "$CONFIG_SOURCE" '{subject: $subject} + (if $session_id == "" then {} else {session_id: $session_id} end)') hook::emit_telemetry "config-change-audit" "ConfigChange" "ok" \ "$START" "$DATA" "${CLAUDE_PROJECT_DIR:-}" diff --git a/plugins/claude-ops/hooks/hook-events.registry.json b/plugins/claude-ops/hooks/hook-events.registry.json new file mode 100644 index 0000000000..7d02a6f2f2 --- /dev/null +++ b/plugins/claude-ops/hooks/hook-events.registry.json @@ -0,0 +1,332 @@ +[ + { + "name": "ConfigChange", + "when": "When a configuration file changes during a session", + "category": "config", + "producer": "observe", + "claim": "hook event ConfigChange is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "CwdChanged", + "when": "When the working directory changes, for example when Claude executes a `cd` command. Useful for reactive environment management with tools like direnv", + "category": "config", + "producer": "observe", + "claim": "hook event CwdChanged is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "DirectoryAdded", + "when": "When a working directory is added mid-session via `/add-dir` or the SDK `register_repo_root` control request", + "category": "config", + "producer": "observe", + "claim": "hook event DirectoryAdded is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "Elicitation", + "when": "When an MCP server requests user input during a tool call", + "category": "mcp", + "producer": "observe", + "claim": "hook event Elicitation is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "ElicitationResult", + "when": "After a user responds to an MCP elicitation, before the response is sent back to the server", + "category": "mcp", + "producer": "observe", + "claim": "hook event ElicitationResult is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "FileChanged", + "when": "When a watched file changes on disk. The `matcher` field specifies which filenames to watch", + "category": "config", + "producer": "exclude: the matcher builds the watch list, so a matcherless row watches nothing", + "claim": "hook event FileChanged is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "InstructionsLoaded", + "when": "When a CLAUDE.md or `.claude/rules/*.md` file is loaded into context. Fires at session start and when files are lazily loaded during a session", + "category": "config", + "producer": "observe", + "claim": "hook event InstructionsLoaded is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "MessageDisplay", + "when": "While assistant message text is displayed", + "category": "display", + "producer": "exclude: Claude Code holds each streamed batch until the hook returns", + "claim": "hook event MessageDisplay is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "Notification", + "when": "When Claude Code sends a notification", + "category": "turn", + "producer": "observe", + "claim": "hook event Notification is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "PermissionDenied", + "when": "When auto mode denies a tool call, including denials without a classifier verdict. Use JSON `hookSpecificOutput.retry: true` to tell the model it may retry the denied tool call. Claude Code ignores `retry` when the classifier produced no verdict", + "category": "permission", + "producer": "observe", + "claim": "hook event PermissionDenied is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "PermissionRequest", + "when": "When a tool call needs a permission decision", + "category": "permission", + "producer": "observe", + "claim": "hook event PermissionRequest is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "PostCompact", + "when": "After context compaction completes", + "category": "compaction", + "producer": "observe", + "claim": "hook event PostCompact is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "PostModelSwitch", + "when": "After the session's model changes, including changes Claude Code makes on its own, such as restoring the model when you resume a session", + "category": "model", + "producer": "observe", + "claim": "hook event PostModelSwitch is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "PostToolBatch", + "when": "After a full batch of parallel tool calls resolves, before the next model call", + "category": "tool", + "producer": "observe", + "claim": "hook event PostToolBatch is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "PostToolUse", + "when": "After a tool call succeeds", + "category": "tool", + "producer": "observe", + "claim": "hook event PostToolUse is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "PostToolUseFailure", + "when": "After a tool call fails", + "category": "tool", + "producer": "observe", + "claim": "hook event PostToolUseFailure is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "PreCompact", + "when": "Before context compaction", + "category": "compaction", + "producer": "observe", + "claim": "hook event PreCompact is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "PreModelSwitch", + "when": "Before Claude Code applies a model switch that you or a client requested. Can block the switch", + "category": "model", + "producer": "observe", + "claim": "hook event PreModelSwitch is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "PreToolUse", + "when": "Before a tool call executes. Can block it", + "category": "tool", + "producer": "observe", + "claim": "hook event PreToolUse is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "SessionEnd", + "when": "When a session terminates", + "category": "session", + "producer": "observe", + "claim": "hook event SessionEnd is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "SessionStart", + "when": "When a session begins or resumes", + "category": "session", + "producer": "observe", + "claim": "hook event SessionStart is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "Setup", + "when": "When you start Claude Code with `--init-only`, or with `--init` or `--maintenance` in `-p` mode. For one-time preparation in CI or scripts", + "category": "session", + "producer": "observe", + "claim": "hook event Setup is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "Stop", + "when": "When Claude finishes responding", + "category": "turn", + "producer": "observe", + "claim": "hook event Stop is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "StopFailure", + "when": "When the turn ends due to an API error", + "category": "turn", + "producer": "observe", + "claim": "hook event StopFailure is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "SubagentStart", + "when": "When a subagent is spawned", + "category": "agent", + "producer": "observe", + "claim": "hook event SubagentStart is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "SubagentStop", + "when": "When a subagent finishes", + "category": "agent", + "producer": "observe", + "claim": "hook event SubagentStop is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "TaskCompleted", + "when": "When a task is being marked as completed", + "category": "task", + "producer": "observe", + "claim": "hook event TaskCompleted is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "TaskCreated", + "when": "When a task is being created via `TaskCreate`", + "category": "task", + "producer": "observe", + "claim": "hook event TaskCreated is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "TeammateIdle", + "when": "When an [agent team](/docs/en/agent-teams) teammate is about to go idle", + "category": "agent", + "producer": "observe", + "claim": "hook event TeammateIdle is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "UserPromptExpansion", + "when": "When a user-typed command expands into a prompt, before it reaches Claude. Can block the expansion", + "category": "prompt", + "producer": "observe", + "claim": "hook event UserPromptExpansion is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "UserPromptSubmit", + "when": "When you submit a prompt, before Claude processes it", + "category": "prompt", + "producer": "observe", + "claim": "hook event UserPromptSubmit is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "WorktreeCreate", + "when": "When a worktree is being created via `--worktree`, `isolation: \"worktree\"`, or for a background session. Replaces default git behavior", + "category": "worktree", + "producer": "exclude: configuring a WorktreeCreate hook replaces the default git worktree creation, and a hook that prints no path fails the worktree", + "claim": "hook event WorktreeCreate is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + }, + { + "name": "WorktreeRemove", + "when": "When a worktree is being removed at session exit, when a subagent finishes, or when you delete a background session", + "category": "worktree", + "producer": "observe", + "claim": "hook event WorktreeRemove is documented in the Hooks reference lifecycle table", + "basis": "https://code.claude.com/docs/en/hooks#hook-lifecycle (raw markdown of hooks.md, fetched with curl -sS -L)", + "as_of": "2026-09-05", + "recheck": "each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" + } +] diff --git a/plugins/claude-ops/hooks/hook-failure-audit.sh b/plugins/claude-ops/hooks/hook-failure-audit.sh index ba90242bc9..8e8a53b06e 100755 --- a/plugins/claude-ops/hooks/hook-failure-audit.sh +++ b/plugins/claude-ops/hooks/hook-failure-audit.sh @@ -64,6 +64,10 @@ hook::require_jq Stop claude-ops "$INPUT" TRANSCRIPT=$(hook::jq_field "$INPUT" '.transcript_path') || exit 0 [[ -f "$TRANSCRIPT" ]] || exit 0 SESSION=$(hook::jq_field "$INPUT" '.session_id') || SESSION="no-session" +# data.session_id (additive, hook-telemetry rule 1): the sink routes an +# envelope carrying one into the per-session log beside session-event-log.sh. +SESSION_ID="" +[[ "$SESSION" != "no-session" && "$SESSION" =~ ^[A-Za-z0-9._-]+$ ]] && SESSION_ID="$SESSION" SESSION="${SESSION//[^A-Za-z0-9_-]/-}" # Bounded tail read: cost stays O(cap) regardless of transcript growth. When @@ -255,8 +259,8 @@ fi # Telemetry subjects stay hookName-only (privacy-safe); the command detail is # user-facing message content, not envelope data. -DATA=$(jq -cn --argjson new "$NEW" --argjson total "${TOTAL:-0}" \ - '{subjects: ([$new[].hookName] | unique), total: $total}') +DATA=$(jq -cn --arg session_id "$SESSION_ID" --argjson new "$NEW" --argjson total "${TOTAL:-0}" \ + '{subjects: ([$new[].hookName] | unique), total: $total} + (if $session_id == "" then {} else {session_id: $session_id} end)') hook::emit_telemetry "hook-failure-audit" "Stop" "error" \ "$START" "$DATA" "${CLAUDE_PROJECT_DIR:-}" diff --git a/plugins/claude-ops/hooks/hook-telemetry-sink.sh b/plugins/claude-ops/hooks/hook-telemetry-sink.sh index 54c4bf629f..fbad214b39 100755 --- a/plugins/claude-ops/hooks/hook-telemetry-sink.sh +++ b/plugins/claude-ops/hooks/hook-telemetry-sink.sh @@ -1,12 +1,27 @@ #!/usr/bin/env bash # Reference telemetry sink for claude-ops. Maps a hook-telemetry envelope (from -# ANY producer, per docs/conventions/hook-telemetry) into one JSONL line in -# .claude/observability/hook-events.jsonl — the shape the claude-observability -# skill reads ({ts, event, hook, tool, duration_ms, exit_code, subject, status}). +# ANY producer, per docs/conventions/hook-telemetry) into one JSONL line under +# the log root (.observability/claude by default, project-relative; the +# session_event_log_dir option moves it). +# +# Two routes, decided by the envelope's `data.session_id`: +# * present and well-formed: one spine-shaped line appended to +# sessions/.jsonl, beside the per-session event log +# (session-event-log.sh); `source: "envelope"` tells the reader which +# producer wrote it. No lock: one file per session removes the shared +# write. +# * absent: the legacy shape ({ts, event, hook, tool, duration_ms, exit_code, +# subject, status}) appended to hook-events.jsonl under the same root, the +# shared file the observability skill has always read, under its lock. # # Field mapping: ts<-timestamp, event<-hook_event, hook<-hook, tool<-data.tool, -# subject<-data.subject; status translates (ok->success) and exit_code derives -# from status (error/blocked->2, else 0), since the skill keys errors on it. +# subject<-data.subject, changed<-data.changed (when a producer sends one); +# status translates (ok->success) and exit_code derives from status +# (error/blocked->2, else 0), since the skill keys errors on it. +# +# The root carries a self-ignoring .gitignore inside a checkout, healed on the +# first write when absent (session-log-lib.sh); a guard an operator changed is +# respected and the write refused. # # Wire it by pointing HOOK_TELEMETRY_SINK at this script (relative path committed # in settings.json is the portable, team-shared form): @@ -24,6 +39,8 @@ set -uo pipefail # shellcheck source=hook-utils.sh source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +# shellcheck source=session-log-lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/session-log-lib.sh" INPUT=$(cat) [[ -n "$INPUT" ]] || exit 0 @@ -39,9 +56,11 @@ command -v jq >/dev/null 2>&1 || exit 0 mapfile -t FIELDS < <(printf '%s' "$INPUT" | jq -r ' if (.hook and .hook_event and (.duration_ms != null) and .status) then (.timestamp // ""), .hook_event, .hook, (.data.tool // ""), - (.duration_ms | tostring), (.data.subject // ""), .status + (.duration_ms | tostring), (.data.subject // ""), .status, + (.data.session_id // "" | tostring), + (.data.changed | if . == true then "true" elif . == false then "false" else "" end) else empty end' 2>/dev/null | tr -d '\r') -[[ "${#FIELDS[@]}" -eq 7 ]] || exit 0 +[[ "${#FIELDS[@]}" -eq 9 ]] || exit 0 TS="${FIELDS[0]}" EVENT="${FIELDS[1]}" @@ -50,6 +69,8 @@ TOOL="${FIELDS[3]}" DURATION_MS="${FIELDS[4]}" SUBJECT="${FIELDS[5]}" STATUS="${FIELDS[6]}" +SESSION_ID="${FIELDS[7]}" +CHANGED="${FIELDS[8]}" # Translate the envelope status to the record shape the skill reads. Known # values pass through (ok → success); an unrecognized value is treated as a @@ -82,8 +103,34 @@ blocked) esac project_dir=$(hook::repo_root "${CLAUDE_PROJECT_DIR:-.}") -log_dir="${project_dir%/}/.claude/observability" -mkdir -p "$log_dir" 2>/dev/null || exit 0 +root="" +slog_root_to root "$project_dir" +[[ -n "$root" ]] || exit 0 +slog_guard_ok "$root" "$project_dir" || exit 0 + +if [[ -n "$SESSION_ID" ]] && slog_valid_id "$SESSION_ID"; then + [[ -d "$root/sessions" ]] || mkdir -p "$root/sessions" 2>/dev/null || exit 0 + LINE=$(MSYS_NO_PATHCONV=1 jq -nc \ + --arg ts "$TS" \ + --arg session_id "$SESSION_ID" \ + --arg event "$EVENT" \ + --arg hook "$HOOK" \ + --arg tool "$TOOL" \ + --argjson duration_ms "${DURATION_MS:-0}" \ + --argjson exit_code "${EXIT_CODE:-0}" \ + --arg subject "$SUBJECT" \ + --arg status "$STATUS_OUT" \ + --arg changed "$CHANGED" \ + '{ts: $ts, session_id: $session_id, hook_event_name: $event, status: $status, + duration_ms: $duration_ms, source: "envelope", hook: $hook, + exit_code: $exit_code, subject: $subject, tool: $tool} + + (if $changed == "" then {} else {changed: ($changed == "true")} end)' 2>/dev/null) || exit 0 + [[ -n "$LINE" ]] || exit 0 + printf '%s\n' "$LINE" >>"$root/sessions/$SESSION_ID.jsonl" 2>/dev/null + exit 0 +fi + +mkdir -p "$root" 2>/dev/null || exit 0 LINE=$(MSYS_NO_PATHCONV=1 jq -nc \ --arg ts "$TS" \ @@ -99,6 +146,6 @@ LINE=$(MSYS_NO_PATHCONV=1 jq -nc \ subject: $subject, status: $status}' 2>/dev/null) || exit 0 [[ -n "$LINE" ]] || exit 0 -hook::append_jsonl "${log_dir}/hook-events.jsonl" "$LINE" +hook::append_jsonl "${root}/hook-events.jsonl" "$LINE" exit 0 diff --git a/plugins/claude-ops/hooks/hook-telemetry-sink.test.sh b/plugins/claude-ops/hooks/hook-telemetry-sink.test.sh index 5e4c8ec430..91089499eb 100755 --- a/plugins/claude-ops/hooks/hook-telemetry-sink.test.sh +++ b/plugins/claude-ops/hooks/hook-telemetry-sink.test.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash # Contract test for hook-telemetry-sink.sh (claude-ops plugin). Black-box. -# The sink reads one envelope on stdin and appends one hook-events.jsonl line. +# The sink reads one envelope on stdin and appends one line: to +# /hook-events.jsonl (legacy shape) when the envelope carries no +# data.session_id, to /sessions/.jsonl (spine shape) when it +# does. is .observability/claude under the project. set -uo pipefail HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -12,13 +15,15 @@ trap 'rm -rf "$TEST_TMPDIR"' EXIT source "$HOOK_DIR/claude-ops-test-helpers.sh" envelope() { - # + # [] + local extra="${7:-}" jq -nc \ --arg hook "$1" --arg ev "$2" --arg st "$3" \ --argjson dur "$4" --arg subj "$5" --arg tool "$6" \ + --argjson extra "{${extra}}" \ '{schema_version:"1.0", timestamp:"2026-07-12T00:00:00Z", hook:$hook, hook_event:$ev, status:$st, duration_ms:$dur, - data:{subject:$subj, tool:$tool}}' + data:({subject:$subj, tool:$tool} + $extra)}' } run_sink() { @@ -26,10 +31,12 @@ run_sink() { printf '%s\n' "$line" | env CLAUDE_PROJECT_DIR="$proj" bash "$SINK" >/dev/null 2>&1 } -# --- ok → status success, exit_code 0, field mapping ----------------------- +ROOT_REL=".observability/claude" + +# --- ok → status success, exit_code 0, field mapping (legacy route) --------- P="$TEST_TMPDIR/p1"; mkdir -p "$P" run_sink "$P" "$(envelope config-change-audit ConfigChange ok 4 project_settings '')" -LOG="$P/.claude/observability/hook-events.jsonl" +LOG="$P/$ROOT_REL/hook-events.jsonl" if [[ -s "$LOG" ]]; then assert_eq "ts mapped from timestamp" "2026-07-12T00:00:00Z" "$(jq -r '.ts' "$LOG")" assert_eq "event from hook_event" "ConfigChange" "$(jq -r '.event' "$LOG")" @@ -39,13 +46,14 @@ if [[ -s "$LOG" ]]; then assert_eq "ok → status success" "success" "$(jq -r '.status' "$LOG")" assert_eq "ok → exit_code 0" "0" "$(jq -r '.exit_code' "$LOG")" else - bad "sink wrote no line for ok envelope" + bad "sink wrote no line for ok envelope at $LOG" fi +assert_file_absent "old .claude/observability path is no longer written" "$P/.claude/observability/hook-events.jsonl" # --- error → status error, exit_code 2 ------------------------------------- P2="$TEST_TMPDIR/p2"; mkdir -p "$P2" run_sink "$P2" "$(envelope tool-failure-audit PostToolUseFailure error 5 Bash:dotnet Bash)" -LOG2="$P2/.claude/observability/hook-events.jsonl" +LOG2="$P2/$ROOT_REL/hook-events.jsonl" assert_eq "error → status error" "error" "$(jq -r '.status' "$LOG2")" assert_eq "error → exit_code 2" "2" "$(jq -r '.exit_code' "$LOG2")" assert_eq "tool mapped from data" "Bash" "$(jq -r '.tool' "$LOG2")" @@ -53,7 +61,7 @@ assert_eq "tool mapped from data" "Bash" "$(jq -r '.tool' "$LOG2")" # --- blocked → status blocked preserved, exit_code 2 ----------------------- P3="$TEST_TMPDIR/p3"; mkdir -p "$P3" run_sink "$P3" "$(envelope permission-denied-audit PermissionDenied blocked 5 Bash:git Bash)" -LOG3="$P3/.claude/observability/hook-events.jsonl" +LOG3="$P3/$ROOT_REL/hook-events.jsonl" assert_eq "blocked → status blocked" "blocked" "$(jq -r '.status' "$LOG3")" assert_eq "blocked → exit_code 2" "2" "$(jq -r '.exit_code' "$LOG3")" @@ -62,7 +70,7 @@ P4="$TEST_TMPDIR/p4"; mkdir -p "$P4" printf 'not json\n' | env CLAUDE_PROJECT_DIR="$P4" bash "$SINK" >/dev/null 2>&1 RC=$? assert_exit "malformed stdin → exit 0" 0 "$RC" -assert_file_absent "malformed stdin → no line" "$P4/.claude/observability/hook-events.jsonl" +assert_file_absent "malformed stdin → no line" "$P4/$ROOT_REL/hook-events.jsonl" printf '' | env CLAUDE_PROJECT_DIR="$P4" bash "$SINK" >/dev/null 2>&1 assert_exit "empty stdin → exit 0" 0 "$?" @@ -70,6 +78,59 @@ assert_exit "empty stdin → exit 0" 0 "$?" # --- Missing required envelope key → dropped ------------------------------- P5="$TEST_TMPDIR/p5"; mkdir -p "$P5" printf '%s\n' '{"schema_version":"1.0","hook":"x"}' | env CLAUDE_PROJECT_DIR="$P5" bash "$SINK" >/dev/null 2>&1 -assert_file_absent "incomplete envelope → no line" "$P5/.claude/observability/hook-events.jsonl" +assert_file_absent "incomplete envelope → no line" "$P5/$ROOT_REL/hook-events.jsonl" + +# --- data.session_id routes the line per session, in the spine shape -------- +P6="$TEST_TMPDIR/p6"; mkdir -p "$P6" +run_sink "$P6" "$(envelope api-error-audit StopFailure error 3 rate_limit '' '"session_id":"sess-42"')" +SLOG="$P6/$ROOT_REL/sessions/sess-42.jsonl" +if [[ -s "$SLOG" ]]; then + jq -e 'has("session_id") and has("hook_event_name") and has("ts") and has("status") and has("source")' "$SLOG" >/dev/null 2>&1 + assert_exit "per-session line carries the full spine" 0 "$?" + assert_eq "per-session: session_id" "sess-42" "$(jq -r .session_id "$SLOG")" + assert_eq "per-session: hook_event_name from hook_event" "StopFailure" "$(jq -r .hook_event_name "$SLOG")" + assert_eq "per-session: source is envelope" "envelope" "$(jq -r .source "$SLOG")" + assert_eq "per-session: hook carried" "api-error-audit" "$(jq -r .hook "$SLOG")" + assert_eq "per-session: duration carried" "3" "$(jq -r .duration_ms "$SLOG")" + assert_eq "per-session: status mapped" "error" "$(jq -r .status "$SLOG")" + assert_eq "per-session: exit_code derived" "2" "$(jq -r .exit_code "$SLOG")" + assert_eq "per-session: no changed key when the producer sent none" "false" "$(jq 'has("changed")' "$SLOG")" +else + bad "per-session route wrote nothing at $SLOG" +fi +assert_file_absent "per-session route writes no legacy line" "$P6/$ROOT_REL/hook-events.jsonl" + +# --- data.changed is carried as a boolean when present ------------------------ +run_sink "$P6" "$(envelope markdown-format PostToolUse ok 12 docs/a.md Write '"session_id":"sess-42","changed":true')" +assert_eq "changed: true carried" "true" "$(tail -1 "$SLOG" | jq -r .changed)" +run_sink "$P6" "$(envelope markdown-format PostToolUse ok 12 docs/a.md Write '"session_id":"sess-42","changed":false')" +assert_eq "changed: false carried" "false" "$(tail -1 "$SLOG" | jq -r .changed)" +assert_eq "three lines on the session file" 3 "$(wc -l <"$SLOG" | tr -d ' ')" + +# --- a malformed session_id falls back to the legacy route ---------------------- +P7="$TEST_TMPDIR/p7"; mkdir -p "$P7" +run_sink "$P7" "$(envelope api-error-audit StopFailure error 3 rate_limit '' '"session_id":"../escape"')" +assert_file_absent "hostile session_id → no session file" "$P7/$ROOT_REL/sessions" +assert_eq "hostile session_id → legacy line" 1 "$(wc -l <"$P7/$ROOT_REL/hook-events.jsonl" | tr -d ' ')" + +# --- inside a checkout the root gets its self-ignoring guard; a changed guard refuses +P8="$TEST_TMPDIR/p8"; mkdir -p "$P8/.git" +run_sink "$P8" "$(envelope config-change-audit ConfigChange ok 4 project_settings '')" +assert_eq "checkout: guard healed" "*" "$(head -1 "$P8/$ROOT_REL/.gitignore")" +assert_eq "checkout: line written" 1 "$(wc -l <"$P8/$ROOT_REL/hook-events.jsonl" | tr -d ' ')" +P9="$TEST_TMPDIR/p9"; mkdir -p "$P9/.git" "$P9/$ROOT_REL" +printf 'sessions/\n' >"$P9/$ROOT_REL/.gitignore" +run_sink "$P9" "$(envelope config-change-audit ConfigChange ok 4 project_settings '')" +assert_file_absent "checkout with an operator's guard → refuses" "$P9/$ROOT_REL/hook-events.jsonl" +assert_file_absent "no checkout → no guard" "$P/$ROOT_REL/.gitignore" + +# --- a configured root is honored; an uncontained one writes nothing ---------- +P10="$TEST_TMPDIR/p10"; mkdir -p "$P10" +printf '%s\n' "$(envelope config-change-audit ConfigChange ok 4 x '')" | + env CLAUDE_PROJECT_DIR="$P10" CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_DIR=telemetry/claude bash "$SINK" >/dev/null 2>&1 +assert_eq "configured root honored" 1 "$(wc -l <"$P10/telemetry/claude/hook-events.jsonl" | tr -d ' ')" +printf '%s\n' "$(envelope config-change-audit ConfigChange ok 4 x '')" | + env CLAUDE_PROJECT_DIR="$P10" CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_DIR=../up bash "$SINK" >/dev/null 2>&1 +assert_file_absent "uncontained root writes nothing" "$TEST_TMPDIR/up" report diff --git a/plugins/claude-ops/hooks/hooks.json b/plugins/claude-ops/hooks/hooks.json index 05b1fffb6b..799814e8d6 100644 --- a/plugins/claude-ops/hooks/hooks.json +++ b/plugins/claude-ops/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Records session observability telemetry across nine events: API errors, config changes, instruction loads, permission denials, compactions, skill usage, tool failures and unsurfaced hook failures.", + "description": "Records session observability telemetry: nine audit hooks (API errors, config changes, instruction loads, permission denials, compactions, skill usage, tool failures, unsurfaced hook failures) that emit the hook-telemetry envelope, plus the default-off per-session hook event log on every observable documented event and its SessionEnd retention.", "hooks": { "StopFailure": [ { @@ -11,6 +11,16 @@ "statusMessage": "Recording API-error telemetry..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the StopFailure event..." + } + ] } ], "ConfigChange": [ @@ -24,6 +34,16 @@ "statusMessage": "Recording config-change telemetry..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the ConfigChange event..." + } + ] } ], "InstructionsLoaded": [ @@ -36,6 +56,16 @@ "statusMessage": "Recording instructions-loaded telemetry..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the InstructionsLoaded event..." + } + ] } ], "PermissionDenied": [ @@ -48,6 +78,16 @@ "statusMessage": "Recording permission-denied telemetry..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the PermissionDenied event..." + } + ] } ], "PreCompact": [ @@ -60,6 +100,16 @@ "statusMessage": "Recording pre-compact telemetry..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the PreCompact event..." + } + ] } ], "PostToolUse": [ @@ -73,6 +123,16 @@ "statusMessage": "Recording skill-usage telemetry..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the PostToolUse event..." + } + ] } ], "UserPromptExpansion": [ @@ -85,6 +145,16 @@ "statusMessage": "Recording skill-usage-expansion telemetry..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the UserPromptExpansion event..." + } + ] } ], "PostToolUseFailure": [ @@ -98,6 +168,16 @@ "statusMessage": "Recording tool-failure telemetry..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the PostToolUseFailure event..." + } + ] } ], "Stop": [ @@ -110,6 +190,277 @@ "statusMessage": "Checking for unsurfaced hook failures..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the Stop event..." + } + ] + } + ], + "CwdChanged": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the CwdChanged event..." + } + ] + } + ], + "DirectoryAdded": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the DirectoryAdded event..." + } + ] + } + ], + "Elicitation": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the Elicitation event..." + } + ] + } + ], + "ElicitationResult": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the ElicitationResult event..." + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the Notification event..." + } + ] + } + ], + "PermissionRequest": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the PermissionRequest event..." + } + ] + } + ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the PostCompact event..." + } + ] + } + ], + "PostModelSwitch": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the PostModelSwitch event..." + } + ] + } + ], + "PostToolBatch": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the PostToolBatch event..." + } + ] + } + ], + "PreModelSwitch": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the PreModelSwitch event..." + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the PreToolUse event..." + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the SessionEnd event..." + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-retention.sh", + "statusMessage": "Pruning the session event log..." + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the SessionStart event..." + } + ] + } + ], + "Setup": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the Setup event..." + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the SubagentStart event..." + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the SubagentStop event..." + } + ] + } + ], + "TaskCompleted": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the TaskCompleted event..." + } + ] + } + ], + "TaskCreated": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the TaskCreated event..." + } + ] + } + ], + "TeammateIdle": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the TeammateIdle event..." + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the UserPromptSubmit event..." + } + ] + } + ], + "WorktreeRemove": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-event-log.sh", + "timeout": 5, + "statusMessage": "Logging the WorktreeRemove event..." + } + ] } ] } diff --git a/plugins/claude-ops/hooks/instructions-loaded-audit.sh b/plugins/claude-ops/hooks/instructions-loaded-audit.sh index 06edde3401..113459bc3b 100755 --- a/plugins/claude-ops/hooks/instructions-loaded-audit.sh +++ b/plugins/claude-ops/hooks/instructions-loaded-audit.sh @@ -30,6 +30,13 @@ START=${EPOCHREALTIME:-} INPUT=$(hook::buffer_stdin) || exit 0 +# data.session_id (additive, hook-telemetry rule 1): the sink routes an +# envelope carrying one into the per-session log beside session-event-log.sh. +# A bash match over the buffered payload, no extra process; empty when the +# payload carries none, and the key is then left out of data. +SESSION_ID="" +[[ "$INPUT" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9._-]+)\" ]] && SESSION_ID="${BASH_REMATCH[1]}" + # Both payload fields in ONE jq process (hook::jq_fields), not two: a jq spawn is # ~140 ms of fork() emulation on Windows Git Bash. A missing jq or an unparsable # payload returns non-zero here and exits 0, the same silent skip the @@ -62,7 +69,7 @@ fi SUBJECT="${FILE_DISP}:${LOAD_REASON}" -DATA=$(jq -nc --arg subject "$SUBJECT" '{subject: $subject}') +DATA=$(jq -nc --arg session_id "$SESSION_ID" --arg subject "$SUBJECT" '{subject: $subject} + (if $session_id == "" then {} else {session_id: $session_id} end)') hook::emit_telemetry "instructions-loaded-audit" "InstructionsLoaded" "ok" \ "$START" "$DATA" "${CLAUDE_PROJECT_DIR:-}" diff --git a/plugins/claude-ops/hooks/permission-denied-audit.sh b/plugins/claude-ops/hooks/permission-denied-audit.sh index d495f337ca..aaac8f6ff0 100755 --- a/plugins/claude-ops/hooks/permission-denied-audit.sh +++ b/plugins/claude-ops/hooks/permission-denied-audit.sh @@ -25,6 +25,13 @@ START=${EPOCHREALTIME:-} INPUT=$(hook::buffer_stdin) || exit 0 +# data.session_id (additive, hook-telemetry rule 1): the sink routes an +# envelope carrying one into the per-session log beside session-event-log.sh. +# A bash match over the buffered payload, no extra process; empty when the +# payload carries none, and the key is then left out of data. +SESSION_ID="" +[[ "$INPUT" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9._-]+)\" ]] && SESSION_ID="${BASH_REMATCH[1]}" + # Both payload fields in ONE jq process (hook::jq_fields), not two: a jq spawn is # ~140 ms of fork() emulation on Windows Git Bash. A missing jq or an unparsable # payload returns non-zero here and exits 0, the same silent skip an absent @@ -36,7 +43,7 @@ TOOL="${HOOK_JQ_FIELDS[0]}" CMD="${HOOK_JQ_FIELDS[1]}" SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") -DATA=$(jq -nc --arg subject "$SUBJECT" --arg tool "$TOOL" '{subject: $subject, tool: $tool}') +DATA=$(jq -nc --arg session_id "$SESSION_ID" --arg subject "$SUBJECT" --arg tool "$TOOL" '{subject: $subject, tool: $tool} + (if $session_id == "" then {} else {session_id: $session_id} end)') hook::emit_telemetry "permission-denied-audit" "PermissionDenied" "blocked" \ "$START" "$DATA" "${CLAUDE_PROJECT_DIR:-}" diff --git a/plugins/claude-ops/hooks/pre-compact-audit.sh b/plugins/claude-ops/hooks/pre-compact-audit.sh index 549e4a949b..24b2198fad 100755 --- a/plugins/claude-ops/hooks/pre-compact-audit.sh +++ b/plugins/claude-ops/hooks/pre-compact-audit.sh @@ -20,9 +20,16 @@ START=${EPOCHREALTIME:-} INPUT=$(hook::buffer_stdin) || exit 0 +# data.session_id (additive, hook-telemetry rule 1): the sink routes an +# envelope carrying one into the per-session log beside session-event-log.sh. +# A bash match over the buffered payload, no extra process; empty when the +# payload carries none, and the key is then left out of data. +SESSION_ID="" +[[ "$INPUT" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9._-]+)\" ]] && SESSION_ID="${BASH_REMATCH[1]}" + TRIGGER=$(hook::jq_field "$INPUT" '.trigger') || exit 0 -DATA=$(jq -nc --arg subject "$TRIGGER" '{subject: $subject}') +DATA=$(jq -nc --arg session_id "$SESSION_ID" --arg subject "$TRIGGER" '{subject: $subject} + (if $session_id == "" then {} else {session_id: $session_id} end)') hook::emit_telemetry "pre-compact-audit" "PreCompact" "ok" \ "$START" "$DATA" "${CLAUDE_PROJECT_DIR:-}" diff --git a/plugins/claude-ops/hooks/session-event-log.sh b/plugins/claude-ops/hooks/session-event-log.sh new file mode 100755 index 0000000000..15afa8c07d --- /dev/null +++ b/plugins/claude-ops/hooks/session-event-log.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# Per-session hook event log: one JSON line per hook event, appended to +# /sessions/.jsonl (root defaults to .observability/claude, +# project-relative). Registered on every documented event the generated +# registry marks observable (plugins/claude-ops/hooks/hook-events.registry.json; +# scripts/gen-hook-event-registry.sh writes the hooks.json rows). +# +# DEFAULT OFF. A consumer who has not set session_event_log_enabled pays the +# kill-switch read below and nothing else: no library is sourced and stdin is +# not read until the switch says so. +# +# This script sources session-log-lib.sh (a few functions, no process) and +# NOT hook-utils.sh: a producer that fires on every event cannot afford the +# 2,766-line library, which measured at more than the rest of the hook +# (docs/topics/hook-logging-pipeline, Brief Q15). What it gives up is the +# library's notice channel, so its quiet exits are data-driven (no session id, +# a filtered category, an uncontained root) and never a missing prerequisite: +# it needs no jq and no git. +# +# Every line carries the spine (ts, session_id, hook_event_name, status, +# duration_ms, source) plus whichever correlation keys the payload carries +# (prompt_id, tool_use_id, agent_id, traceparent) and, for events that carry a +# decision or a change, a small payload (tool_name, file_path, reason). Values +# are the payload's own JSON string bodies, re-emitted verbatim, so no escaping +# is re-derived here; ids are constrained to file-name-safe characters because +# session_id names the file. +# +# stdin is read in bounded slices the way hook::buffer_stdin does, without +# sourcing it: a Win32 pipe delivers EOF late, so a read that waits for EOF +# waits out its timeout on every event. Only the first 64 KB matter (every +# spine key precedes tool_input in the payload), so the read stops at that cap, +# at EOF, at a `}` tail after a quiet slice, or after one whole idle bound. +# +# Kill switch: CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED (default false). +# Category filter: CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_CATEGORIES. +# Root: CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_DIR (default .observability/claude). + +set -uo pipefail + +[[ "${CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED:-false}" == "true" ]] || exit 0 + +start=${EPOCHREALTIME:-} + +# shellcheck source=session-log-lib.sh +source "${BASH_SOURCE[0]%/*}/session-log-lib.sh" + +# --- bounded stdin read ------------------------------------------------------- +idle="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" +[[ "$idle" =~ ^[0-9]+(\.[0-9]+)?$ ]] || idle=2 +# Four slices per idle bound when this shell takes a fractional -t (Bash 4+), +# so a stall is declared within a quarter-bound of the configured interval; one +# whole-bound slice otherwise. +slices=1 +slice="$idle" +if ((BASH_VERSINFO[0] >= 4)); then + whole="${idle%%.*}" + frac="${idle#"$whole"}" + frac="${frac#.}000" + micros=$((10#$whole * 1000 + 10#${frac:0:3})) + if ((micros >= 4)); then + printf -v slice '%d.%03d' "$((micros / 4 / 1000))" "$((micros / 4 % 1000))" + slices=4 + fi +fi +read_opts=(-r -t "$slice") +if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); then + read_opts+=(-N 4096) +else + read_opts+=(-d '') +fi +buf="" +quiet=0 +while :; do + chunk="" + rc=0 + # shellcheck disable=SC2162 # -r is in read_opts + IFS= read "${read_opts[@]}" chunk || rc=$? + buf+="$chunk" + ((${#buf} >= 65536)) && break + if ((rc == 0)); then + [[ -n "$chunk" ]] || break + quiet=0 + continue + fi + ((rc > 128)) || break # EOF (rc 1) or a read error: what we hold is what there is + if [[ -n "$chunk" ]]; then + quiet=0 + # A slice that timed out holding data is the late-EOF shape: the payload + # is here and the pipe is not closing. Stop early only when what we hold + # looks like the WHOLE payload: it ends in `}`, it carries the event name + # (the key every fire must have), and its braces balance. A writer that + # paused after a nested `}` (tool_input closed, tool_use_id still to come) + # fails the balance test and the loop keeps reading to the idle bound; a + # brace inside a string can only delay the stop, never force it early on + # its own. + tail="${buf##*[![:space:]]}" + body="${buf%"$tail"}" + if [[ "$body" == *'}' && "$buf" == *'"hook_event_name"'* ]]; then + # The brace characters come from variables: a literal `}` inside the + # bracket class ends the `${...}` expansion early (bash parses the + # expansion's closing brace before the pattern), so `[^}]` written out + # is not the class it looks like. + ob='{' + cb='}' + open="${body//[^$ob]/}" + close="${body//[^$cb]/}" + ((${#open} == ${#close})) && break + fi + continue + fi + quiet=$((quiet + 1)) + ((quiet >= slices)) && break +done +[[ -n "$buf" ]] || exit 0 + +# --- spine and payload ---------------------------------------------------------- +# First match wins; every spine key is a top-level key that precedes tool_input, +# so it is found before any user content could carry the same text. +# shellcheck disable=SC2034 # the payload keys are read through ${!key} below +session_id="" event="" prompt_id="" tool_use_id="" agent_id="" tool_name="" +# shellcheck disable=SC2034 +file_path="" reason="" cwd="" category="" root="" ts="" duration_ms="" +field_to() { # : the JSON string body of "": "..." or "" + if [[ "$buf" =~ \"$2\"[[:space:]]*:[[:space:]]*\"(([^\"\\]|\\.)*)\" ]]; then + printf -v "$1" '%s' "${BASH_REMATCH[1]}" + else + printf -v "$1" '%s' "" + fi +} +field_to session_id session_id +field_to event hook_event_name +[[ -n "$session_id" && -n "$event" ]] || exit 0 +slog_valid_id "$session_id" || exit 0 +[[ "$event" =~ ^[A-Za-z]+$ ]] || exit 0 + +slog_category_to category "$event" +slog_category_enabled "$category" || exit 0 + +field_to prompt_id prompt_id +field_to tool_use_id tool_use_id +field_to agent_id agent_id +field_to tool_name tool_name +field_to file_path file_path +field_to reason reason +field_to cwd cwd + +# --- root and guard ------------------------------------------------------------ +project="${CLAUDE_PROJECT_DIR:-}" +[[ -n "$project" ]] || project="$cwd" +[[ -n "$project" ]] || exit 0 +slog_root_to root "$project" +[[ -n "$root" ]] || exit 0 +slog_guard_ok "$root" "$project" || exit 0 +[[ -d "$root/sessions" ]] || mkdir -p "$root/sessions" 2>/dev/null || exit 0 + +# --- the line ------------------------------------------------------------------- +# A file path is recorded repo-relative when it sits under the project, else +# by its last segment: an absolute path embeds the developer's username, which +# the observability privacy rules keep out of every record. +if [[ -n "$file_path" ]]; then + if [[ "$file_path" == "$project/"* ]]; then + file_path="${file_path#"$project"/}" + else + # Last segment after either separator: a Windows path arrives with `\\` + # (JSON-escaped backslashes) and no `/` at all, and stripping on `/` alone + # would keep the whole path, username included. + file_path="${file_path##*[/\\]}" + fi +fi +slog_ts_to ts +slog_duration_ms_to duration_ms "$start" +line="{\"ts\":\"$ts\",\"session_id\":\"$session_id\",\"hook_event_name\":\"$event\"" +line+=",\"category\":\"$category\",\"status\":\"ok\",\"source\":\"event-log\"" +line+=",\"duration_ms\":${duration_ms:-null}" +for key in prompt_id tool_use_id agent_id tool_name file_path reason; do + [[ -n "${!key}" ]] || continue + [[ "$key" == prompt_id || "$key" == tool_use_id || "$key" == agent_id ]] && ! slog_valid_id "${!key}" && continue + line+=",\"$key\":\"${!key}\"" +done +[[ -n "${TRACEPARENT:-}" && "$TRACEPARENT" =~ ^[0-9a-f-]+$ ]] && line+=",\"traceparent\":\"$TRACEPARENT\"" +line+="}" + +printf '%s\n' "$line" >>"$root/sessions/$session_id.jsonl" 2>/dev/null +exit 0 diff --git a/plugins/claude-ops/hooks/session-event-log.test.sh b/plugins/claude-ops/hooks/session-event-log.test.sh new file mode 100755 index 0000000000..1693188ca2 --- /dev/null +++ b/plugins/claude-ops/hooks/session-event-log.test.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# Contract test for session-event-log.sh (claude-ops plugin). Black-box: the +# producer reads one hook payload on stdin and appends at most one line to +# /sessions/.jsonl. Nothing here sources the hook. +set -uo pipefail + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$HOOK_DIR/session-event-log.sh" +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +# shellcheck source=claude-ops-test-helpers.sh +source "$HOOK_DIR/claude-ops-test-helpers.sh" + +ON=CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED=true + +# payload [] +payload() { + local extra="${3:-}" + printf '{"session_id":"%s","prompt_id":"p-1","transcript_path":"/x/t.jsonl","cwd":"/x","permission_mode":"default","hook_event_name":"%s"%s}' \ + "$1" "$2" "${extra:+,$extra}" +} + +# project [--no-git] -> a fixture project dir, a git checkout by default +project() { + local d="$TEST_TMPDIR/$1" + mkdir -p "$d" + [[ "${2:-}" == "--no-git" ]] || mkdir -p "$d/.git" + printf '%s' "$d" +} + +# run [env...]: runs the hook with CLAUDE_PROJECT_DIR set. +run() { + local proj="$1" body="$2" + shift 2 + printf '%s' "$body" | env -u HOOK_TELEMETRY_SINK CLAUDE_PROJECT_DIR="$proj" "$@" bash "$HOOK" 2>&1 +} + +# --- default OFF: nothing is read or written -------------------------------- +P=$(project off) +OUT=$(run "$P" "$(payload s1 PostToolUse)") +assert_exit "disabled by default → exit 0" 0 "$?" +assert_silent "disabled by default → silent" "$OUT" +assert_file_absent "disabled by default → no root created" "$P/.observability" + +# --- enabled, fresh checkout: guard healed, one full-spine line ------------- +P=$(project on) +OUT=$(run "$P" "$(payload sess-abc PostToolUse '"tool_name":"Write","tool_input":{"file_path":"'"$P"'/docs/a.md","content":"x"},"tool_use_id":"toolu_01"')" "$ON") +assert_exit "enabled → exit 0" 0 "$?" +assert_silent "enabled → silent (no stdout, no stderr)" "$OUT" +LOG="$P/.observability/claude/sessions/sess-abc.jsonl" +if [[ -s "$LOG" ]]; then + assert_eq "guard healed on first write" "*" "$(head -1 "$P/.observability/claude/.gitignore")" + assert_eq "one line per event" 1 "$(wc -l <"$LOG" | tr -d ' ')" + jq -e 'has("session_id") and has("hook_event_name") and has("ts") and has("status") and has("source")' "$LOG" >/dev/null 2>&1 + assert_exit "line carries the full spine" 0 "$?" + assert_eq "session_id" "sess-abc" "$(jq -r .session_id "$LOG")" + assert_eq "hook_event_name" "PostToolUse" "$(jq -r .hook_event_name "$LOG")" + assert_eq "category" "tool" "$(jq -r .category "$LOG")" + assert_eq "source" "event-log" "$(jq -r .source "$LOG")" + assert_eq "prompt_id carried" "p-1" "$(jq -r .prompt_id "$LOG")" + assert_eq "tool_use_id carried" "toolu_01" "$(jq -r .tool_use_id "$LOG")" + assert_eq "tool_name carried" "Write" "$(jq -r .tool_name "$LOG")" + assert_eq "file_path recorded repo-relative" "docs/a.md" "$(jq -r .file_path "$LOG")" + assert_eq "duration_ms is a number" "number" "$(jq -r '.duration_ms | type' "$LOG")" +else + bad "enabled: no line written at $LOG" +fi + +# --- a second event on the same session appends; another session gets its own file +run "$P" "$(payload sess-abc Stop)" "$ON" >/dev/null +run "$P" "$(payload sess-xyz SessionStart)" "$ON" >/dev/null +assert_eq "second event appends to the session file" 2 "$(wc -l <"$LOG" | tr -d ' ')" +assert_eq "another session gets its own file" 1 "$(wc -l <"$P/.observability/claude/sessions/sess-xyz.jsonl" | tr -d ' ')" +assert_eq "exactly two session files" 2 "$(find "$P/.observability/claude/sessions" -name '*.jsonl' | wc -l | tr -d ' ')" + +# --- the guard is never overwritten when an operator changed it --------------- +P=$(project guarded) +mkdir -p "$P/.observability/claude" +printf '# mine\nsessions/\n' >"$P/.observability/claude/.gitignore" +run "$P" "$(payload s1 PostToolUse)" "$ON" >/dev/null +assert_file_absent "a present-but-different guard refuses the write" "$P/.observability/claude/sessions/s1.jsonl" +assert_eq "the operator's guard is left alone" "# mine" "$(head -1 "$P/.observability/claude/.gitignore")" + +# --- an EMPTY guard file is healed, not refused --------------------------------- +# Two producers racing on one event: the first opens .gitignore, the second +# reads it before the first has written its byte. Read as an operator file it +# refuses the write and a line goes missing (the 33-parallel case below caught +# 32); read as heal-in-progress both write `*` and nothing is lost. +P=$(project empty-guard) +mkdir -p "$P/.observability/claude" +: >"$P/.observability/claude/.gitignore" +run "$P" "$(payload s1 PreToolUse)" "$ON" >/dev/null +if [[ -f "$P/.observability/claude/sessions/s1.jsonl" ]]; then + ok "an empty guard file does not refuse the write" +else + bad "an empty guard file does not refuse the write" +fi +assert_eq "an empty guard file is healed to *" "*" "$(head -1 "$P/.observability/claude/.gitignore")" + +# --- outside a checkout there is nothing to keep clean: write, no guard ------- +P=$(project nogit --no-git) +run "$P" "$(payload s2 PostToolUse)" "$ON" >/dev/null +assert_eq "no checkout → line written" 1 "$(wc -l <"$P/.observability/claude/sessions/s2.jsonl" | tr -d ' ')" +assert_file_absent "no checkout → no guard file" "$P/.observability/claude/.gitignore" + +# --- lines are never written without the spine --------------------------------- +P=$(project nospine) +run "$P" '{"cwd":"/x","hook_event_name":"PostToolUse"}' "$ON" >/dev/null +assert_file_absent "no session_id → nothing written" "$P/.observability" +run "$P" '{"session_id":"s3","cwd":"/x"}' "$ON" >/dev/null +assert_file_absent "no hook_event_name → nothing written" "$P/.observability" +run "$P" "$(payload '../escape' PostToolUse)" "$ON" >/dev/null +assert_file_absent "hostile session_id → nothing written" "$P/.observability" +run "$P" "$(payload 's4' 'Post Tool')" "$ON" >/dev/null +assert_file_absent "hostile event name → nothing written" "$P/.observability" + +# --- category filter ------------------------------------------------------------ +P=$(project cats) +run "$P" "$(payload s5 PostToolUse)" "$ON" CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_CATEGORIES=session,turn >/dev/null +assert_file_absent "filtered category → nothing written" "$P/.observability/claude/sessions/s5.jsonl" +run "$P" "$(payload s5 Stop)" "$ON" CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_CATEGORIES=session,turn >/dev/null +assert_eq "listed category → written" "turn" "$(jq -r .category "$P/.observability/claude/sessions/s5.jsonl")" + +# --- configured root: contained relative only; the project root itself is refused +P=$(project roots) +run "$P" "$(payload s6 PostToolUse)" "$ON" CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_DIR=telemetry/claude >/dev/null +assert_eq "contained custom root is used" 1 "$(wc -l <"$P/telemetry/claude/sessions/s6.jsonl" | tr -d ' ')" +# shellcheck disable=SC2088 # the literal tilde is the fixture: a root spelled ~/x must be refused, not expanded +for bad_root in /abs ../up 'C:\logs' '~/x' 'a/../b' .; do + run "$P" "$(payload s7 PostToolUse)" "$ON" CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_DIR="$bad_root" >/dev/null +done +assert_file_absent "uncontained roots write nothing (s7 never lands)" "$P/telemetry/claude/sessions/s7.jsonl" +assert_file_absent "the project root itself is refused" "$P/sessions" +assert_file_absent "the project root gets no guard" "$P/.gitignore" + +# A lexically contained root whose existing component is a symlink can point +# anywhere, and retention deletes under the root, so containment is also +# physical: a link out of the project is refused, a link that stays inside is +# followed, and a link back to the project root itself is refused like `.`. +OUTSIDE="$TEST_TMPDIR/outside-target" +mkdir -p "$OUTSIDE" "$P/inside-target" +ln -s "$OUTSIDE" "$P/escape" +ln -s "$P/inside-target" "$P/stays" +ln -s "$P" "$P/loop" +run "$P" "$(payload s7e PostToolUse)" "$ON" CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_DIR=escape/claude >/dev/null +assert_file_absent "a root through a symlink out of the project writes nothing" "$OUTSIDE/claude/sessions/s7e.jsonl" +assert_file_absent "and leaves no guard outside the project" "$OUTSIDE/claude/.gitignore" +run "$P" "$(payload s7i PostToolUse)" "$ON" CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_DIR=stays/claude >/dev/null +assert_eq "a root through a symlink inside the project is used" 1 "$(wc -l <"$P/inside-target/claude/sessions/s7i.jsonl" 2>/dev/null | tr -d ' ')" +run "$P" "$(payload s7l PostToolUse)" "$ON" CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_DIR=loop >/dev/null +assert_file_absent "a root that resolves to the project itself is refused" "$P/sessions/s7l.jsonl" +assert_file_absent "and the project root still gets no guard" "$P/.gitignore" + +# --- cwd is the project when CLAUDE_PROJECT_DIR is unset ------------------------- +P=$(project bycwd) +printf '{"session_id":"s8","cwd":"%s","hook_event_name":"PostToolUse"}' "$P" | + env -u CLAUDE_PROJECT_DIR -u HOOK_TELEMETRY_SINK "$ON" bash "$HOOK" >/dev/null 2>&1 +assert_eq "payload cwd resolves the project" 1 "$(wc -l <"$P/.observability/claude/sessions/s8.jsonl" 2>/dev/null | tr -d ' ')" + +# --- file paths outside the project are reduced to their last segment ------------ +P=$(project outside) +run "$P" "$(payload s9 PostToolUse '"tool_name":"Edit","tool_input":{"file_path":"/opt/elsewhere/private/notes.md"}')" "$ON" >/dev/null +assert_eq "outside path → last segment only" "notes.md" "$(jq -r .file_path "$P/.observability/claude/sessions/s9.jsonl")" +# A Windows path carries no `/`: the last segment must be taken after `\` too, +# or the whole path (username included) lands in the log. +# Assembled from parts so no drive-letter path literal sits in this file (the +# repo's hardcoded-path guard rejects one); the payload carries `\\` per +# separator, the JSON escape of one backslash. +BS="\\\\" +WIN_PATH="Q:${BS}scratch${BS}private${BS}notes.md" +run "$P" "$(payload s9w PostToolUse "\"tool_name\":\"Edit\",\"tool_input\":{\"file_path\":\"${WIN_PATH}\"}")" "$ON" >/dev/null +assert_eq "Windows outside path → last segment only" "notes.md" "$(jq -r .file_path "$P/.observability/claude/sessions/s9w.jsonl")" + +# --- a pause after a NESTED `}` does not end the read early ---------------------- +# The writer stops for longer than one slice right after tool_input closes, +# then sends the rest. Read as "the payload ended", the buffer has no event +# name and the fire is silently dropped; read as "not yet balanced", the loop +# waits for the rest and the line lands with every key. +P=$(project midpause) +{ + printf '{"session_id":"s9p","cwd":"%s","tool_name":"Edit","tool_input":{"file_path":"x.md"}' "$P" + sleep 1.2 + printf ',"hook_event_name":"PostToolUse","tool_use_id":"tu-9"}' +} | env CLAUDE_PROJECT_DIR="$P" "$ON" CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT=1 bash "$HOOK" >/dev/null 2>&1 +assert_eq "mid-message pause after a nested brace → the line is still written" 1 \ + "$(wc -l <"$P/.observability/claude/sessions/s9p.jsonl" 2>/dev/null | tr -d ' ')" +assert_eq "mid-message pause → the keys after the pause are present" "tu-9" \ + "$(jq -r .tool_use_id "$P/.observability/claude/sessions/s9p.jsonl" 2>/dev/null)" + +# --- a held-open pipe returns inside the idle bound, with the line written -------- +# The writer keeps the pipe open for 3 s after the payload (the Win32 late-EOF +# shape). The hook's own wall time is measured on the reading side, because the +# pipeline as a whole only ends when the writer does. +P=$(project heldopen) +elapsed_ms=$( + { + printf '%s' "$(payload s10 PostToolUse)" + sleep 3 + } | + { + t0=$EPOCHREALTIME + env CLAUDE_PROJECT_DIR="$P" "$ON" CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT=1 bash "$HOOK" >/dev/null 2>&1 + t1=$EPOCHREALTIME + awk -v a="$t0" -v b="$t1" 'BEGIN { printf "%d", (b - a) * 1000 }' + } +) +# The whole payload (with its `/`-bearing paths) is in the first slice, so the +# early stop must fire on that slice's timeout: about a quarter of the 1 s idle +# bound plus startup, never the bound itself. A ceiling at the bound would pass +# a broken early stop (1.26 s was measured with one), which is why it is 700. +if ((elapsed_ms < 700)); then + ok "held-open pipe: returned in ${elapsed_ms} ms (one quarter-bound slice, early stop)" +else + bad "held-open pipe: took ${elapsed_ms} ms, expected under 1300" +fi +assert_eq "held-open pipe: the line was still written" 1 "$(wc -l <"$P/.observability/claude/sessions/s10.jsonl" | tr -d ' ')" + +# --- a 512 KB payload still yields the spine (only the first 64 KB is read) ------ +P=$(project big) +BIG=$(head -c 524288 /dev/zero | tr '\0' 'x') +run "$P" "$(payload s11 PostToolUse '"tool_name":"Read","tool_input":{"file_path":"/x/y"},"tool_response":"'"$BIG"'"')" "$ON" >/dev/null +assert_eq "512 KB payload → spine written" "PostToolUse" "$(jq -r .hook_event_name "$P/.observability/claude/sessions/s11.jsonl")" + +# --- 33 parallel fires on one session produce 33 intact lines -------------------- +P=$(project parallel) +for i in $(seq 1 33); do + run "$P" "$(payload s12 PostToolUse "\"tool_name\":\"T$i\"")" "$ON" >/dev/null & +done +wait +PLOG="$P/.observability/claude/sessions/s12.jsonl" +assert_eq "33 parallel fires → 33 lines" 33 "$(wc -l <"$PLOG" | tr -d ' ')" +assert_eq "33 parallel fires → every line parses" 33 "$(jq -c . "$PLOG" 2>/dev/null | wc -l | tr -d ' ')" + +# --- the producer sources nothing from hook-utils -------------------------------- +assert_eq "no hook-utils.sh source" 0 "$(grep -cE '^[[:space:]]*(source|\.)[[:space:]].*hook-utils' "$HOOK" "$HOOK_DIR/session-log-lib.sh" | awk -F: '{ s += $2 } END { print s + 0 }')" +assert_eq "kill switch is the first statement after set" 1 \ + "$(awk '/^set -uo pipefail/{f=1;next} f && NF && !/^#/ {print; exit}' "$HOOK" | grep -c 'SESSION_EVENT_LOG_ENABLED')" + +report diff --git a/plugins/claude-ops/hooks/session-log-lib.sh b/plugins/claude-ops/hooks/session-log-lib.sh new file mode 100644 index 0000000000..86f05e5af1 --- /dev/null +++ b/plugins/claude-ops/hooks/session-log-lib.sh @@ -0,0 +1,185 @@ +# shellcheck shell=bash +# Shared by the per-session hook event log (session-event-log.sh), the +# SessionEnd retention hook (session-retention.sh) and the telemetry sink +# (hook-telemetry-sink.sh): where the log root is, whether a configured root +# is contained, and the self-ignoring guard that keeps the tree out of `git +# status`. Sourced, never executed (no shebang, like every other sourced +# library here). Deliberately NOT lib/hook-utils.sh: a logging producer runs on +# every hook event, and parsing that library costs more than the rest of the +# hook (docs/topics/hook-logging-pipeline, Brief Q15). Nothing here spawns a +# process; every function assigns into a caller-named variable (`printf -v`) +# or returns a status. Locals carry a `slog__` prefix so `printf -v` can never +# land on a shadowed name. + +# Default log root, project-relative. Overridden by the session_event_log_dir +# userConfig option (CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_DIR). +SLOG_DEFAULT_ROOT=".observability/claude" + +# slog_contained : 0 when the path is a contained relative path +# (no leading slash, drive or UNC prefix, no `..` segment with either separator, +# no leading `~`), 1 otherwise. Same rule the skill-usage store applies. +slog_contained() { + local slog__p="$1" + [[ -n "$slog__p" ]] || return 1 + case "$slog__p" in + /* | ~* | [A-Za-z]:*) return 1 ;; + *) ;; + esac + [[ "$slog__p" == *\\* ]] && return 1 + case "/$slog__p/" in + */../* | */./*) return 1 ;; + *) ;; + esac + return 0 +} + +# slog_root_to : the absolute log root for , +# or the empty string when the configured root is uncontained or resolves to +# the project root itself (a `*` guard there would ignore the whole repository). +# +# Containment is checked twice: lexically (slog_contained) and physically. A +# relative path whose existing component is a symlink can point anywhere, and +# the retention hook deletes under this root, so the nearest existing ancestor +# of the root is resolved with `cd -P` (a builtin; no process) and must sit +# below the physically resolved project. A root that exists and resolves to +# the project itself is refused for the same reason `.` is. +slog_root_to() { + local slog__var="$1" slog__project="$2" + local slog__rel="${CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_DIR:-$SLOG_DEFAULT_ROOT}" + slog__rel="${slog__rel%/}" + if ! slog_contained "$slog__rel" || [[ -z "$slog__project" ]]; then + printf -v "$slog__var" '%s' "" + return 0 + fi + local slog__abs="${slog__project%/}/$slog__rel" + local slog__probe="$slog__abs" slog__saved="$PWD" slog__phys_project="" slog__phys_probe="" + while [[ -n "$slog__probe" && ! -d "$slog__probe" ]]; do + slog__probe="${slog__probe%/*}" + done + if [[ -z "$slog__probe" ]] || ! cd -P -- "$slog__project" 2>/dev/null; then + printf -v "$slog__var" '%s' "" + return 0 + fi + slog__phys_project="$PWD" + if cd -P -- "$slog__probe" 2>/dev/null; then + slog__phys_probe="$PWD" + fi + cd -- "$slog__saved" 2>/dev/null || cd / || true + if [[ -z "$slog__phys_probe" ]] || + [[ "$slog__phys_probe" != "$slog__phys_project" && "$slog__phys_probe" != "$slog__phys_project/"* ]] || + [[ "$slog__phys_probe" == "$slog__phys_project" && "$slog__probe" == "$slog__abs" ]]; then + printf -v "$slog__var" '%s' "" + return 0 + fi + printf -v "$slog__var" '%s' "$slog__abs" +} + +# slog_in_checkout : 0 when the project is a git checkout (a .git +# directory, or the .git file a worktree carries). No git spawn. +slog_in_checkout() { + [[ -d "$1/.git" || -f "$1/.git" ]] +} + +# slog_guard_ok : make sure nothing written under +# can show up as an untracked file. Inside a checkout the root must carry a +# self-ignoring .gitignore whose first non-comment line is `*`; one is created +# (announced through the observability report, never here) when absent, and a +# present-but-different one is left alone and refuses the write, because an +# operator edited it. Outside a checkout there is nothing to keep clean, so the +# write proceeds without a guard. Returns 0 when writing is allowed. +slog_guard_ok() { + local slog__root="$1" slog__project="$2" slog__line + slog_in_checkout "$slog__project" || return 0 + if [[ -f "$slog__root/.gitignore" ]]; then + while IFS= read -r slog__line || [[ -n "$slog__line" ]]; do + slog__line="${slog__line%$'\r'}" + case "$slog__line" in + '' | '#'*) continue ;; + '*') return 0 ;; + *) return 1 ;; + esac + done <"$slog__root/.gitignore" + # Content but no `*` line (comments only): an operator's file, refused. + # No content at all: a sibling producer opened the file a moment ago and + # has not written its byte yet (33 hooks fire on one event), or a crash + # left it empty; either way the `*` write below is what it needs, and two + # writers of the same two bytes cannot disagree. + [[ -s "$slog__root/.gitignore" ]] && return 1 + else + mkdir -p "$slog__root" 2>/dev/null || return 1 + fi + printf '*\n' >"$slog__root/.gitignore" 2>/dev/null || return 1 + return 0 +} + +# slog_valid_id : 0 when is a safe file-name component +# (Claude Code session and agent ids are UUID-shaped; nothing else is admitted +# because the value names a file under the log root). +slog_valid_id() { + [[ "$1" =~ ^[A-Za-z0-9._-]+$ ]] +} + +# slog_ts_to : UTC timestamp, second resolution, without a process on Bash +# 4.2+ (printf %()T); `date` on older shells. +slog_ts_to() { + local slog__ts="" + if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 2))); then + TZ=UTC printf -v slog__ts '%(%Y-%m-%dT%H:%M:%SZ)T' -1 + else + slog__ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) + fi + printf -v "$1" '%s' "$slog__ts" +} + +# slog_duration_ms_to : whole milliseconds since +# , or the empty string when EPOCHREALTIME is unavailable (Bash < 5). +slog_duration_ms_to() { + local slog__var="$1" slog__start="$2" slog__now="${EPOCHREALTIME:-}" + if [[ -z "$slog__start" || -z "$slog__now" ]]; then + printf -v "$slog__var" '%s' "" + return 0 + fi + local slog__s0="${slog__start%%.*}" slog__f0="${slog__start#*.}" + local slog__s1="${slog__now%%.*}" slog__f1="${slog__now#*.}" + slog__f0="${slog__f0}000000" + slog__f1="${slog__f1}000000" + local slog__us=$(((10#$slog__s1 - 10#$slog__s0) * 1000000 + 10#${slog__f1:0:6} - 10#${slog__f0:0:6})) + ((slog__us < 0)) && slog__us=0 + printf -v "$slog__var" '%s' "$((slog__us / 1000))" +} + +# slog_category_to : the category a documented event +# belongs to; the same table the generated registry carries (Phase 4 of the +# topic plan), pinned by the registry's own test. +slog_category_to() { + local slog__c + case "$2" in + SessionStart | SessionEnd | Setup) slog__c=session ;; + UserPromptSubmit | UserPromptExpansion) slog__c=prompt ;; + PreToolUse | PostToolUse | PostToolUseFailure | PostToolBatch) slog__c=tool ;; + PermissionRequest | PermissionDenied) slog__c=permission ;; + SubagentStart | SubagentStop | TeammateIdle) slog__c=agent ;; + TaskCreated | TaskCompleted) slog__c=task ;; + Stop | StopFailure | Notification) slog__c=turn ;; + InstructionsLoaded | ConfigChange | CwdChanged | DirectoryAdded | FileChanged) slog__c=config ;; + WorktreeCreate | WorktreeRemove) slog__c=worktree ;; + PreCompact | PostCompact) slog__c=compaction ;; + PreModelSwitch | PostModelSwitch) slog__c=model ;; + Elicitation | ElicitationResult) slog__c=mcp ;; + MessageDisplay) slog__c=display ;; + *) slog__c=other ;; + esac + printf -v "$1" '%s' "$slog__c" +} + +# slog_category_enabled : 0 unless the session_event_log_categories +# option is set and does not list (comma or space separated). +slog_category_enabled() { + local slog__want="${CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_CATEGORIES:-}" slog__c + [[ -n "$slog__want" ]] || return 0 + slog__want="${slog__want//,/ }" + for slog__c in $slog__want; do + [[ "$slog__c" == "$1" ]] && return 0 + done + return 1 +} diff --git a/plugins/claude-ops/hooks/session-retention.sh b/plugins/claude-ops/hooks/session-retention.sh new file mode 100755 index 0000000000..48986857ec --- /dev/null +++ b/plugins/claude-ops/hooks/session-retention.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# SessionEnd retention for the per-session hook event log +# (/sessions/.jsonl, written by session-event-log.sh and the +# telemetry sink). Keeps the newer of the last N sessions or the last D days +# (session_log_keep_sessions, default 30; session_log_keep_days, default 14): +# a file survives when it is among the newest N OR younger than D days. +# +# Budget: SessionEnd hooks get 1.5 s by default, and a plugin-provided timeout +# cannot raise it (Hooks reference, SessionEnd), so this hook does the least +# possible: FOUR processes on a run that prunes (a sweep of stale pending +# directories, one `ls -t` for recency, one `find` for age, one `mv` or `rm` +# over the whole doomed set), and it never reads stdin. The payload is not +# needed, and on a Win32 pipe a read waits out its timeout before returning. +# +# Prune means delete, with one extensibility point: when +# session_log_pre_prune_command is set, the doomed files are MOVED into +# /prune-pending/-/ (one rename each, atomic on one +# filesystem) and the command is spawned fully detached with that directory +# as its one argument, so a slow or failing archiver can neither delay this +# hook nor lose the files; the directory is deleted by a later run once it is +# older than 24 h. Without a command the doomed files are unlinked directly. +# Either way the sessions/ directory is pruned inside this run. +# +# Same kill switch as the producer: retention of a log nobody writes is +# meaningless, and an operator who turned logging off expects nothing to +# happen. Sources session-log-lib.sh only, never hook-utils.sh. +# +# Kill switch: CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED (default false). + +set -uo pipefail + +[[ "${CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED:-false}" == "true" ]] || exit 0 + +# shellcheck source=session-log-lib.sh +source "${BASH_SOURCE[0]%/*}/session-log-lib.sh" + +keep_n="${CLAUDE_PLUGIN_OPTION_SESSION_LOG_KEEP_SESSIONS:-30}" +keep_days="${CLAUDE_PLUGIN_OPTION_SESSION_LOG_KEEP_DAYS:-14}" +[[ "$keep_n" =~ ^[0-9]+$ ]] && ((keep_n >= 1)) || keep_n=30 +[[ "$keep_days" =~ ^[0-9]+$ ]] && ((keep_days >= 1)) || keep_days=14 +pre_prune="${CLAUDE_PLUGIN_OPTION_SESSION_LOG_PRE_PRUNE_COMMAND:-}" + +project="${CLAUDE_PROJECT_DIR:-$PWD}" +root="" +slog_root_to root "$project" +[[ -n "$root" && -d "$root/sessions" ]] || exit 0 + +# 1. Sweep pending directories an earlier run handed to an archiver more than +# 24 h ago. One find; nothing to do on most runs. +if [[ -d "$root/prune-pending" ]]; then + stale=() + while IFS= read -r d; do + [[ -n "$d" ]] && stale+=("$d") + done < <(find "$root/prune-pending" -mindepth 1 -maxdepth 1 -type d -mmin +1440 2>/dev/null) + ((${#stale[@]})) && rm -rf -- "${stale[@]}" 2>/dev/null +fi + +# 2. Recency order (newest first) and the age set, one process each. +shopt -s nullglob +files=("$root/sessions/"*.jsonl) +shopt -u nullglob +((${#files[@]} > keep_n)) || exit 0 +declare -A protected=() old=() +i=0 +while IFS= read -r f; do + [[ -n "$f" ]] || continue + ((i < keep_n)) && protected["$f"]=1 + i=$((i + 1)) +done < <(ls -t -- "${files[@]}" 2>/dev/null) +while IFS= read -r f; do + [[ -n "$f" ]] && old["$f"]=1 +done < <(find "$root/sessions" -mindepth 1 -maxdepth 1 -name '*.jsonl' -mmin "+$((keep_days * 1440))" 2>/dev/null) + +doomed=() +for f in "${files[@]}"; do + [[ -n "${protected[$f]:-}" ]] && continue + [[ -n "${old[$f]:-}" ]] || continue + doomed+=("$f") +done +((${#doomed[@]})) || exit 0 + +# 3. Prune: unlink, or move aside for the archiver and detach it. +if [[ -z "$pre_prune" ]]; then + rm -f -- "${doomed[@]}" 2>/dev/null + exit 0 +fi +pending="$root/prune-pending/${EPOCHSECONDS:-0}-$$" +mkdir -p "$pending" 2>/dev/null || exit 0 +mv -- "${doomed[@]}" "$pending/" 2>/dev/null +# Detached: its own session, stdin closed, no inherited fds, so a Windows child +# cannot hold this hook's process tree open past the SessionEnd budget. +(nohup bash -c "$pre_prune"' "$@"' bash "$pending" /dev/null 2>&1 &) +exit 0 diff --git a/plugins/claude-ops/hooks/session-retention.test.sh b/plugins/claude-ops/hooks/session-retention.test.sh new file mode 100755 index 0000000000..2315354fe0 --- /dev/null +++ b/plugins/claude-ops/hooks/session-retention.test.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Contract test for session-retention.sh (claude-ops plugin). Black-box over a +# fixture log root; mtimes are staged with POSIX `touch -t` from a bash +# builtin timestamp, and process spawns are counted through PATH shims. +set -uo pipefail + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$HOOK_DIR/session-retention.sh" +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +# shellcheck source=claude-ops-test-helpers.sh +source "$HOOK_DIR/claude-ops-test-helpers.sh" + +ON=CLAUDE_PLUGIN_OPTION_SESSION_EVENT_LOG_ENABLED=true + +# stamp_to : a `touch -t` timestamp that many days in the past. +stamp_to() { + printf -v "$1" '%(%Y%m%d%H%M)T' "$((EPOCHSECONDS - $2 * 86400))" +} + +# populate : session files, file i being i days old +# (file 1 newest), so recency order and age agree and the union rule is exact. +populate() { + local root="$1" n="$2" i st + mkdir -p "$root/sessions" + for ((i = 1; i <= n; i++)); do + printf '{"session_id":"s%03d"}\n' "$i" >"$root/sessions/s$(printf '%03d' "$i").jsonl" + stamp_to st "$i" + touch -t "$st" "$root/sessions/s$(printf '%03d' "$i").jsonl" + done +} + +# Spawn-counting shims: every process the hook may start is logged, then +# delegated to the real binary. +SHIM="$TEST_TMPDIR/shim" +SPAWNS="$TEST_TMPDIR/spawns" +mkdir -p "$SHIM" +for b in ls find mv rm nohup; do + real=$(command -v "$b") + printf '#!/usr/bin/env bash\nprintf "%%s\\n" "%s" >>"%s"\nexec "%s" "$@"\n' "$b" "$SPAWNS" "$real" >"$SHIM/$b" + chmod +x "$SHIM/$b" +done + +# run [env...]: no stdin is offered at all (the hook must not read it). +run() { + local proj="$1" + shift + : >"$SPAWNS" + env CLAUDE_PROJECT_DIR="$proj" PATH="$SHIM:$PATH" "$@" bash "$HOOK" /dev/null 2>&1 +} + +# --- disabled: nothing happens ---------------------------------------------- +P="$TEST_TMPDIR/off" +populate "$P/.observability/claude" 40 +run "$P" +assert_eq "disabled → every file kept" 40 "$(find "$P/.observability/claude/sessions" -name '*.jsonl' | wc -l | tr -d ' ')" +assert_eq "disabled → no process spawned" 0 "$(wc -l <"$SPAWNS" | tr -d ' ')" + +# --- the union rule: keep newest N or younger than D days ------------------- +# 100 files, file i is i days old. keep 30 sessions / 14 days: files 1..30 +# are protected by count, files 1..14 by age; doomed = 31..100 (70 files). +P="$TEST_TMPDIR/union" +populate "$P/.observability/claude" 100 +t0=$EPOCHREALTIME +run "$P" "$ON" +t1=$EPOCHREALTIME +kept=$(find "$P/.observability/claude/sessions" -name '*.jsonl' | wc -l | tr -d ' ') +assert_eq "100 files, 30/14 → 30 kept" 30 "$kept" +assert_eq "the newest 30 are the survivors" "s001.jsonl s030.jsonl" \ + "$(find "$P/.observability/claude/sessions" -name '*.jsonl' -exec basename {} \; | sort | sed -n '1p;$p' | tr '\n' ' ' | sed 's/ $//')" +# Three processes on a first prune (ls, find, rm); the pending sweep's find +# is paid only once a prune-pending directory exists. +assert_eq "a pruning run spawns three processes (ls, find, rm)" 3 "$(wc -l <"$SPAWNS" | tr -d ' ')" +# No stdin read: a writer that holds the pipe open for 3 s (the Win32 late-EOF +# shape) must not delay the hook at all. Measured on the reading side, because +# the pipeline as a whole only ends when the writer does. +P2="$TEST_TMPDIR/heldopen" +populate "$P2/.observability/claude" 40 +held_ms=$( + { + printf '{"session_id":"x","hook_event_name":"SessionEnd","reason":"other"}' + sleep 3 + } | + { + h0=$EPOCHREALTIME + env CLAUDE_PROJECT_DIR="$P2" PATH="$SHIM:$PATH" "$ON" bash "$HOOK" >/dev/null 2>&1 + h1=$EPOCHREALTIME + awk -v a="$h0" -v b="$h1" 'BEGIN { printf "%d", (b - a) * 1000 }' + } +) +if ((held_ms < 500)); then + ok "no stdin read: a held-open stdin does not delay the hook (${held_ms} ms)" +else + bad "no stdin read: the hook waited on stdin (${held_ms} ms)" +fi +assert_eq "held-open stdin: the prune still ran" 30 "$(find "$P2/.observability/claude/sessions" -name '*.jsonl' | wc -l | tr -d ' ')" +printf 'info: 100-file prune took %s ms\n' "$(awk -v a="$t0" -v b="$t1" 'BEGIN { printf "%d", (b - a) * 1000 }')" + +# keep by age beyond the count: 50 sessions / 1 day → files 1..50 by count, +# files younger than 1 day: none older than the count, so 50 kept. +P="$TEST_TMPDIR/bycount" +populate "$P/.observability/claude" 60 +run "$P" "$ON" CLAUDE_PLUGIN_OPTION_SESSION_LOG_KEEP_SESSIONS=50 CLAUDE_PLUGIN_OPTION_SESSION_LOG_KEEP_DAYS=1 +assert_eq "count protects beyond age" 50 "$(find "$P/.observability/claude/sessions" -name '*.jsonl' | wc -l | tr -d ' ')" +# keep by age beyond the count: 5 sessions / 40 days on 60 files → all younger +# than 40 days are kept (files 1..39), the rest doomed. +P="$TEST_TMPDIR/byage" +populate "$P/.observability/claude" 60 +run "$P" "$ON" CLAUDE_PLUGIN_OPTION_SESSION_LOG_KEEP_SESSIONS=5 CLAUDE_PLUGIN_OPTION_SESSION_LOG_KEEP_DAYS=40 +kept=$(find "$P/.observability/claude/sessions" -name '*.jsonl' | wc -l | tr -d ' ') +if ((kept >= 39 && kept <= 40)); then ok "age protects beyond count ($kept kept)"; else bad "age protects beyond count: kept $kept"; fi + +# --- under the count nothing is touched, and an empty root is fine ----------- +P="$TEST_TMPDIR/few" +populate "$P/.observability/claude" 10 +run "$P" "$ON" +assert_eq "10 files under keep 30 → untouched" 10 "$(find "$P/.observability/claude/sessions" -name '*.jsonl' | wc -l | tr -d ' ')" +P="$TEST_TMPDIR/empty" +mkdir -p "$P/.observability/claude/sessions" +run "$P" "$ON" +assert_exit "empty sessions dir → exit 0" 0 "$?" +P="$TEST_TMPDIR/noroot" +mkdir -p "$P" +run "$P" "$ON" +assert_exit "no root at all → exit 0" 0 "$?" + +# --- invalid knobs fall back to the defaults -------------------------------------- +P="$TEST_TMPDIR/knobs" +populate "$P/.observability/claude" 100 +run "$P" "$ON" CLAUDE_PLUGIN_OPTION_SESSION_LOG_KEEP_SESSIONS=zero CLAUDE_PLUGIN_OPTION_SESSION_LOG_KEEP_DAYS=-3 +assert_eq "invalid knobs → defaults (30/14)" 30 "$(find "$P/.observability/claude/sessions" -name '*.jsonl' | wc -l | tr -d ' ')" + +# --- pre-prune command: files move aside, the command runs detached --------------- +P="$TEST_TMPDIR/archiver" +populate "$P/.observability/claude" 40 +ARCHIVE_LOG="$TEST_TMPDIR/archiver.log" +CMD="printf '%s\\n' \"\$1\" >>\"$ARCHIVE_LOG\"; sleep 30" +t0=$EPOCHREALTIME +run "$P" "$ON" CLAUDE_PLUGIN_OPTION_SESSION_LOG_PRE_PRUNE_COMMAND="$CMD" +t1=$EPOCHREALTIME +elapsed=$(awk -v a="$t0" -v b="$t1" 'BEGIN { printf "%d", (b - a) * 1000 }') +if ((elapsed < 1000)); then ok "a sleeping archiver does not delay the hook (${elapsed} ms)"; else bad "hook waited on the archiver: ${elapsed} ms"; fi +assert_eq "sessions/ pruned to the kept 30" 30 "$(find "$P/.observability/claude/sessions" -name '*.jsonl' | wc -l | tr -d ' ')" +pending=$(find "$P/.observability/claude/prune-pending" -mindepth 1 -maxdepth 1 -type d | head -1) +assert_eq "doomed files moved into one pending directory" 10 "$(find "$pending" -name '*.jsonl' | wc -l | tr -d ' ')" +wait_for_sink "$ARCHIVE_LOG" 100 +assert_eq "the archiver received the pending directory as its argument" "$pending" "$(head -1 "$ARCHIVE_LOG" 2>/dev/null)" +# The sleeping archiver is detached; it must not keep the test alive. +pkill -f 'sleep 30' 2>/dev/null || true + +# --- a pending directory is swept only once it is older than 24 h ---------------- +P="$TEST_TMPDIR/sweep" +populate "$P/.observability/claude" 5 +mkdir -p "$P/.observability/claude/prune-pending/1-old" "$P/.observability/claude/prune-pending/2-fresh" +stamp_to st 2 +touch -t "$st" "$P/.observability/claude/prune-pending/1-old" +run "$P" "$ON" +assert_file_absent "a pending directory older than 24 h is swept" "$P/.observability/claude/prune-pending/1-old" +if [[ -d "$P/.observability/claude/prune-pending/2-fresh" ]]; then + ok "a fresh pending directory is left for its archiver" +else + bad "fresh pending directory was swept" +fi + +# --- the hook sources nothing from hook-utils ------------------------------------------ +assert_eq "no hook-utils.sh source" 0 "$(grep -cE '^[[:space:]]*(source|\.)[[:space:]].*hook-utils' "$HOOK")" + +report diff --git a/plugins/claude-ops/hooks/skill-usage-audit.sh b/plugins/claude-ops/hooks/skill-usage-audit.sh index 18f5ad9bb0..43bbedb521 100755 --- a/plugins/claude-ops/hooks/skill-usage-audit.sh +++ b/plugins/claude-ops/hooks/skill-usage-audit.sh @@ -30,6 +30,13 @@ START=${EPOCHREALTIME:-} INPUT=$(hook::buffer_stdin) || exit 0 +# data.session_id (additive, hook-telemetry rule 1): the sink routes an +# envelope carrying one into the per-session log beside session-event-log.sh. +# A bash match over the buffered payload, no extra process; empty when the +# payload carries none, and the key is then left out of data. +SESSION_ID="" +[[ "$INPUT" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9._-]+)\" ]] && SESSION_ID="${BASH_REMATCH[1]}" + TOOL=$(hook::jq_field "$INPUT" '.tool_name') || exit 0 [[ "$TOOL" == "Skill" ]] || exit 0 @@ -45,8 +52,8 @@ claude_ops::record_skill_use "PostToolUse" "skill-usage-audit" "$INPUT" "$SKILL" # --- Telemetry envelope (only when a sink is wired) ------------------------- if hook::telemetry_enabled; then - DATA=$(jq -nc --arg subject "Skill:$SKILL" --arg skill "$SKILL" \ - '{subject: $subject, skill: $skill, source: "tool"}') + DATA=$(jq -nc --arg session_id "$SESSION_ID" --arg subject "Skill:$SKILL" --arg skill "$SKILL" \ + '{subject: $subject, skill: $skill, source: "tool"} + (if $session_id == "" then {} else {session_id: $session_id} end)') hook::emit_telemetry "skill-usage-audit" "PostToolUse" "ok" \ "$START" "$DATA" "${CLAUDE_PROJECT_DIR:-}" fi diff --git a/plugins/claude-ops/hooks/skill-usage-expansion-audit.sh b/plugins/claude-ops/hooks/skill-usage-expansion-audit.sh index 7c1da777ce..3da2b9a365 100755 --- a/plugins/claude-ops/hooks/skill-usage-expansion-audit.sh +++ b/plugins/claude-ops/hooks/skill-usage-expansion-audit.sh @@ -40,6 +40,13 @@ START=${EPOCHREALTIME:-} INPUT=$(hook::buffer_stdin) || exit 0 +# data.session_id (additive, hook-telemetry rule 1): the sink routes an +# envelope carrying one into the per-session log beside session-event-log.sh. +# A bash match over the buffered payload, no extra process; empty when the +# payload carries none, and the key is then left out of data. +SESSION_ID="" +[[ "$INPUT" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9._-]+)\" ]] && SESSION_ID="${BASH_REMATCH[1]}" + SKILL=$(hook::jq_field "$INPUT" '.command_name') || exit 0 SKILL="${SKILL#/}" @@ -54,9 +61,9 @@ claude_ops::record_skill_use "UserPromptExpansion" "skill-usage-expansion-audit" # --- Telemetry envelope (only when a sink is wired) ------------------------- if hook::telemetry_enabled; then - DATA=$(jq -nc --arg subject "Skill:$SKILL" --arg skill "$SKILL" --arg exp "$EXP_TYPE" \ + DATA=$(jq -nc --arg session_id "$SESSION_ID" --arg subject "Skill:$SKILL" --arg skill "$SKILL" --arg exp "$EXP_TYPE" \ '{subject: $subject, skill: $skill, source: "expansion"} - + (if $exp != "" then {expansion_type: $exp} else {} end)') + + (if $exp != "" then {expansion_type: $exp} else {} end) + (if $session_id == "" then {} else {session_id: $session_id} end)') hook::emit_telemetry "skill-usage-audit" "UserPromptExpansion" "ok" \ "$START" "$DATA" "${CLAUDE_PROJECT_DIR:-}" fi diff --git a/plugins/claude-ops/hooks/tool-failure-audit.sh b/plugins/claude-ops/hooks/tool-failure-audit.sh index 527b0b0411..306c053dda 100755 --- a/plugins/claude-ops/hooks/tool-failure-audit.sh +++ b/plugins/claude-ops/hooks/tool-failure-audit.sh @@ -22,6 +22,13 @@ START=${EPOCHREALTIME:-} INPUT=$(hook::buffer_stdin) || exit 0 +# data.session_id (additive, hook-telemetry rule 1): the sink routes an +# envelope carrying one into the per-session log beside session-event-log.sh. +# A bash match over the buffered payload, no extra process; empty when the +# payload carries none, and the key is then left out of data. +SESSION_ID="" +[[ "$INPUT" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9._-]+)\" ]] && SESSION_ID="${BASH_REMATCH[1]}" + # Both payload fields in ONE jq process (hook::jq_fields), not two: a jq spawn is # ~140 ms of fork() emulation on Windows Git Bash. A missing jq or an unparsable # payload returns non-zero here and exits 0, the same silent skip an absent @@ -33,7 +40,7 @@ TOOL="${HOOK_JQ_FIELDS[0]}" CMD="${HOOK_JQ_FIELDS[1]}" SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") -DATA=$(jq -nc --arg subject "$SUBJECT" --arg tool "$TOOL" '{subject: $subject, tool: $tool}') +DATA=$(jq -nc --arg session_id "$SESSION_ID" --arg subject "$SUBJECT" --arg tool "$TOOL" '{subject: $subject, tool: $tool} + (if $session_id == "" then {} else {session_id: $session_id} end)') hook::emit_telemetry "tool-failure-audit" "PostToolUseFailure" "error" \ "$START" "$DATA" "${CLAUDE_PROJECT_DIR:-}" diff --git a/plugins/claude-ops/lib/check-retirements.sh b/plugins/claude-ops/lib/check-retirements.sh new file mode 100755 index 0000000000..d427308215 --- /dev/null +++ b/plugins/claude-ops/lib/check-retirements.sh @@ -0,0 +1,654 @@ +#!/usr/bin/env bash +# Retired-convention detection and cleanup for a plugin's retirements.yaml. +# +# WHY. When a plugin retires a consumer-facing convention — a config file it no +# longer reads, a gitignore line it no longer recommends, a directory it +# renamed — the old artifact stays behind in every consumer repository. Before +# this helper each plugin detected its own leftovers in bespoke setup prose, +# and the prose drifted. Now the plugin appends one append-only record to its +# retirements.yaml and this helper evaluates every record against the consumer +# repo: setup `check` runs the detection as one fixed step, setup `apply` +# offers the per-record cleanup behind an operator gate. The owner doc is +# docs/conventions/retired-conventions/README.md; this header keeps a named +# operational duplicate of the contract so the executable ships self-described. +# +# MANIFEST. Records separated by a line that is exactly `---`; flat +# `key: value` scalars only (no nesting, no lists); a value may be wrapped in +# single or double quotes (one layer is stripped, nothing inside is escaped). +# Lines starting with `#` and blank lines are ignored. Fields: +# +# id -rNNN — unique within the manifest, never reused +# retired YYYY-MM-DD +# plugin_version semver of the release that retired the convention +# kind file | dir | line +# path repo-relative; absolute, `..` segments, a leading `~`, +# backslashes, `.` and tabs are rejected +# match POSIX ERE — REQUIRED for kind line, forbidden otherwise +# heading optional ATX heading (1-6 hashes, whitespace, title), kind +# line only: the record only fires when a matching line sits +# in that heading's section body, so a standalone occurrence +# elsewhere in a markdown file is not a leftover +# content_match optional POSIX ERE, kind file only: the record only fires +# when the file's content matches, so a path the successor +# reuses is not reported as a leftover +# action delete | remove-line | migrate — remove-line only with kind +# line, delete only with kind file or dir +# successor prose the model follows for a migrate — REQUIRED for migrate +# note one line, required +# status optional; active (default) | report-only (the demotion) +# +# DETECTION. Per kind: file = a regular file exists at path AND (no +# content_match OR it matches); dir = a directory exists; line = the file +# exists AND some line matches `match` (and, when `heading` is set, that line +# sits in the body of a markdown section whose heading line equals `heading`). +# A section runs from the line after that heading through the line before the +# next ATX heading of the same or higher level, or EOF; every such section is +# searched. A trailing carriage return is stripped from every line before +# matching, so a `$`-anchored pattern matches a CRLF-authored file. One TSV +# row per leftover on stdout: +# +# idkindpathactionstatusnote +# +# Paths are emitted exactly as declared — repo-relative, never joined onto the +# root (docs/conventions/windows-path-emit). A human summary goes to stderr. +# +# CLEANUP. `--clean ` cleans exactly one record's artifact. delete unlinks +# the file (only if content_match, when declared, still matches) or removes the +# directory (only after re-resolving that it is inside the root and is not the +# root itself). remove-line rewrites the file keeping every non-matching line +# byte-for-byte — each line's own ending survives, so a CRLF file stays CRLF — +# via a temp file in the same directory and a rename. When `heading` is set, +# only matching lines inside that heading's section body are removed. A migrate +# record refuses to clean until `--i-migrated` states that the successor prose +# was followed; it then removes the artifact the way its kind implies. +# +# VALIDATION FAILS THE WHOLE RUN. An invalid record — bad kind, missing match, +# absolute path, duplicate id, migrate without successor, an unknown key — is +# exit 2 before any row is written, naming the record and the field. A skipped +# record would be a leftover nobody hears about, which is the failure this +# helper exists to end. +# +# Usage: +# check-retirements.sh --manifest [--root ] +# check-retirements.sh --manifest --clean [--i-migrated] [--root ] +# check-retirements.sh --help +# +# --root defaults to ${CLAUDE_PROJECT_DIR}, else the git toplevel, else cwd. +# +# Exit (detect): 0 no active leftover (report-only hits may still be listed); +# 1 at least one active leftover; 2 usage, unreadable manifest, +# or an invalid record. +# Exit (clean): 0 cleaned; 1 nothing present to clean; 2 usage, invalid +# record, unknown id, migrate without --i-migrated, or a failed +# remove/rename (on Windows usually a locked file — close it and +# re-run; nothing is left half-done). +# +# Shared source: this file is the canonical copy (claude-config) and is synced +# byte-identical into the plugins that carry it by scripts/sync-check-retirements.sh, +# registered in scripts/cross-plugin-source-registry.txt. Bash 3.2-compatible on +# purpose: no associative arrays, no mapfile, no jq, no python. + +set -uo pipefail + +usage() { + cat <<'EOF' +check-retirements.sh — detect and clean a plugin's retired conventions. + +Evaluates every record of a retirements.yaml against a consumer repository and +prints one TSV row per leftover: + + idkindpathactionstatusnote + +Usage: + check-retirements.sh --manifest [--root ] + check-retirements.sh --manifest --clean [--i-migrated] [--root ] + check-retirements.sh --help + + --manifest the plugin's retirements.yaml + --root consumer repository root; defaults to ${CLAUDE_PROJECT_DIR}, + else the git toplevel, else the current directory + --clean clean exactly that record's artifact instead of detecting + --i-migrated required with --clean on a migrate record: states that the + successor prose was followed, so the artifact may go + +Exit (detect): 0 no active leftover; 1 at least one active leftover; 2 usage, + unreadable manifest, or an invalid record (the whole run fails). +Exit (clean): 0 cleaned; 1 nothing present to clean; 2 error. +EOF +} + +die() { + echo "ERROR: $*" >&2 + exit 2 +} + +MANIFEST="" +ROOT_ARG="" +CLEAN_ID="" +I_MIGRATED=0 +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --manifest) + [[ $# -ge 2 ]] || die "--manifest needs a path" + MANIFEST="$2" + shift 2 + ;; + --root) + [[ $# -ge 2 ]] || die "--root needs a path" + ROOT_ARG="$2" + shift 2 + ;; + --clean) + [[ $# -ge 2 ]] || die "--clean needs a record id" + CLEAN_ID="$2" + shift 2 + ;; + --i-migrated) + I_MIGRATED=1 + shift + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +[[ -n "$MANIFEST" ]] || die "--manifest is required (see --help)" +[[ -f "$MANIFEST" && -r "$MANIFEST" ]] || die "manifest is not a readable file: $MANIFEST" +if [[ $I_MIGRATED -eq 1 && -z "$CLEAN_ID" ]]; then + die "--i-migrated only makes sense with --clean " +fi + +# tr -d '\r': Git on Windows can return a CRLF-terminated path. +if [[ -n "$ROOT_ARG" ]]; then + ROOT="$ROOT_ARG" +elif [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + ROOT="$CLAUDE_PROJECT_DIR" +else + ROOT=$(git rev-parse --show-toplevel 2>/dev/null | tr -d '\r') + [[ -n "$ROOT" ]] || ROOT="$PWD" +fi +[[ -d "$ROOT" ]] || die "--root is not a directory: $ROOT" +ROOT="${ROOT%/}" + +# --------------------------------------------------------------------------- +# Manifest parsing — every record is validated before anything is evaluated. +# Parallel indexed arrays rather than one associative array per record: the +# helper has to run on the bash 3.2 that stock macOS ships. +# --------------------------------------------------------------------------- + +REC_ID=() +REC_KIND=() +REC_PATH=() +REC_MATCH=() +REC_HEADING=() +REC_CONTENT_MATCH=() +REC_ACTION=() +REC_SUCCESSOR=() +REC_NOTE=() +REC_STATUS=() +REC_COUNT=0 +SEEN_IDS=" +" + +# Built without backslash-bearing literals: shellcheck's SC1003 fires on every +# spelling of one inside quotes. +BACKSLASH=$(printf '%b' '\134') +TAB=$(printf '\t') + +# ere_valid — 0 when grep -E accepts the pattern. grep exits 2 on a +# malformed expression and 1 on the (expected) no-match against empty input. +ere_valid() { + local rc=0 + grep -E -e "$1" /dev/null 2>&1 || rc=$? + [[ $rc -ne 2 ]] +} + +# Current record's fields; reset at each `---`. +r_start=0 +r_id="" r_retired="" r_plugin_version="" r_kind="" r_path="" r_match="" +r_heading="" r_content_match="" r_action="" r_successor="" r_note="" r_status="" +r_keys=" " +r_nonempty=0 + +reset_record() { + r_start=$1 + r_id="" r_retired="" r_plugin_version="" r_kind="" r_path="" r_match="" + r_heading="" r_content_match="" r_action="" r_successor="" r_note="" r_status="" + r_keys=" " + r_nonempty=0 +} + +# record_label — how a validation message names the record being checked. +record_label() { + if [[ -n "$r_id" ]]; then + printf 'record %d (id: %s, line %d)' "$((REC_COUNT + 1))" "$r_id" "$r_start" + else + printf 'record %d (line %d)' "$((REC_COUNT + 1))" "$r_start" + fi +} + +invalid() { + # invalid + echo "ERROR: $MANIFEST: $(record_label): field '$1' $2" >&2 + exit 2 +} + +# strip_quotes — remove one layer of matching single or double quotes. +strip_quotes() { + local v="$1" + case "$v" in + \'*\') [[ ${#v} -ge 2 ]] && v="${v#\'}" && v="${v%\'}" ;; + \"*\") [[ ${#v} -ge 2 ]] && v="${v#\"}" && v="${v%\"}" ;; + *) ;; + esac + printf '%s' "$v" +} + +finish_record() { + [[ $r_nonempty -eq 1 ]] || return 0 + + [[ -n "$r_id" ]] || invalid id "is required" + printf '%s' "$r_id" | grep -Eq '^[a-z0-9]([a-z0-9-]*[a-z0-9])?-r[0-9]{3,}$' || + invalid id "must be -rNNN: '$r_id'" + case "$SEEN_IDS" in + *" +$r_id +"*) invalid id "duplicates an earlier record's id: '$r_id'" ;; + *) ;; + esac + SEEN_IDS="${SEEN_IDS}${r_id} +" + + [[ -n "$r_retired" ]] || invalid retired "is required" + printf '%s' "$r_retired" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' || + invalid retired "must be YYYY-MM-DD: '$r_retired'" + + [[ -n "$r_plugin_version" ]] || invalid plugin_version "is required" + printf '%s' "$r_plugin_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' || + invalid plugin_version "must be semver: '$r_plugin_version'" + + case "$r_kind" in + file | dir | line) ;; + "") invalid kind "is required" ;; + *) invalid kind "must be file, dir or line: '$r_kind'" ;; + esac + + [[ -n "$r_path" ]] || invalid path "is required" + case "$r_path" in + /*) invalid path "must be repo-relative, not absolute: '$r_path'" ;; + [A-Za-z]:*) invalid path "must be repo-relative, not a drive path: '$r_path'" ;; + '~'*) invalid path "must not start with ~: '$r_path'" ;; + *"$BACKSLASH"*) invalid path "must use forward slashes: '$r_path'" ;; + *"$TAB"*) invalid path "must not contain a tab: '$r_path'" ;; + . | ./ | ./. | */. | */./ | *//*) invalid path "must name a file or directory inside the repo, not the repo itself: '$r_path'" ;; + *) ;; + esac + printf '%s' "$r_path" | grep -Eq '(^|/)\.\.(/|$)' && + invalid path "must not contain a .. segment: '$r_path'" + + if [[ "$r_kind" == "line" ]]; then + [[ -n "$r_match" ]] || invalid match "is required for kind line" + ere_valid "$r_match" || invalid match "is not a valid POSIX ERE: '$r_match'" + elif [[ -n "$r_match" ]]; then + invalid match "is only allowed for kind line (kind is $r_kind)" + fi + + if [[ -n "$r_heading" ]]; then + [[ "$r_kind" == "line" ]] || invalid heading "is only allowed for kind line (kind is $r_kind)" + printf '%s' "$r_heading" | grep -Eq '^#{1,6}[[:space:]]+[^[:space:]]' || + invalid heading "must be an ATX heading (1-6 hashes, whitespace, title): '$r_heading'" + fi + + if [[ -n "$r_content_match" ]]; then + [[ "$r_kind" == "file" ]] || invalid content_match "is only allowed for kind file (kind is $r_kind)" + ere_valid "$r_content_match" || invalid content_match "is not a valid POSIX ERE: '$r_content_match'" + fi + + case "$r_action" in + delete) + [[ "$r_kind" != "line" ]] || invalid action "delete is not allowed for kind line (use remove-line)" + ;; + remove-line) + [[ "$r_kind" == "line" ]] || invalid action "remove-line requires kind line (kind is $r_kind)" + ;; + migrate) + [[ -n "$r_successor" ]] || invalid successor "is required for action migrate" + ;; + "") invalid action "is required" ;; + *) invalid action "must be delete, remove-line or migrate: '$r_action'" ;; + esac + + [[ -n "$r_note" ]] || invalid note "is required" + case "$r_note" in + *"$TAB"*) invalid note "must not contain a tab" ;; + *) ;; + esac + + case "$r_status" in + "") r_status="active" ;; + active | report-only) ;; + *) invalid status "must be active or report-only: '$r_status'" ;; + esac + + REC_ID[REC_COUNT]="$r_id" + REC_KIND[REC_COUNT]="$r_kind" + REC_PATH[REC_COUNT]="$r_path" + REC_MATCH[REC_COUNT]="$r_match" + REC_HEADING[REC_COUNT]="$r_heading" + REC_CONTENT_MATCH[REC_COUNT]="$r_content_match" + REC_ACTION[REC_COUNT]="$r_action" + REC_SUCCESSOR[REC_COUNT]="$r_successor" + REC_NOTE[REC_COUNT]="$r_note" + REC_STATUS[REC_COUNT]="$r_status" + REC_COUNT=$((REC_COUNT + 1)) +} + +lineno=0 +reset_record 1 +while IFS= read -r line || [[ -n "$line" ]]; do + lineno=$((lineno + 1)) + line="${line%$'\r'}" + case "$line" in + ---) + finish_record + reset_record $((lineno + 1)) + continue + ;; + "" | \#*) continue ;; + *) ;; + esac + # Leading whitespace only ever precedes a comment or nothing in a flat + # manifest; anything else is a nesting attempt and is rejected below. + case "$line" in + [[:space:]]*) + trimmed="${line#"${line%%[![:space:]]*}"}" + case "$trimmed" in + "" | \#*) continue ;; + *) ;; + esac + ;; + *) ;; + esac + if [[ ! "$line" =~ ^([a-z_]+):[[:space:]]*(.*)$ ]]; then + echo "ERROR: $MANIFEST: $(record_label): line $lineno is not 'key: value': $line" >&2 + exit 2 + fi + key="${BASH_REMATCH[1]}" + value="${BASH_REMATCH[2]}" + value="${value%"${value##*[![:space:]]}"}" + value="$(strip_quotes "$value")" + r_nonempty=1 + case "$r_keys" in + *" $key "*) + echo "ERROR: $MANIFEST: $(record_label): field '$key' is set twice (line $lineno)" >&2 + exit 2 + ;; + *) ;; + esac + r_keys="${r_keys}${key} " + case "$key" in + id) r_id="$value" ;; + retired) r_retired="$value" ;; + plugin_version) r_plugin_version="$value" ;; + kind) r_kind="$value" ;; + path) r_path="$value" ;; + match) r_match="$value" ;; + heading) r_heading="$value" ;; + content_match) r_content_match="$value" ;; + action) r_action="$value" ;; + successor) r_successor="$value" ;; + note) r_note="$value" ;; + status) r_status="$value" ;; + *) + echo "ERROR: $MANIFEST: $(record_label): field '$key' is not part of the schema (line $lineno)" >&2 + exit 2 + ;; + esac +done <"$MANIFEST" +finish_record + +# --------------------------------------------------------------------------- +# Detection primitives +# --------------------------------------------------------------------------- + +# content_hits — 0 when some line of , its trailing CR +# stripped, matches . grep is used WITHOUT -q so it drains the pipe: under +# pipefail an early-closing grep would turn awk's SIGPIPE into a failed test. +content_hits() { + awk '{ sub(/\r$/, ""); print }' "$1" | grep -E -e "$2" >/dev/null +} + +# section_body_nrs — one 1-based line number per line that +# sits in the body of every markdown section whose heading line equals +# (trailing whitespace ignored on both sides). The heading line +# itself is excluded. A section ends at the next ATX heading of the same or +# higher level, or EOF. POSIX awk only: no interval quantifiers. +section_body_nrs() { + awk -v heading="$2" ' + function rtrim(s) { + sub(/[ \t]+$/, "", s) + return s + } + function atx_level(s, n) { + n = 0 + while (substr(s, n + 1, 1) == "#") n++ + if (n >= 1 && n <= 6 && substr(s, n + 1, 1) ~ /[ \t]/) return n + return 0 + } + { + sub(/\r$/, "") + trimmed = rtrim($0) + if (in_section) { + lvl = atx_level(trimmed) + if (lvl > 0 && lvl <= start_level) in_section = 0 + } + if (in_section == 0 && trimmed == heading) { + in_section = 1 + start_level = atx_level(trimmed) + if (start_level == 0) start_level = 6 + next + } + if (in_section) print NR + } + ' "$1" +} + +# matching_line_nrs [heading] — space-separated 1-based line +# numbers whose text (CR stripped) matches . When is non-empty, +# only lines inside that heading's section body. +matching_line_nrs() { + local file="$1" ere="$2" heading="${3:-}" all scoped n + all=$(awk '{ sub(/\r$/, ""); print }' "$file" | grep -E -n -e "$ere" | cut -d: -f1 | tr '\n' ' ') + if [[ -z "$heading" ]]; then + printf '%s' "$all" + return + fi + scoped=$(section_body_nrs "$file" "$heading" | tr '\n' ' ') + for n in $all; do + case " $scoped " in + *" $n "*) printf '%s ' "$n" ;; + *) ;; + esac + done +} + +# present — 0 when record 's artifact is present in ROOT. +present() { + local i="$1" target nrs + target="$ROOT/${REC_PATH[$i]}" + case "${REC_KIND[$i]}" in + file) + [[ -f "$target" ]] || return 1 + [[ -z "${REC_CONTENT_MATCH[$i]}" ]] && return 0 + content_hits "$target" "${REC_CONTENT_MATCH[$i]}" + ;; + dir) + [[ -d "$target" ]] + ;; + line) + [[ -f "$target" ]] || return 1 + nrs=$(matching_line_nrs "$target" "${REC_MATCH[$i]}" "${REC_HEADING[$i]}") + [[ -n "${nrs// /}" ]] + ;; + *) return 1 ;; + esac +} + +# --------------------------------------------------------------------------- +# Detect mode +# --------------------------------------------------------------------------- + +if [[ -z "$CLEAN_ID" ]]; then + active_hits=0 + report_only_hits=0 + i=0 + while [[ $i -lt $REC_COUNT ]]; do + if present "$i"; then + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "${REC_ID[$i]}" "${REC_KIND[$i]}" "${REC_PATH[$i]}" \ + "${REC_ACTION[$i]}" "${REC_STATUS[$i]}" "${REC_NOTE[$i]}" + if [[ "${REC_STATUS[$i]}" == "active" ]]; then + active_hits=$((active_hits + 1)) + else + report_only_hits=$((report_only_hits + 1)) + fi + fi + i=$((i + 1)) + done + echo "check-retirements: $MANIFEST: $REC_COUNT record(s) evaluated against $ROOT — $active_hits active leftover(s), $report_only_hits report-only." >&2 + [[ $active_hits -eq 0 ]] && exit 0 + exit 1 +fi + +# --------------------------------------------------------------------------- +# Clean mode +# --------------------------------------------------------------------------- + +idx=-1 +i=0 +while [[ $i -lt $REC_COUNT ]]; do + if [[ "${REC_ID[$i]}" == "$CLEAN_ID" ]]; then + idx=$i + break + fi + i=$((i + 1)) +done +[[ $idx -ge 0 ]] || die "$MANIFEST has no record with id '$CLEAN_ID'" + +kind="${REC_KIND[$idx]}" +path="${REC_PATH[$idx]}" +action="${REC_ACTION[$idx]}" +target="$ROOT/$path" + +if [[ "$action" == "migrate" && $I_MIGRATED -eq 0 ]]; then + echo "ERROR: $CLEAN_ID is a migrate record: its content must be carried over before the artifact goes." >&2 + echo " successor: ${REC_SUCCESSOR[$idx]}" >&2 + echo " Re-run with --i-migrated once that is done." >&2 + exit 2 +fi + +locked_hint="re-run after closing the file (on Windows a locked file makes the remove fail; nothing was changed)" + +# File and line cleanup share this: a syntactically clean repo-relative path can +# still walk a symlink parent out of ROOT. Re-resolve at the moment of use. +assert_target_inside_root() { + local real_root real_parent + real_root=$(cd "$ROOT" 2>/dev/null && pwd -P) || die "cannot resolve root: $ROOT" + real_parent=$(cd "$(dirname -- "$target")" 2>/dev/null && pwd -P) || die "cannot resolve $path" + case "$real_parent" in + "$real_root" | "$real_root"/*) ;; + *) die "refusing to remove $path — it resolves outside the repository root ($real_parent)" ;; + esac +} + +if ! present "$idx"; then + if [[ "$kind" == "file" && -f "$target" && -n "${REC_CONTENT_MATCH[$idx]}" ]]; then + echo "check-retirements: $CLEAN_ID: $path exists but its content no longer matches the record — the path is in use by something else; nothing to clean." >&2 + else + echo "check-retirements: $CLEAN_ID: nothing present at $path to clean." >&2 + fi + exit 1 +fi + +case "$kind" in +file) + assert_target_inside_root + if ! rm -f "$target"; then + die "could not remove $path — $locked_hint" + fi + echo "check-retirements: $CLEAN_ID: removed $path." >&2 + exit 0 + ;; +dir) + # rm -rf is the one thing here that can do real damage, so the path is + # re-resolved at the moment of use: it must land strictly inside ROOT and + # must not be ROOT itself, whatever the manifest text said. + real_root=$(cd "$ROOT" 2>/dev/null && pwd -P) || die "cannot resolve root: $ROOT" + real_target=$(cd "$target" 2>/dev/null && pwd -P) || die "cannot resolve $path" + [[ "$real_target" != "$real_root" ]] || die "refusing to remove $path — it resolves to the repository root" + case "$real_target" in + "$real_root"/*) ;; + *) die "refusing to remove $path — it resolves outside the repository root ($real_target)" ;; + esac + if ! rm -rf "$target"; then + die "could not remove directory $path — $locked_hint" + fi + echo "check-retirements: $CLEAN_ID: removed directory $path." >&2 + exit 0 + ;; +line) + assert_target_inside_root + match="${REC_MATCH[$idx]}" + # Which input lines match, by number, decided once by grep -E (the same ERE + # dialect detection used); when heading is set, only section-body hits + # count. awk then copies every other line through with its own bytes, CR + # included. Only the matched lines' text is stripped of the CR, and only + # for the comparison. + matched_lines=$(matching_line_nrs "$target" "$match" "${REC_HEADING[$idx]}") + [[ -n "$matched_lines" ]] || { + echo "check-retirements: $CLEAN_ID: no line of $path matches; nothing to clean." >&2 + exit 1 + } + # Whether the original's last line carries a newline: `tail -c 1` prints the + # final byte and command substitution eats a trailing newline, so an empty + # result means the file ended with one. + if [[ -z "$(tail -c 1 "$target")" ]]; then + ends_with_newline=1 + else + ends_with_newline=0 + fi + dir=$(dirname "$target") + tmp=$(mktemp "$dir/.check-retirements.XXXXXX") || die "could not create a temp file beside $path" + # cp -p so the rewritten file keeps the original's mode; the content is + # replaced by the redirect below. + cp -p "$target" "$tmp" 2>/dev/null || true + if ! awk -v skip=" $matched_lines" -v nl="$ends_with_newline" ' + { if (index(skip, " " NR " ") > 0) next; kept[++k] = $0; at[k] = NR } + END { + for (i = 1; i <= k; i++) { + printf "%s", kept[i] + # Every line but the original last one had a newline after it; the + # last one had it only if the file did. + if (at[i] < NR || nl) printf "\n" + } + }' "$target" >"$tmp"; then + rm -f "$tmp" + die "could not rewrite $path — $locked_hint" + fi + if ! mv -f "$tmp" "$target"; then + rm -f "$tmp" + die "could not replace $path with the rewritten file — $locked_hint" + fi + removed=$(printf '%s' "$matched_lines" | wc -w | tr -d ' ') + echo "check-retirements: $CLEAN_ID: removed $removed line(s) from $path." >&2 + exit 0 + ;; +*) die "unreachable kind: $kind" ;; +esac diff --git a/plugins/claude-ops/retirements.yaml b/plugins/claude-ops/retirements.yaml new file mode 100644 index 0000000000..fa73397c1f --- /dev/null +++ b/plugins/claude-ops/retirements.yaml @@ -0,0 +1,14 @@ +# Retired consumer-facing conventions for the claude-ops plugin. +# Append-only: records are never deleted and their detection fields are never +# edited once published (demote with `status: report-only` instead). Schema and +# helper contract: docs/conventions/retired-conventions/README.md in the +# consuming marketplace; evaluated by lib/check-retirements.sh from setup. +--- +id: claude-ops-r001 +retired: 2026-09-05 +plugin_version: 0.42.6 +kind: file +path: .claude/observability/hook-events.jsonl +action: migrate +successor: "the reference telemetry sink now writes under the hook log root (session_event_log_dir, default .observability/claude): an envelope carrying data.session_id goes to sessions/.jsonl there and every other envelope to hook-events.jsonl there, and the observability skill reads both from that root. Append the old file's lines to /hook-events.jsonl (the record shape is unchanged), confirm with the operator that /claude-ops:observability counts them at the new root, then clean. Rows older than the retention window may be dropped instead of carried" +note: "hook-events.jsonl moved from .claude/observability to the hook log root; the skill-usage and OTEL stores stay where they were" diff --git a/plugins/claude-ops/skills/observability/SKILL.md b/plugins/claude-ops/skills/observability/SKILL.md index 78ccd37975..340a079bee 100644 --- a/plugins/claude-ops/skills/observability/SKILL.md +++ b/plugins/claude-ops/skills/observability/SKILL.md @@ -1,12 +1,12 @@ --- -description: "Read and report on locally captured Claude Code telemetry, OTEL DuckDB store, collector, optional Aspire dashboard, hook-event JSONL, ccusage, with cross-session trend reports and store pruning. Use when: 'claude observability', 'OTEL', 'collector', 'token burn rate', 'hook latency', 'cost breakdown', 'how am I doing'; read-only except the explicit clean action." +description: "Read and report on locally captured Claude Code telemetry, OTEL DuckDB store, collector, optional Aspire dashboard, the per-session hook event log and hook-event JSONL, ccusage, with cross-session trend reports, a per-session report, and store pruning. Use when: 'claude observability', 'OTEL', 'collector', 'token burn rate', 'hook latency', 'cost breakdown', 'how am I doing', 'what did this session do', 'hook event log', 'which hooks fired'; read-only except the explicit clean action." user-invocable: true disable-model-invocation: false -argument-hint: "[scope|action]. Week (default), session, day, month, since:YYYY-MM-DD, all, clean [--keep-days N] [--dry-run] [--skill-usage-scope repo|user|data-dir]" +argument-hint: "[scope|action]. Week (default), session, session:, day, month, since:YYYY-MM-DD, all, clean [--keep-days N] [--dry-run] [--hook-root REL] [--skill-usage-scope repo|user|data-dir]" shell: bash metadata: workflow-stage: operator - summary: Report on locally captured telemetry. Token burn, cost, hook latency, trends + summary: Report on locally captured telemetry. Token burn, cost, hook latency, per-session activity cadence: weekly --- @@ -15,20 +15,24 @@ metadata: Current branch: !`git branch --show-current 2>/dev/null || echo "unknown"` Repo slug: !`git rev-parse --show-toplevel >/dev/null 2>&1 && git rev-parse --show-toplevel 2>/dev/null | sed 's|.*/||' || echo "(git toplevel unavailable)"` ccusage availability: !`command -v npx >/dev/null 2>&1 && echo "npx present" || echo "npx MISSING"` -Hook event log: !`bash "${CLAUDE_PLUGIN_ROOT}/skills/observability/scripts/probe-observability-state.sh" --hook-events 2>/dev/null || echo "unknown"` +Hook log root (rendered option, empty or unrendered means the default `.observability/claude`): `${user_config.session_event_log_dir}` +Hook event log: !`bash "${CLAUDE_PLUGIN_ROOT}/skills/observability/scripts/probe-observability-state.sh" --hook-events --root "${user_config.session_event_log_dir}" 2>/dev/null || echo "unknown"` +Hook logging pipeline: !`bash "${CLAUDE_PLUGIN_ROOT}/skills/observability/scripts/probe-observability-state.sh" --pipeline --root "${user_config.session_event_log_dir}" --enabled "${user_config.session_event_log_enabled}" --categories "${user_config.session_event_log_categories}" --keep-sessions "${user_config.session_log_keep_sessions}" --keep-days "${user_config.session_log_keep_days}" --pre-prune-command "${user_config.session_log_pre_prune_command}" 2>/dev/null || echo "unknown"` OTEL collector :4318: !`bash -c 'source "${CLAUDE_PLUGIN_ROOT}/skills/observability/otel/net-probe.sh" && port_status 4318' 2>/dev/null || echo unknown` OTEL store: !`bash "${CLAUDE_PLUGIN_ROOT}/skills/observability/scripts/probe-observability-state.sh" --otel-store 2>/dev/null || echo "unknown"` ## Purpose **Single place to read Claude Code observability**, where to read telemetry, how the -collector/dashboard/store fit together, and cross-session trend reports. **CC** shorthand = -Claude Code CLI. See [context/operator-setup.md](context/operator-setup.md) "Naming". -Progressive disclosure lives in `context/` (read on demand. Do not recap inline). +collector/dashboard/store fit together, cross-session trend reports, and what one session did +(which hooks fired, what was blocked, the event timeline). **CC** shorthand = Claude Code CLI. +See [context/operator-setup.md](context/operator-setup.md) "Naming". Progressive disclosure +lives in `context/` (read on demand. Do not recap inline). **Read-only**, never writes user-visible state except a report file under `${CLAUDE_PLUGIN_DATA}/reports/` (when `--write` is passed). Honors -[context/privacy.md](context/privacy.md). +[context/privacy.md](context/privacy.md). Turning the hook logging pipeline on or off, and +placing its guard, is `/claude-ops:setup`'s job; this skill reports what is in effect. **Not `/claude-ops:known-issues`**. That skill tracks Anthropic product bugs and GitHub issues. This skill reads **your** captured telemetry and ops signals. @@ -44,8 +48,8 @@ This skill reads **your** captured telemetry and ops signals. | [context/operator-setup-collector-daemon.md](context/operator-setup-collector-daemon.md) | Collector/Aspire service down or unhealthy, lifecycle repair | | [context/operator-setup-retention.md](context/operator-setup-retention.md) | Prune mechanics, retention knobs, scheduled prune task | | [context/operator-setup-emission-privacy.md](context/operator-setup-emission-privacy.md) | Emission tiers, content-capture keys, privacy toggle | -| [context/data-sources.md](context/data-sources.md) | JSONL + ccusage jq (batch reports) | -| [context/output-format.md](context/output-format.md) | Rendering scope reports | +| [context/data-sources.md](context/data-sources.md) | JSONL + ccusage jq (batch reports, the per-session report, toggles in effect) | +| [context/output-format.md](context/output-format.md) | Rendering scope reports and the per-session report | | [context/privacy.md](context/privacy.md) | Before any user-visible output | OTEL query and retention helpers live in `otel/` (private backends) with stable entry points in @@ -60,15 +64,20 @@ and dashboard lifecycle. | Scope | Window | Use case | |---|---|---| -| `session` | current session only | quick check before `/clear` | +| `session` | the newest session file (by mtime) under the hook log root | what this session did, before `/clear` | +| `session:` | one named session file, `sessions/.jsonl` | a session the timeline or another report named | | `day` | last 24 hours | end-of-day review | | `week` (**default**) | last 7 days | weekly retro complement | | `month` | last 30 days | trend evaluation | | `since:YYYY-MM-DD` | from explicit date | post-launch evaluation | | `all` | no filter | full history | -Optional second token: `--write` (persist report to -`${CLAUDE_PLUGIN_DATA}/reports/claude-observability-.md` instead of stdout). +The two session scopes render the per-session skeleton in +[context/output-format.md](context/output-format.md); every other scope renders the whole-root +report. Optional tokens: `--write` (persist the report to +`${CLAUDE_PLUGIN_DATA}/reports/claude-observability-.md` instead of stdout) and +`--hook-root REL` (read a different hook log root for this run; the rendered option above is +the default). When the scope is `week` or larger, optionally offer a self-contained HTML dashboard rendering the same multi-metric trend report alongside the markdown (session/day stay markdown; markdown @@ -78,12 +87,14 @@ remains the durable record). | Action | Args | Effect | |---|---|---| -| `clean` | `[--keep-days N]` (default 30) `[--dry-run]` `[--quiet]` `[--skill-usage-scope repo\|user\|data-dir]` `[--skill-usage-dir REL]` `[--keep-skill-usage-days N]` (default 365) | Prune JSONL + OTEL store. See [context/read-routing.md](context/read-routing.md) "Retention" and `scripts/clean.sh`. Skill-usage pruning is **opt-in**: inert unless `--skill-usage-scope` is passed, and `data-dir` requires an explicit `--skill-usage-dir` rather than trusting `CLAUDE_PLUGIN_DATA` | +| `clean` | `[--keep-days N]` (default 30) `[--dry-run]` `[--quiet]` `[--hook-root REL]` `[--skill-usage-scope repo\|user\|data-dir]` `[--skill-usage-dir REL]` `[--keep-skill-usage-days N]` (default 365) | Prune the hook log root (the shared file line by line, session files untouched for the window whole, stale `prune-pending/` sets regardless of the logging switch), the retired shared file while it exists, and the OTEL store. See [context/read-routing.md](context/read-routing.md) "Retention" and `scripts/clean.sh`. Skill-usage pruning is **opt-in**: inert unless `--skill-usage-scope` is passed, and `data-dir` requires an explicit `--skill-usage-dir` rather than trusting `CLAUDE_PLUGIN_DATA` | Action invocation: `/claude-ops:observability clean [flags]`. **`clean` requires explicit user confirmation** before running when invoked by the model. Show `--dry-run` output first unless user already passed `--dry-run` or explicitly ordered cleanup. +Routine retention (newest N sessions or the last N days) is the `SessionEnd` hook's job while +`session_event_log_enabled` is on; `clean` is the operator's explicit sweep. ### Ad-hoc telemetry reads (no special action) @@ -107,8 +118,8 @@ fi SCOPE="${1:-week}" case "$SCOPE" in session|day|week|month|all) ;; - since:*) ;; - *) echo "Unknown scope: $SCOPE. Use session|day|week|month|since:YYYY-MM-DD|all|clean" >&2; exit 1 ;; + session:*|since:*) ;; + *) echo "Unknown scope: $SCOPE. Use session|session:|day|week|month|since:YYYY-MM-DD|all|clean" >&2; exit 1 ;; esac ``` @@ -119,7 +130,8 @@ Read [context/data-sources.md](context/data-sources.md). Summary: | Source | Path | What it provides | |---|---|---| | ccusage | MCP or CLI | Token counts, cost USD, billing blocks | -| Hook event log | `.claude/observability/hook-events.jsonl` (project-relative; present only if the consumer's hooks emit it) | Hook duration, exit codes | +| Hook log root | `/` (`.observability/claude` by default, project-relative): `sessions/.jsonl` and the shared `hook-events.jsonl`; present only once a producer wrote there | Hook duration, exit codes, what was blocked, the per-session event timeline | +| Pipeline state | `scripts/probe-observability-state.sh --pipeline` (the "Hook logging pipeline" line above) | Toggles, retention, guard state, stale prune sets | | OTEL store | `$CC_OTEL_STORE/*.json` → DuckDB | Logs, metrics, spans. [context/otel-queries.md](context/otel-queries.md) | | Auto-memory | `~/.claude/.../memory/feedback_*.md` | User-correction patterns | | Git / GH | `git log`, `gh pr list` | Activity context | @@ -127,16 +139,20 @@ Read [context/data-sources.md](context/data-sources.md). Summary: ### 2–5. Compute, privacy, render, output Unchanged. [context/data-sources.md](context/data-sources.md), [context/privacy.md](context/privacy.md), -[context/output-format.md](context/output-format.md). +[context/output-format.md](context/output-format.md). Every report ends with the "Toggles and +retention in effect" section, the six probe lines verbatim. ## Cross-references - `/claude-ops:known-issues`. CC product bugs (not telemetry reads) +- `/claude-ops:setup`. Turns the hook logging pipeline on, places the guard, migrates the retired shared-file location ## Gotchas - Empty stores are normal on first run. Degrade gracefully -- **`session_id` drift**. Use `cwd` + `branch` + time proximity +- **`session_id` joins only per-session files**. Rows in `sessions/.jsonl` carry the id; rows in the shared `hook-events.jsonl` do not, and are never attributed to a session (say "legacy rows, shared file, time proximity only"). OTEL rows join on `session_id` as before; `cwd` + `branch` + time proximity is the fallback for a producer that sends none +- **Per-hook duration per session covers producers that emit `data.session_id`** (the nine claude-ops audit hooks). Other hooks appear in the whole-root tables only +- **Hooks run in parallel**. Row order within one second is write order, not fire order; group by `prompt_id` or `tool_use_id`, not by adjacency - **Stop hook unreliability**. Do not rely on Stop for aggregation - **`cc_spans` / `cc_traces`**. Views skip bind until `cc-traces.json` has content @@ -144,5 +160,6 @@ Unchanged. [context/data-sources.md](context/data-sources.md), [context/privacy. - **Does not track GitHub bugs**. Invoke `/claude-ops:known-issues` via the Skill tool - **Does not modify code**. Read-only +- **Does not configure the pipeline**. `/claude-ops:setup` owns the toggles and the guard - **Does not replace built-in `/insights`** or your own retrospective workflow - **Does not write to memory** unless user explicitly saves diff --git a/plugins/claude-ops/skills/observability/claude-observability.test.sh b/plugins/claude-ops/skills/observability/claude-observability.test.sh index 5dcfbe412b..3e22b87f0f 100755 --- a/plugins/claude-ops/skills/observability/claude-observability.test.sh +++ b/plugins/claude-ops/skills/observability/claude-observability.test.sh @@ -28,11 +28,18 @@ assert_eq() { if [[ "$3" == "$2" ]]; then pass "$1"; else fail "$1" "$2" "$3"; f assert_contains() { if [[ "$2" == *"$3"* ]]; then pass "$1"; else fail "$1" "contains: $3" "$2"; fi; } assert_not_contains() { if [[ "$2" != *"$3"* ]]; then pass "$1"; else fail "$1" "absent: $3" "$2"; fi; } -# --- Fixture: hook-events.jsonl --- -HOOK_LOG="$TEST_TMPDIR/hook-events.jsonl" +# --- Fixture: a hook log root --- +# The shared file (legacy rows, `event` key) plus one per-session file whose +# envelope rows carry `hook_event_name` and `source: "envelope"`. Every +# whole-root query reads both through the HOOK_NORM prelude in data-sources.md. +HOOK_ROOT="$TEST_TMPDIR/root" +mkdir -p "$HOOK_ROOT/sessions" +HOOK_LOG="$HOOK_ROOT/hook-events.jsonl" +SESSION_LOG="$HOOK_ROOT/sessions/s-a.jsonl" SINCE_ISO="2026-01-01T00:00:00Z" +HOOK_NORM='map(. + {event: (.event // .hook_event_name)})' -# Append a single event to $HOOK_LOG. Schema mirrors observability/conventions.md fields; +# Append a single legacy event to the shared file. Schema mirrors observability/conventions.md fields; # session_id/branch/cwd are constant across the fixture (no test asserts on them). # Args: emit_event() { @@ -46,6 +53,28 @@ emit_event() { session_id:"s1", branch:"main", cwd:"/repo"}' \ >>"$HOOK_LOG" } +# Append one per-session envelope row (the reference sink's per-session shape). +# Args: +emit_session_event() { + jq -nc \ + --arg ts "$1" --arg hook "$2" --arg ev "$3" \ + --argjson duration_ms "$4" --argjson exit_code "$5" \ + --arg subject "$6" --arg status "$7" \ + '{ts:$ts, session_id:"s-a", hook_event_name:$ev, status:$status, + duration_ms:$duration_ms, source:"envelope", hook:$hook, + exit_code:$exit_code, subject:$subject, tool:"Write"}' \ + >>"$SESSION_LOG" +} +# Append one per-session event-log row (session-event-log.sh's shape). No hook. +# Args: [tool_name] [file_path] +emit_log_row() { + jq -nc --arg ts "$1" --arg ev "$2" --arg cat "$3" --arg tool "${4:-}" --arg file "${5:-}" \ + '{ts:$ts, session_id:"s-a", hook_event_name:$ev, category:$cat, status:"ok", + source:"event-log", duration_ms:1, prompt_id:"p-1"} + + (if $tool == "" then {} else {tool_name:$tool} end) + + (if $file == "" then {} else {file_path:$file} end)' \ + >>"$SESSION_LOG" +} # Count lines in a JSONL fixture, stripping spaces and Git Bash CR. lines_in() { @@ -68,9 +97,24 @@ emit_event "2026-04-29T11:05:00.000Z" sarif-diagnostics Edit 1200 2 b.cs error # Out-of-window event (should be filtered) emit_event "2025-01-01T00:00:00.000Z" old-hook Write 100 0 old.sh success -# --- Test 1: latency p50/p95 per (hook,event) --- -LAT_OUT=$(jq -s --arg since "$SINCE_ISO" ' - map(select(.ts >= $since)) +# Per-session file: two more bash-format fires, one blocked guard, and event-log +# rows (which carry no hook and must never count as a hook fire). +emit_session_event "2026-04-29T12:01:00.000Z" bash-format PostToolUse 130 0 s.sh success +emit_session_event "2026-04-29T12:02:00.000Z" bash-format PostToolUse 140 0 t.sh success +emit_session_event "2026-04-29T12:03:00.000Z" block-dangerous-git PreToolUse 7 2 "Bash:git push --force" blocked +emit_log_row "2026-04-29T12:00:59.000Z" PreToolUse tool Write s.sh +emit_log_row "2026-04-29T12:01:00.500Z" PostToolUse tool Write s.sh +emit_log_row "2026-04-29T12:05:00.000Z" Stop turn + +# The whole-root file set, as data-sources.md builds it. +shopt -s nullglob +HOOK_FILES=("$HOOK_ROOT"/sessions/*.jsonl) +shopt -u nullglob +[[ -f "$HOOK_LOG" ]] && HOOK_FILES+=("$HOOK_LOG") +assert_eq "file set: one session file plus the shared file" "2" "${#HOOK_FILES[@]}" + +# --- Test 1: latency p50/p95 per (hook,event) across the root --- +LAT_OUT=$(jq -s --arg since "$SINCE_ISO" "$HOOK_NORM"' | map(select(.ts >= $since and .hook != null)) | group_by(.hook + "|" + .event) | map({ key: (.[0].hook + " " + .[0].event), @@ -78,39 +122,75 @@ LAT_OUT=$(jq -s --arg since "$SINCE_ISO" ' p50: (sort_by(.duration_ms) | .[length/2|floor].duration_ms), p95: (sort_by(.duration_ms) | .[(length*0.95)|floor].duration_ms), max: (max_by(.duration_ms).duration_ms) - })' "$HOOK_LOG") + })' "${HOOK_FILES[@]}") assert_contains "bash-format key present" "$LAT_OUT" "bash-format PostToolUse" assert_contains "sarif-diagnostics key present" "$LAT_OUT" "sarif-diagnostics PostToolUse" +assert_contains "a per-session envelope row keys on hook_event_name" "$LAT_OUT" "block-dangerous-git PreToolUse" bash_format_n=$(echo "$LAT_OUT" | jq -r '.[] | select(.key=="bash-format PostToolUse") | .n') -assert_eq "bash-format count = 12" "12" "$bash_format_n" +assert_eq "bash-format count = 14 (12 shared + 2 per-session)" "14" "$bash_format_n" bash_format_max=$(echo "$LAT_OUT" | jq -r '.[] | select(.key=="bash-format PostToolUse") | .max') assert_eq "bash-format max = 6000" "6000" "$bash_format_max" # --- Test 2: error rate per hook --- -ERR_OUT=$(jq -s --arg since "$SINCE_ISO" ' - map(select(.ts >= $since)) +ERR_OUT=$(jq -s --arg since "$SINCE_ISO" "$HOOK_NORM"' | map(select(.ts >= $since and .hook != null)) | group_by(.hook) | map({ hook: .[0].hook, n: length, errors: (map(select(.exit_code != 0)) | length) }) - | map(select(.errors > 0))' "$HOOK_LOG") + | map(select(.errors > 0))' "${HOOK_FILES[@]}") assert_contains "sarif-diagnostics has errors" "$ERR_OUT" "sarif-diagnostics" sarif_err=$(echo "$ERR_OUT" | jq -r '.[] | select(.hook=="sarif-diagnostics") | .errors') assert_eq "sarif error count = 1" "1" "$sarif_err" +assert_contains "a blocked per-session row counts as an error" "$ERR_OUT" "block-dangerous-git" -# --- Test 3: window filter excludes 2025 event --- -filtered_count=$(jq -s --arg since "$SINCE_ISO" \ - 'map(select(.ts >= $since)) | length' "$HOOK_LOG") -assert_eq "window filter applied" "17" "$filtered_count" # 12 + 5 (excludes old-hook) +# --- Test 3: window filter excludes 2025 event; event-log rows never count as hooks --- +filtered_count=$(jq -s --arg since "$SINCE_ISO" "$HOOK_NORM"' | map(select(.ts >= $since and .hook != null)) | length' "${HOOK_FILES[@]}") +assert_eq "window filter applied" "20" "$filtered_count" # 12 + 5 + 3 (excludes old-hook and the 3 event-log rows) assert_not_contains "out-of-window event excluded" "$LAT_OUT" "old-hook" +# --- Test 3.5: the per-session report (data-sources.md §2.5) --- +SESSION_FILES=("$SESSION_LOG") +FIRED=$(jq -s "$HOOK_NORM"' | map(select(.source == "envelope")) + | group_by(.hook) + | map({hook: .[0].hook, n: length, + events: (map(.event) | unique), + errors: (map(select(.exit_code != 0)) | length), + p50_ms: (sort_by(.duration_ms) | .[length/2|floor].duration_ms), + max_ms: (max_by(.duration_ms).duration_ms)}) + | sort_by(-.n)' "${SESSION_FILES[0]}") +assert_eq "per-session: two hooks fired" "2" "$(echo "$FIRED" | jq 'length')" +assert_eq "per-session: bash-format fired twice" "2" "$(echo "$FIRED" | jq -r '.[] | select(.hook=="bash-format") | .n')" +assert_eq "per-session: event-log rows are not hook fires" "0" "$(echo "$FIRED" | jq '[.[] | select(.hook == null)] | length')" + +BLOCKED=$(jq -sc "$HOOK_NORM"' | .[] | select(.status == "blocked") | {ts, hook, event, subject}' "${SESSION_FILES[0]}") +assert_eq "per-session: one blocked row" "1" "$(printf '%s\n' "$BLOCKED" | grep -c .)" +assert_contains "per-session: blocked row names the hook" "$BLOCKED" "block-dangerous-git" + +REWROTE=$(jq -sc "$HOOK_NORM"' | .[] | select(.changed == true) | {ts, hook, subject}' "${SESSION_FILES[0]}") +assert_eq "per-session: rewrote is empty until a producer emits changed" "" "$REWROTE" + +TIMELINE=$(jq -sr '.[] | select(.source == "event-log") + | [.ts, .hook_event_name, .category, (.tool_name // ""), (.file_path // ""), (.agent_id // "")] + | @tsv' "${SESSION_FILES[0]}") +assert_eq "per-session: timeline has the three event-log rows" "3" "$(printf '%s\n' "$TIMELINE" | grep -c .)" +assert_contains "per-session: timeline carries the tool and file" "$TIMELINE" "PreToolUse tool Write s.sh" + +# session: names one file; `session` is the newest by mtime. +sleep 1 +printf '%s\n' '{"ts":"2026-04-30T00:00:00Z","session_id":"s-b","hook_event_name":"Stop","category":"turn","status":"ok","source":"event-log","duration_ms":1}' >"$HOOK_ROOT/sessions/s-b.jsonl" +SCOPE="session:s-a" +assert_eq "session: resolves the named file" "$SESSION_LOG" "$HOOK_ROOT/sessions/${SCOPE#session:}.jsonl" +# shellcheck disable=SC2012 # mtime order is the documented resolution; names are hook-validated ids +NEWEST="$(ls -t "$HOOK_ROOT"/sessions/*.jsonl 2>/dev/null | head -n 1)" +assert_eq "session resolves the newest file by mtime" "$HOOK_ROOT/sessions/s-b.jsonl" "$NEWEST" + # --- Test 4: redaction — token-shaped strings --- redact() { sed -E 's|[A-Za-z0-9+/_-]{32,}={0,2}|[redacted-token]|g' @@ -287,6 +367,58 @@ assert_contains "clean wires OTEL prune (dry-run report)" "$otel_out" "cc-logs.j otel_after=$(lines_in "$otel_logs") assert_eq "clean --dry-run leaves OTEL store unchanged" "$otel_before" "$otel_after" +# --- Test 9h: the hook log root — session files, stale prune sets, the new shared file --- +NEW_ROOT="$clean_test_dir/.observability/claude" +mkdir -p "$NEW_ROOT/sessions" "$NEW_ROOT/prune-pending/1000-old" "$NEW_ROOT/prune-pending/2000-fresh" +printf '*\n' >"$NEW_ROOT/.gitignore" +printf '{"ts":"%s","event":"Test","hook":"x","duration_ms":0,"exit_code":0,"subject":"a","status":"success"}\n' "$OLD_45" >"$NEW_ROOT/hook-events.jsonl" +printf '{"ts":"%s","event":"Test","hook":"x","duration_ms":0,"exit_code":0,"subject":"a","status":"success"}\n' "$TODAY" >>"$NEW_ROOT/hook-events.jsonl" +printf '{"a":1}\n' >"$NEW_ROOT/sessions/stale.jsonl" +printf '{"a":1}\n' >"$NEW_ROOT/sessions/live.jsonl" +touch -t 202601010000 "$NEW_ROOT/sessions/stale.jsonl" "$NEW_ROOT/prune-pending/1000-old" + +root_dry=$(cd "$clean_test_dir" && env -u CC_OTEL_STORE bash "$CLEAN" --keep-days 30 --dry-run 2>&1) +assert_contains "clean --dry-run names the hook log root" "$root_dry" "hook log root $NEW_ROOT" +assert_contains "clean --dry-run counts stale session files" "$root_dry" "sessions/: 1 file(s) untouched for 30 days → would remove" +assert_contains "clean --dry-run counts stale prune sets" "$root_dry" "prune-pending/: 1 set(s) older than 24 h → would sweep" +if [[ -f "$NEW_ROOT/sessions/stale.jsonl" ]]; then + pass "clean --dry-run leaves the stale session file" +else + fail "clean --dry-run leaves the stale session file" "present" "removed" +fi + +(cd "$clean_test_dir" && env -u CC_OTEL_STORE bash "$CLEAN" --keep-days 30 --quiet) >/dev/null 2>&1 +if [[ ! -e "$NEW_ROOT/sessions/stale.jsonl" && -e "$NEW_ROOT/sessions/live.jsonl" ]]; then + pass "clean removes the stale session file and keeps the live one" +else + fail "clean removes the stale session file and keeps the live one" "stale gone, live kept" "$(ls "$NEW_ROOT/sessions")" +fi +if [[ ! -e "$NEW_ROOT/prune-pending/1000-old" && -e "$NEW_ROOT/prune-pending/2000-fresh" ]]; then + pass "clean sweeps the prune set older than 24 h and keeps the fresh one" +else + fail "clean sweeps the prune set older than 24 h and keeps the fresh one" "old swept, fresh kept" "$(ls "$NEW_ROOT/prune-pending")" +fi +assert_eq "clean prunes the root's shared file line by line" "1" "$(lines_in "$NEW_ROOT/hook-events.jsonl")" +assert_eq "clean leaves the root's guard alone" "*" "$(head -1 "$NEW_ROOT/.gitignore")" + +# --hook-root moves the root; an uncontained value is refused. +ALT_ROOT="$clean_test_dir/telemetry/hooks" +mkdir -p "$ALT_ROOT/sessions" +printf '{"a":1}\n' >"$ALT_ROOT/sessions/old.jsonl" +touch -t 202601010000 "$ALT_ROOT/sessions/old.jsonl" +(cd "$clean_test_dir" && env -u CC_OTEL_STORE bash "$CLEAN" --keep-days 30 --hook-root telemetry/hooks --quiet) >/dev/null 2>&1 +if [[ ! -e "$ALT_ROOT/sessions/old.jsonl" ]]; then + pass "clean --hook-root prunes the moved root" +else + fail "clean --hook-root prunes the moved root" "old.jsonl removed" "present" +fi +rc=0 +(cd "$clean_test_dir" && bash "$CLEAN" --hook-root ../outside --quiet) >/dev/null 2>&1 || rc=$? +assert_eq "clean refuses an uncontained --hook-root (exit 2)" "2" "$rc" +rc=0 +(cd "$clean_test_dir" && bash "$CLEAN" --hook-root . --quiet) >/dev/null 2>&1 || rc=$? +assert_eq "clean refuses a root-equivalent --hook-root (exit 2)" "2" "$rc" + # --- Test 10: failed-then-fixed sequence detection (data-sources.md query) --- retry_log="$TEST_TMPDIR/retry-events.jsonl" printf '%s\n' \ @@ -295,8 +427,7 @@ printf '%s\n' \ '{"ts":"2026-01-01T10:01:00Z","hook":"biome","exit_code":0}' \ '{"ts":"2026-01-01T10:02:00Z","hook":"bash-format","exit_code":1}' \ '{"ts":"2026-01-01T10:02:05Z","hook":"bash-format","exit_code":0}' >"$retry_log" -retry_out=$(jq -s ' - sort_by(.ts) as $e +retry_out=$(jq -s "$HOOK_NORM"' | map(select(.hook != null)) | sort_by(.ts) as $e | [range(1; $e | length) | select($e[. - 1].hook == $e[.].hook and $e[. - 1].exit_code != 0 and $e[.].exit_code == 0) | $e[. - 1].hook] diff --git a/plugins/claude-ops/skills/observability/context/data-sources.md b/plugins/claude-ops/skills/observability/context/data-sources.md index 252edbea99..fee8856f0c 100644 --- a/plugins/claude-ops/skills/observability/context/data-sources.md +++ b/plugins/claude-ops/skills/observability/context/data-sources.md @@ -1,25 +1,61 @@ # `/claude-ops:observability` data sources — JSONL + ccusage query catalog -jq pipelines and CLI invocations for **hook-events.jsonl** and **ccusage**. OTEL store +jq pipelines and CLI invocations for the **hook log root** and **ccusage**. OTEL store (DuckDB) and Aspire: [read-routing.md](read-routing.md) + [otel-queries.md](otel-queries.md). ## Setup — common variables +The hook log root is the plugin's `session_event_log_dir` option, project-relative, default +`.observability/claude`. Its rendered value is on the skill body's "Hook log root" line: use +that, never `CLAUDE_PLUGIN_DATA` and never the environment (a skill subprocess inherits no +`CLAUDE_PLUGIN_OPTION_*`). A `--hook-root REL` token on the invocation overrides it for one +run. Under the root: `sessions/.jsonl`, one file per session, holding the +per-session event log rows (`source: "event-log"`) and the sink's envelope rows for that +session (`source: "envelope"`); and the shared `hook-events.jsonl`, the legacy shape for +envelopes that carry no session id. + ```bash REPO_ROOT=$(git rev-parse --show-toplevel) -HOOK_LOG="${REPO_ROOT}/.claude/observability/hook-events.jsonl" - -# Scope → window cutoff (ISO-8601 UTC) +HOOK_ROOT_REL="${HOOK_ROOT_REL:-.observability/claude}" # the rendered option, or the flag +HOOK_ROOT="${REPO_ROOT}/${HOOK_ROOT_REL%/}" + +# Scope → window cutoff (ISO-8601 UTC) and file set. Every whole-root query reads +# every session file plus the shared file; a session scope reads one file. +shopt -s nullglob +HOOK_FILES=("$HOOK_ROOT"/sessions/*.jsonl) +shopt -u nullglob +[[ -f "$HOOK_ROOT/hook-events.jsonl" ]] && HOOK_FILES+=("$HOOK_ROOT/hook-events.jsonl") case "$SCOPE" in - session) SINCE_ISO="" ;; # filter by session_id at query time + session) # the newest session file by mtime, the one still being written + SINCE_ISO="" + HOOK_FILES=("$(ls -t "$HOOK_ROOT"/sessions/*.jsonl 2>/dev/null | head -n 1)") ;; + session:*) SINCE_ISO=""; HOOK_FILES=("$HOOK_ROOT/sessions/${SCOPE#session:}.jsonl") ;; day) SINCE_ISO=$(date -u -d "1 day ago" +%Y-%m-%dT%H:%M:%SZ) ;; week) SINCE_ISO=$(date -u -d "7 days ago" +%Y-%m-%dT%H:%M:%SZ) ;; month) SINCE_ISO=$(date -u -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ) ;; since:*) SINCE_ISO="${SCOPE#since:}T00:00:00Z" ;; all) SINCE_ISO="1970-01-01T00:00:00Z" ;; esac +[[ -f "${HOOK_FILES[0]:-}" ]] || echo "hook log empty — see the empty-store line under §2" +``` + +Never call `jq -s` with an empty file set: it would read stdin. Guard with the test above. + +Three row shapes share the root, and the queries below normalize them with one prelude so a +`hook`-keyed query sees the same fields wherever the row came from: + +```bash +# Prepend to every jq program: legacy rows carry `event`, per-session rows carry +# `hook_event_name`; only envelope-shaped rows (legacy or per-session) describe a hook. +HOOK_NORM='map(. + {event: (.event // .hook_event_name)})' ``` +| Row | Where | Keys | +|---|---|---| +| legacy envelope | `hook-events.jsonl` | `ts event hook tool duration_ms exit_code subject status` | +| per-session envelope (`source: "envelope"`) | `sessions/.jsonl` | the legacy keys with `hook_event_name` for `event`, plus `session_id`, and `changed` (boolean) when the producer sent one | +| per-session event log (`source: "event-log"`) | `sessions/.jsonl` | `ts session_id hook_event_name category status duration_ms` plus `prompt_id tool_use_id agent_id tool_name file_path reason traceparent` when present; no `hook`, and `duration_ms` is the logger's own cost, not a hook's | + Cross-platform: `date -u -d "..."` is GNU. macOS BSD date uses `date -u -v-7d`. Skill detects platform — see fallback in implementation. ## 1. ccusage — token + cost @@ -65,8 +101,7 @@ Empty / missing: emit `"ccusage not installed — npm install -g ccusage or wire **p50 / p95 / p99 / max per `(hook, event)`:** ```bash -jq -s --arg since "$SINCE_ISO" ' - map(select(.ts >= $since)) +jq -s --arg since "$SINCE_ISO" "$HOOK_NORM"' | map(select(.ts >= $since and .hook != null)) | group_by(.hook + "|" + .event) | map({ key: (.[0].hook + " " + .[0].event), @@ -78,7 +113,7 @@ jq -s --arg since "$SINCE_ISO" ' err_count: (map(select(.exit_code != 0)) | length) }) | sort_by(-.p95) -' "$HOOK_LOG" +' "${HOOK_FILES[@]}" ``` **Flag rules:** @@ -90,8 +125,7 @@ jq -s --arg since "$SINCE_ISO" ' **Error rate per hook:** ```bash -jq -s --arg since "$SINCE_ISO" ' - map(select(.ts >= $since)) +jq -s --arg since "$SINCE_ISO" "$HOOK_NORM"' | map(select(.ts >= $since and .hook != null)) | group_by(.hook) | map({ hook: .[0].hook, @@ -101,10 +135,91 @@ jq -s --arg since "$SINCE_ISO" ' }) | sort_by(-.err_pct) | map(select(.err_pct > 0)) -' "$HOOK_LOG" +' "${HOOK_FILES[@]}" +``` + +Empty: `"hook log empty — wire HOOK_TELEMETRY_SINK to your sink script, or turn on session_event_log_enabled, and re-run after hooks fire"`. + +## 2.5 Per-session report (`session` and `session:` scopes) + +One file, one session. `session` is the newest `sessions/*.jsonl` by mtime (the file the running +session is still appending to); `session:` names one. Rows from the shared +`hook-events.jsonl` carry no session id: they are never joined to a session, and a report that +mentions them says so ("legacy rows, shared file, time proximity only"). Each block below is +one jq over `"${HOOK_FILES[0]}"`. + +**Hooks fired, grouped by hook (envelope rows only):** + +```bash +jq -s "$HOOK_NORM"' | map(select(.source == "envelope")) + | group_by(.hook) + | map({hook: .[0].hook, n: length, + events: (map(.event) | unique), + errors: (map(select(.exit_code != 0)) | length), + p50_ms: (sort_by(.duration_ms) | .[length/2|floor].duration_ms), + max_ms: (max_by(.duration_ms).duration_ms)}) + | sort_by(-.n) +' "${HOOK_FILES[0]}" +``` + +**Blocked:** what a guard refused, in order. + +```bash +jq -sc "$HOOK_NORM"' | .[] | select(.status == "blocked") + | {ts, hook, event, subject}' "${HOOK_FILES[0]}" +``` + +**Rewrote:** what a formatter changed. `changed` is a defined key no producer emits yet +(formatters send `data.findings` only), so this list is empty until one does; render it as +`_no data — no producer reports rewrites yet_`, not as "nothing was rewritten". + +```bash +jq -sc "$HOOK_NORM"' | .[] | select(.changed == true) + | {ts, hook, subject}' "${HOOK_FILES[0]}" +``` + +**Duration per hook** is the `p50_ms` / `max_ms` pair in the first block; the whole-session +hook cost is `map(select(.source == "envelope") | .duration_ms) | add`. Per-hook duration is +available only for producers that emit `data.session_id` (the nine claude-ops audit hooks +today); a hook that does not still appears in the whole-root §2 tables through the shared file. + +**Event timeline** (the per-session event log, opt-in): every hook event the session saw, in +order, with the correlation keys that were present. + +```bash +jq -sr '.[] | select(.source == "event-log") + | [.ts, .hook_event_name, .category, (.tool_name // ""), (.file_path // ""), (.agent_id // "")] + | @tsv' "${HOOK_FILES[0]}" +``` + +Every block above slurps (`-s`): the prelude's `map` and the `.[]` walk need one array, and a +JSONL file read without `-s` hands jq one object at a time. + +Group by `agent_id` to separate subagent fires from the main thread; group by `prompt_id` for +per-turn counts; `tool_use_id` joins a `PreToolUse` row to its `PostToolUse` (and to the OTEL +`tool_result` event). Empty when `session_event_log_enabled` is off: say so, and point at +`/claude-ops:setup` rather than at the shared file. + +## 2.6 Toggles and retention in effect + +Render the six options, the guard, and the prune state from one probe call, so the report +shows what the pipeline is doing rather than what the reader assumes. The values are the +rendered `${user_config.*}` from the skill body, passed as flags; an unrendered placeholder +reads as the manifest default. + +```bash +bash "${CLAUDE_PLUGIN_ROOT}/skills/observability/scripts/probe-observability-state.sh" --pipeline \ + --root "$HOOK_ROOT_REL" --enabled "" \ + --categories "" --keep-sessions "" \ + --keep-days "" --pre-prune-command "" ``` -Empty: `"hook log empty — wire HOOK_TELEMETRY_SINK to your sink script and re-run after hooks fire"`. +Six fixed lines: `root:`, `guard:`, `sessions:`, `shared:`, `prune-pending:`, `logging:`. Copy +them into the report verbatim under "Toggles and retention in effect". A `WARN` on the +`prune-pending:` line (a moved-aside set older than 24 h) is a MEDIUM finding: the configured +pre-prune command is not finishing, and `/claude-ops:observability clean` sweeps the set. A +`guard: operator-edited` line is a HIGH finding: the hooks are refusing to write. The probe +never heals the guard; `/claude-ops:setup apply` does. ## 3. Tool call decisions — which calls were denied, and why @@ -138,11 +253,10 @@ DuckDB queries: [otel-queries.md](otel-queries.md) § "Tool decisions". n-gram over `(event, hook)` sequences in hook event log. Flag any 3-gram appearing 5+ times in window. ```bash -jq -sr --arg since "$SINCE_ISO" ' - map(select(.ts >= $since)) +jq -sr --arg since "$SINCE_ISO" "$HOOK_NORM"' | map(select(.ts >= $since and .hook != null)) | sort_by(.ts) | map(.event + ":" + .hook) -' "$HOOK_LOG" \ +' "${HOOK_FILES[@]}" \ | python3 -c ' import sys, json, collections seq = json.load(sys.stdin) @@ -156,14 +270,13 @@ for k, v in ngrams.most_common(10): **Failed-then-fixed sequences:** detect adjacent `exit_code != 0` followed by same-hook `exit_code == 0` — implies user/agent re-edited and same hook fired green. ```bash -jq -s ' - sort_by(.ts) as $e +jq -s "$HOOK_NORM"' | map(select(.hook != null)) | sort_by(.ts) as $e | [range(1; $e | length) | select($e[. - 1].hook == $e[.].hook and $e[. - 1].exit_code != 0 and $e[.].exit_code == 0) | $e[. - 1].hook] | group_by(.) | map({hook: .[0], retries: length}) | sort_by(-.retries) -' "$HOOK_LOG" +' "${HOOK_FILES[@]}" ``` Pattern detection across session JSONL transcripts (`~/.claude/projects//*.jsonl`) is deferred — schema undocumented. @@ -175,8 +288,8 @@ Pattern detection across session JSONL transcripts (`~/.claude/projects//* **Per-period count + per-binary breakdown:** ```bash -jq -s --arg since "$SINCE_ISO" ' - map(select(.event == "PostToolUse" and .hook == "cli-flag-verify" and .ts >= $since)) +jq -s --arg since "$SINCE_ISO" "$HOOK_NORM"' + | map(select(.event == "PostToolUse" and .hook == "cli-flag-verify" and .ts >= $since)) | { total: length, unique_pairs: (map(.subject) | unique | length), by_binary: (group_by(.subject | split(":")[0]) @@ -184,17 +297,17 @@ jq -s --arg since "$SINCE_ISO" ' count: length, unique: (map(.subject) | unique | length) }) | sort_by(-.count)) } -' "$HOOK_LOG" +' "${HOOK_FILES[@]}" ``` **Top recurring hallucinations** (same `:` repeating = same flag re-hallucinated): ```bash -jq -s --arg since "$SINCE_ISO" ' - map(select(.event == "PostToolUse" and .hook == "cli-flag-verify" and .ts >= $since) | .subject) +jq -s --arg since "$SINCE_ISO" "$HOOK_NORM"' + | map(select(.event == "PostToolUse" and .hook == "cli-flag-verify" and .ts >= $since) | .subject) | group_by(.) | map({ subject: .[0], count: length }) | sort_by(-.count) | .[0:10] -' "$HOOK_LOG" +' "${HOOK_FILES[@]}" ``` **Flag rules:** @@ -252,6 +365,7 @@ All queries run on file sizes ≤ 50MB without issue. Skill caps total runtime ~ ## Cross-references -- `hook-events.jsonl` schema: whatever the consumer's hook emitter writes — treat the fields used here (`ts`, `hook`, `tool`, `duration_ms`, `exit_code`, `subject`, `status`) as the expected shape and degrade gracefully when fields are absent +- Row schemas: the three shapes in "Setup" above. The shared file is whatever the consumer's hook emitter writes — treat the fields used here (`ts`, `hook`, `tool`, `duration_ms`, `exit_code`, `subject`, `status`) as the expected shape and degrade gracefully when fields are absent; the per-session shapes are the reference sink's and `session-event-log.sh`'s (see `hooks/hook-events.registry.json` for which events the event log records) +- The old `.claude/observability/hook-events.jsonl` location is retired (`retirements.yaml` `claude-ops-r001`); `/claude-ops:setup` detects and migrates it. The skill-usage store and the OTEL store still live under `.claude/observability/` - Privacy filter applied at output time: [privacy.md](privacy.md) - Output template: [output-format.md](output-format.md) diff --git a/plugins/claude-ops/skills/observability/context/output-format.md b/plugins/claude-ops/skills/observability/context/output-format.md index e17bca5cc4..cda07cf921 100644 --- a/plugins/claude-ops/skills/observability/context/output-format.md +++ b/plugins/claude-ops/skills/observability/context/output-format.md @@ -104,8 +104,67 @@ Top recurring (same `:` ≥ 3×): ## Activity context - Commits: · PRs opened: · merged: + +## Toggles and retention in effect + +root: .observability/claude (default) +guard: ok +sessions: 12 file(s), newest +shared: 340 event(s) in hook-events.jsonl +prune-pending: none +logging: on; categories: all; keep: 30 sessions or 14 days; pre-prune: none ``` +The last section is the six lines of `probe-observability-state.sh --pipeline`, verbatim. A +`WARN` on the `prune-pending:` line is a MEDIUM finding; `guard: operator-edited` is HIGH. + +## Per-session skeleton (`session` and `session:` scopes) + +```markdown +# Claude observability — session + +Generated: +Repo: · Branch: · File: sessions/.jsonl ( rows, ) + +## Summary + +- +- + +## Hooks fired + +| Hook | Events | n | err | p50 ms | max ms | +|---|---|---:|---:|---:|---:| +| permission-denied-audit | PermissionDenied | 3 | 0 | 4 | 6 | + +Whole-session hook cost: ms across envelope rows. Per-hook rows cover producers that +emit `data.session_id`; other hooks appear only in the whole-root report. + +## Blocked + +- `` `` `` — `` +- (or) `_nothing blocked_` + +## Rewrote + +- (or) `_no data — no producer reports rewrites yet_` + +## Event timeline + +| ts | event | category | tool | file | agent | +|---|---|---|---|---|---| + +Per turn (`prompt_id`): turns, events in the busiest. Subagents (`agent_id`): . +(or) `_no data — session_event_log_enabled is off; /claude-ops:setup to turn it on_` + +## Toggles and retention in effect + + +``` + +Legacy rows from the shared `hook-events.jsonl` are never joined to a session; when the reader +mentions them the line reads "legacy rows, shared file, time proximity only". + ## Rendering rules - **Tables for ≥3 rows**, otherwise inline list @@ -143,5 +202,5 @@ Reports are working artifacts — copy one into the consumer project only if it ## What this template intentionally omits - Recommendations / action items — `/claude-ops:observability` surfaces signals, user decides what to act on -- Per-session detail — aggregation hides per-session noise; use ccusage MCP directly for session drill-down +- Per-session token and cost drill-down — the per-session skeleton covers hooks and events; use ccusage MCP directly for a session's tokens - Cross-repo data — out of scope; observability is project-local diff --git a/plugins/claude-ops/skills/observability/context/privacy.md b/plugins/claude-ops/skills/observability/context/privacy.md index be22001f8e..c370775445 100644 --- a/plugins/claude-ops/skills/observability/context/privacy.md +++ b/plugins/claude-ops/skills/observability/context/privacy.md @@ -55,7 +55,7 @@ Apply just before final stdout / file write — never to the raw JSONL input. ## Trust boundary -Source files (`hook-events.jsonl`) are gitignored. They never leave the repo unless the user explicitly shares the `/claude-ops:observability` report or copies the JSONL elsewhere. The privacy filter assumes the report MAY be shared (e.g., pasted into chat, attached to issue) and prevents the worst leaks. +Source files (the hook log root's `sessions/*.jsonl` and `hook-events.jsonl`) sit in a tree that carries its own self-ignoring `.gitignore`. They never leave the repo unless the user explicitly shares the `/claude-ops:observability` report or copies the JSONL elsewhere. A per-session file also carries `file_path` values (repo-relative, or the basename when the file is outside the repo) and, for a `reason`-bearing event, the reason text the hook was given; both pass through the same filter below before any report. The privacy filter assumes the report MAY be shared (e.g., pasted into chat, attached to issue) and prevents the worst leaks. Does NOT defend against: diff --git a/plugins/claude-ops/skills/observability/context/read-routing.md b/plugins/claude-ops/skills/observability/context/read-routing.md index a2a59571f9..0f4babb0f0 100644 --- a/plugins/claude-ops/skills/observability/context/read-routing.md +++ b/plugins/claude-ops/skills/observability/context/read-routing.md @@ -14,21 +14,28 @@ Batch reports and JSONL jq: this skill's scope actions. Product bugs: `/claude-o |---|---|---| | **OTEL → DuckDB store** | CC CLI logs, metrics, traces (spans) | Yes — hot NDJSON + cold Parquet | | **OTEL → Aspire dashboard** | CC logs, metrics, traces (live in-memory) | **No** — restart drops history | -| **JSONL observability** | Hook timing | Yes — `.claude/observability/*.jsonl` | +| **JSONL observability** | Hook timing, per-session hook event log | Yes — the hook log root (`.observability/claude/` by default): `sessions/.jsonl` and the shared `hook-events.jsonl` | ```text CC CLI ── OTLP :4318 ──▶ Collector ──┬── file ──▶ DuckDB (cc_logs, cc_metrics, cc_spans) ← SSOT └── gRPC ──▶ Aspire :18888 (all 3 signals, optional UI) -Hooks ──▶ hook-events.jsonl +Hooks ──▶ envelope ──▶ sink ──┬── data.session_id ──▶ /sessions/.jsonl (source: envelope) + └── no session id ──▶ /hook-events.jsonl (legacy shape) +Every event ──▶ session-event-log (opt-in) ──▶ /sessions/.jsonl (source: event-log) ``` +The root is the plugin's `session_event_log_dir` option (project-relative, self-ignoring +`.gitignore` inside). The skill-usage store and the OTEL store stay under `.claude/observability/`. + ## Quick routing — "I need to know X" | Question | Best path | Detail | |---|---|---| | Token/cost totals, per-model split, billing blocks | ccusage MCP or CLI | [data-sources.md](data-sources.md) §1 | -| Hook p95 latency, hook errors, recurring hook sequences | `hook-events.jsonl` | [data-sources.md](data-sources.md) §2 | +| Hook p95 latency, hook errors, recurring hook sequences | the hook log root (`sessions/*.jsonl` + `hook-events.jsonl`) | [data-sources.md](data-sources.md) §2 | +| What one session did: hooks fired, blocked, rewrote, per-hook duration, the event timeline | `sessions/.jsonl` (`session` / `session:` scope) | [data-sources.md](data-sources.md) §2.5 | +| Which hook-logging toggles and retention are in effect, guard state, stale prune sets | `probe-observability-state.sh --pipeline` | [data-sources.md](data-sources.md) §2.6 | | Why most installed skills never get used — starved by the listing budget, unreachable, or simply unobserved | `/claude-ops:audit-skill-visibility` | That skill owns interpretation of skill-usage data; this skill owns the store, the OTEL pipeline, and retention | | Tool latency, API errors (historical) | DuckDB `cc_logs` | [otel-queries.md](otel-queries.md) | | Token/cost metrics (historical) | DuckDB `cc_metrics` | [otel-queries.md](otel-queries.md) | diff --git a/plugins/claude-ops/skills/observability/scripts/clean.sh b/plugins/claude-ops/skills/observability/scripts/clean.sh index 588f3329ff..749af31ce9 100755 --- a/plugins/claude-ops/skills/observability/scripts/clean.sh +++ b/plugins/claude-ops/skills/observability/scripts/clean.sh @@ -1,8 +1,15 @@ #!/usr/bin/env bash # /observability clean — prune local observability data by age. -# Three layers, each with its own retention: -# 1. JSONL metadata (.claude/observability/hook-events.jsonl) — -# path-only, pruned in place to the --keep-days window (default 30). +# Four layers, each with its own retention: +# 1. JSONL metadata — the hook log root's hook-events.jsonl (the reference +# sink's shared file, default root .observability/claude, --hook-root moves +# it) and, while a consumer still carries it, the retired +# .claude/observability/hook-events.jsonl — path-only, pruned in place to +# the --keep-days window (default 30). The root's per-session files +# (sessions/.jsonl) are removed whole once older than the same window, +# and any prune-pending/ the SessionEnd retention hook moved aside for +# an archiver more than 24 h ago is swept regardless of the plugin's +# logging switch, so disabling the hooks leaves no orphan. # 2. OTEL file store (.claude/observability/otel/{cc-logs,cc-metrics}.json) — holds full # prompt + raw API bodies, so TIGHTER windows: CC_OTEL_RETENTION_DAYS (default 7) for # structure events, CC_OTEL_BODY_RETENTION_DAYS (default 2) for api_*_body records. @@ -16,7 +23,7 @@ # unrelated plugin's data directory — so `data-dir` demands an explicit --skill-usage-dir. # # Usage: -# bash clean.sh [--keep-days N] [--dry-run] [--quiet] +# bash clean.sh [--keep-days N] [--dry-run] [--quiet] [--hook-root REL] # [--skill-usage-scope repo|user|data-dir] [--skill-usage-dir REL] # [--keep-skill-usage-days N] # @@ -24,6 +31,9 @@ # --keep-days N JSONL retention window in days (default: 30). The OTEL store uses its own # CC_OTEL_RETENTION_DAYS / CC_OTEL_BODY_RETENTION_DAYS env windows # (defaults 7 / 2), NOT this flag. +# --hook-root REL The hook log root, project-relative (the plugin's session_event_log_dir +# option; default .observability/claude). A FLAG, never the environment, for +# the same reason as the skill-usage flags below. # --dry-run Report would-prune counts for BOTH layers; modify nothing # --quiet Suppress progress output (still prints final summary) # @@ -43,6 +53,9 @@ set -uo pipefail KEEP_DAYS=30 DRY_RUN=0 QUIET=0 +# The hook log root: sessions/.jsonl, the sink's shared hook-events.jsonl +# and prune-pending/ live here. Same default as the hooks' session-log-lib.sh. +HOOK_ROOT_REL=".observability/claude" # Skill-usage pruning is INERT unless --skill-usage-scope is passed. That is the # rollback story: with the flag absent this script's behavior is byte-for-byte # what it was, so reverting the feature is dropping one branch. @@ -66,6 +79,15 @@ while [[ $# -gt 0 ]]; do KEEP_DAYS="${1#*=}" shift ;; + --hook-root) + shift + HOOK_ROOT_REL="${1:-}" + shift + ;; + --hook-root=*) + HOOK_ROOT_REL="${1#*=}" + shift + ;; --skill-usage-scope) shift SKILL_USAGE_SCOPE="${1:-}" @@ -116,7 +138,7 @@ while [[ $# -gt 0 ]]; do ;; *) echo "ERROR: unknown flag: $1" >&2 - echo "Usage: $0 [--keep-days N] [--dry-run] [--quiet] [--skill-usage-scope repo|user|data-dir] [--skill-usage-dir REL] [--keep-skill-usage-days N]" >&2 + echo "Usage: $0 [--keep-days N] [--dry-run] [--quiet] [--hook-root REL] [--skill-usage-scope repo|user|data-dir] [--skill-usage-dir REL] [--keep-skill-usage-days N]" >&2 exit 2 ;; esac @@ -130,6 +152,17 @@ case "$KEEP_DAYS" in *) ;; esac +# A contained relative path is the only accepted root shape: the hooks refuse +# anything else, so a prune there would touch a tree nothing writes to. +case "$HOOK_ROOT_REL" in +"" | . | ./ | /* | [A-Za-z]:* | ~* | *..* | *\\*) + echo "ERROR: --hook-root must be a contained relative path below the project root (got: $HOOK_ROOT_REL)" >&2 + exit 2 + ;; +*) ;; +esac +HOOK_ROOT_REL="${HOOK_ROOT_REL%/}" + REPO_ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null | tr -d '\r')}" if [[ -z "$REPO_ROOT" ]]; then echo "ERROR: not in a git repo (and CLAUDE_PROJECT_DIR is unset)" >&2 @@ -137,7 +170,10 @@ if [[ -z "$REPO_ROOT" ]]; then fi OBS_DIR="${REPO_ROOT}/.claude/observability" +# The retired shared-file location (claude-ops-r001): pruned while a consumer +# still carries it, so the old rows keep aging out until setup migrates them. HOOK_LOG="${OBS_DIR}/hook-events.jsonl" +HOOK_ROOT="${REPO_ROOT}/${HOOK_ROOT_REL}" # Cutoff timestamp for N days ago — ISO-8601 UTC. GNU date (Linux/Git Bash) and # BSD date (macOS) require different flags for relative time and epoch-to-ISO @@ -255,6 +291,48 @@ SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" prune_file "$HOOK_LOG" "ts" +# --- the hook log root: shared file, per-session files, moved-aside prune sets --- +# The shared file is pruned line by line like the retired one. Session files are +# one session each and carry no `ts` worth scanning for a whole-file decision: +# a file untouched for the whole window is removed whole (the SessionEnd +# retention hook keeps the newest N regardless of age; `clean` is the +# operator's explicit ask and applies the window alone). prune-pending/ +# directories are what that hook moved aside for a detached archiver; it deletes +# them itself after 24 h, but only while the plugin's logging switch is on, so +# `clean` sweeps the stale ones whether or not the hooks still run. +log "clean: hook log root $HOOK_ROOT" +prune_file "$HOOK_ROOT/hook-events.jsonl" "ts" +if [[ -d "$HOOK_ROOT/sessions" ]]; then + OLD_SESSIONS=() + while IFS= read -r f; do OLD_SESSIONS+=("$f"); done < <( + find "$HOOK_ROOT/sessions" -mindepth 1 -maxdepth 1 -type f -name '*.jsonl' -mmin +"$((KEEP_DAYS * 1440))" 2>/dev/null + ) + if [[ "$DRY_RUN" -eq 1 ]]; then + printf ' sessions/: %d file(s) untouched for %d days → would remove\n' "${#OLD_SESSIONS[@]}" "$KEEP_DAYS" + elif ((${#OLD_SESSIONS[@]} == 0)); then + log " sessions/: no file untouched for $KEEP_DAYS days — no change" + else + rm -f -- "${OLD_SESSIONS[@]}" + printf ' sessions/: removed %d file(s) untouched for %d days\n' "${#OLD_SESSIONS[@]}" "$KEEP_DAYS" + fi +else + log " sessions/: missing — skip" +fi +if [[ -d "$HOOK_ROOT/prune-pending" ]]; then + STALE_PENDING=() + while IFS= read -r d; do STALE_PENDING+=("$d"); done < <( + find "$HOOK_ROOT/prune-pending" -mindepth 1 -maxdepth 1 -type d -mmin +1440 2>/dev/null + ) + if [[ "$DRY_RUN" -eq 1 ]]; then + printf ' prune-pending/: %d set(s) older than 24 h → would sweep\n' "${#STALE_PENDING[@]}" + elif ((${#STALE_PENDING[@]} == 0)); then + log " prune-pending/: nothing older than 24 h — no change" + else + rm -rf -- "${STALE_PENDING[@]}" + printf ' prune-pending/: swept %d set(s) older than 24 h\n' "${#STALE_PENDING[@]}" + fi +fi + # --- skill-usage.jsonl (opt-in, its own window, its own resolved location) --- # Inert unless --skill-usage-scope is passed, so the default run is unchanged. # diff --git a/plugins/claude-ops/skills/observability/scripts/probe-observability-state.sh b/plugins/claude-ops/skills/observability/scripts/probe-observability-state.sh index 06ce0cae4f..f2ba09f7f9 100755 --- a/plugins/claude-ops/skills/observability/scripts/probe-observability-state.sh +++ b/plugins/claude-ops/skills/observability/scripts/probe-observability-state.sh @@ -1,36 +1,59 @@ #!/usr/bin/env bash # probe-observability-state.sh — report local telemetry-store state for the -# observability skill's `## Pre-computed context` block. +# observability skill's `## Pre-computed context` block and its "Toggles and +# retention in effect" section. # -# Two of that block's lines are real shell work — a repo-root-or-working-directory +# The block's lines are real shell work — a repo-root-or-working-directory # default, an env override, a per-file size loop — and a pre-compute line carrying # genuine shell expansion (`$f`, `$d`, `${VAR:-default}`, `$(…)`) is refused by the # worktree-isolation guard, so the whole skill fails to load when invoked from an -# isolated agent (#1687). Both lines therefore call this script through +# isolated agent (#1687). The lines therefore call this script through # `${CLAUDE_PLUGIN_ROOT}`, which the harness substitutes into a literal path before # any shell sees it. # -# One script, two modes rather than two scripts: both lines read the same local -# observability tree, so they are two facets of one probe surface, and a caller +# One script, three modes rather than three scripts: every mode reads the same +# local observability tree, so they are facets of one probe surface, and a caller # picking a mode is the only difference between them. # # Usage: -# probe-observability-state.sh --hook-events +# probe-observability-state.sh --hook-events [--root ] # probe-observability-state.sh --otel-store +# probe-observability-state.sh --pipeline [--root ] [--enabled ] +# [--categories ] [--keep-sessions ] [--keep-days ] +# [--pre-prune-command ] # -# --hook-events output (stdout, exactly one line) over -# /.claude/observability/hook-events.jsonl: +# --root is the hook log root, project-relative: the plugin's +# session_event_log_dir option. Absent, empty, or still an unexpanded +# `${user_config...}` placeholder → `.observability/claude`. The skill passes the +# rendered option through this flag because a skill subprocess inherits no +# CLAUDE_PLUGIN_OPTION_* (the hooks read theirs from the session environment). +# The same holds for every --pipeline value: an unexpanded placeholder reads as +# the option's manifest default. +# +# --hook-events output (stdout, exactly one line) over the root's +# sessions/*.jsonl files plus its hook-events.jsonl: # events # EMPTY (no hook-event emitter wired, or no hooks fired yet) +# INVALID root (): the hooks write nothing # # --otel-store output (stdout, three lines — one per store file, in order # cc-logs.json, cc-metrics.json, cc-traces.json): # :B # :absent # +# --pipeline output (stdout, six lines, fixed order and labels; read-only, it +# never heals the guard): +# root: (default|configured) | root: INVALID (uncontained; the hooks write nothing) +# guard: ok | absent (the first write heals it) | operator-edited (writes refused) +# | not needed (not a git checkout) | n/a (root invalid) +# sessions: file(s), newest | none +# shared: event(s) in hook-events.jsonl | absent +# prune-pending: none | dir(s), older than 24 h[ WARN: an archiver is not finishing] +# logging: on|off; categories: all|; keep: sessions or days; pre-prune: none|set +# # Store resolution: # --hook-events /.claude/observability/hook-events.jsonl — no env override. +# repo>/ — no env override. # --otel-store CC_OTEL_STORE when set and non-empty, used verbatim; otherwise # /.claude/observability/otel. # @@ -45,14 +68,30 @@ set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# One containment rule for the log root, shared with the hooks that write it. +# shellcheck source=../../../hooks/session-log-lib.sh +. "$SCRIPT_DIR/../../../hooks/session-log-lib.sh" + err() { printf 'ERROR: %s\n' "$*" >&2; } usage() { awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "${BASH_SOURCE[0]}"; } +# An option value the skill passed through unrendered (`${user_config.x}`) or +# empty means "unset": the manifest default applies. +# shellcheck disable=SC2016 # the literal placeholder text is the thing matched +unset_value() { [[ -z "$1" || "$1" == '${user_config.'* ]]; } + MODE="" +ROOT_ARG="" +ENABLED_ARG="" +CATEGORIES_ARG="" +KEEP_SESSIONS_ARG="" +KEEP_DAYS_ARG="" +PRE_PRUNE_ARG="" while (($#)); do case "$1" in - --hook-events | --otel-store) + --hook-events | --otel-store | --pipeline) if [[ -n "$MODE" ]]; then err "modes are mutually exclusive: $MODE and $1" exit 3 @@ -60,6 +99,22 @@ while (($#)); do MODE="$1" shift ;; + --root | --enabled | --categories | --keep-sessions | --keep-days | --pre-prune-command) + if (($# < 2)); then + err "$1 needs a value" + exit 3 + fi + case "$1" in + --root) ROOT_ARG="$2" ;; + --enabled) ENABLED_ARG="$2" ;; + --categories) CATEGORIES_ARG="$2" ;; + --keep-sessions) KEEP_SESSIONS_ARG="$2" ;; + --keep-days) KEEP_DAYS_ARG="$2" ;; + --pre-prune-command) PRE_PRUNE_ARG="$2" ;; + *) ;; + esac + shift 2 + ;; -h | --help) usage exit 0 @@ -72,7 +127,7 @@ while (($#)); do done if [[ -z "$MODE" ]]; then - err "a mode is required: --hook-events or --otel-store" + err "a mode is required: --hook-events, --otel-store or --pipeline" exit 3 fi @@ -86,11 +141,38 @@ repo_root() { fi } +# The configured root, its origin, and whether the hooks would accept it. +ROOT_REL="$SLOG_DEFAULT_ROOT" +ROOT_ORIGIN="default" +ROOT_VALID=1 +if ! unset_value "$ROOT_ARG"; then + ROOT_REL="${ROOT_ARG%/}" + ROOT_ORIGIN="configured" + slog_contained "$ROOT_REL" || ROOT_VALID=0 +fi + +# The files the hook log holds: sessions/*.jsonl plus the shared file. +# Populated by hook_files into HOOK_FILES. +HOOK_FILES=() +hook_files() { + local f + HOOK_FILES=() + shopt -s nullglob + for f in "$1"/sessions/*.jsonl; do HOOK_FILES+=("$f"); done + shopt -u nullglob + [[ -f "$1/hook-events.jsonl" ]] && HOOK_FILES+=("$1/hook-events.jsonl") + return 0 +} + case "$MODE" in --hook-events) - HOOK_LOG="$(repo_root)/.claude/observability/hook-events.jsonl" - if [[ -f "$HOOK_LOG" ]]; then - printf '%s events\n' "$(wc -l <"$HOOK_LOG")" + if ((!ROOT_VALID)); then + printf 'INVALID root (%s): the hooks write nothing\n' "$ROOT_ARG" + exit 0 + fi + hook_files "$(repo_root)/$ROOT_REL" + if ((${#HOOK_FILES[@]})); then + printf '%s events\n' "$(cat "${HOOK_FILES[@]}" | wc -l)" else printf 'EMPTY (no hook-event emitter wired, or no hooks fired yet)\n' fi @@ -105,6 +187,96 @@ case "$MODE" in fi done ;; +--pipeline) + PROJECT="$(repo_root)" + ABS_ROOT="$PROJECT/$ROOT_REL" + if ((ROOT_VALID)); then + printf 'root: %s (%s)\n' "$ROOT_REL" "$ROOT_ORIGIN" + else + printf 'root: %s INVALID (uncontained; the hooks write nothing)\n' "$ROOT_ARG" + fi + + # Guard state, read the way the hooks read it (first non-blank, non-comment + # line is `*`) and never written: healing is the hooks' and setup apply's job. + if ((!ROOT_VALID)); then + printf 'guard: n/a (root invalid)\n' + elif ! slog_in_checkout "$PROJECT"; then + printf 'guard: not needed (not a git checkout)\n' + elif [[ ! -f "$ABS_ROOT/.gitignore" ]]; then + printf 'guard: absent (the first write heals it)\n' + else + guard_state="absent (the first write heals it)" + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + case "$line" in + '' | '#'*) continue ;; + '*') guard_state="ok" ;; + *) guard_state="operator-edited (writes refused)" ;; + esac + break + done <"$ABS_ROOT/.gitignore" + printf 'guard: %s\n' "$guard_state" + fi + + # Session files, newest by mtime; the shared file's line count. + session_count=0 + newest="" + if ((ROOT_VALID)) && [[ -d "$ABS_ROOT/sessions" ]]; then + shopt -s nullglob + session_files=("$ABS_ROOT"/sessions/*.jsonl) + shopt -u nullglob + session_count=${#session_files[@]} + if ((session_count)); then + # shellcheck disable=SC2012 # mtime order is the point; names are hook-validated ids + newest="$(ls -t "$ABS_ROOT"/sessions/*.jsonl 2>/dev/null | head -n 1)" + newest="${newest##*/}" + newest="${newest%.jsonl}" + fi + fi + if ((session_count)); then + printf 'sessions: %s file(s), newest %s\n' "$session_count" "$newest" + else + printf 'sessions: none\n' + fi + if ((ROOT_VALID)) && [[ -f "$ABS_ROOT/hook-events.jsonl" ]]; then + printf 'shared: %s event(s) in hook-events.jsonl\n' "$(wc -l <"$ABS_ROOT/hook-events.jsonl" | tr -d ' ')" + else + printf 'shared: absent\n' + fi + + # Moved-aside prune sets waiting for an archiver; retention deletes them after + # 24 h, so an older one means the detached command never finished. + pending_total=0 + pending_old=0 + if ((ROOT_VALID)) && [[ -d "$ABS_ROOT/prune-pending" ]]; then + shopt -s nullglob + pending_dirs=("$ABS_ROOT"/prune-pending/*/) + shopt -u nullglob + pending_total=${#pending_dirs[@]} + pending_old="$(find "$ABS_ROOT/prune-pending" -mindepth 1 -maxdepth 1 -type d -mmin +1440 2>/dev/null | wc -l | tr -d ' ')" + fi + if ((pending_total == 0)); then + printf 'prune-pending: none\n' + elif ((pending_old > 0)); then + printf 'prune-pending: %s dir(s), %s older than 24 h WARN: an archiver is not finishing\n' "$pending_total" "$pending_old" + else + printf 'prune-pending: %s dir(s), %s older than 24 h\n' "$pending_total" "$pending_old" + fi + + # The six options as rendered, defaults applied where unset. + logging="off" + ! unset_value "$ENABLED_ARG" && [[ "$ENABLED_ARG" == "true" ]] && logging="on" + categories="all" + unset_value "$CATEGORIES_ARG" || categories="$CATEGORIES_ARG" + keep_sessions="30" + unset_value "$KEEP_SESSIONS_ARG" || keep_sessions="$KEEP_SESSIONS_ARG" + keep_days="14" + unset_value "$KEEP_DAYS_ARG" || keep_days="$KEEP_DAYS_ARG" + pre_prune="none" + unset_value "$PRE_PRUNE_ARG" || pre_prune="set (runs detached at SessionEnd)" + printf 'logging: %s; categories: %s; keep: %s sessions or %s days; pre-prune: %s\n' \ + "$logging" "$categories" "$keep_sessions" "$keep_days" "$pre_prune" + ;; *) err "unhandled mode: $MODE" exit 3 diff --git a/plugins/claude-ops/skills/observability/scripts/probe-observability-state.test.sh b/plugins/claude-ops/skills/observability/scripts/probe-observability-state.test.sh index 40f67ab577..67c80b6ceb 100755 --- a/plugins/claude-ops/skills/observability/scripts/probe-observability-state.test.sh +++ b/plugins/claude-ops/skills/observability/scripts/probe-observability-state.test.sh @@ -1,23 +1,33 @@ #!/usr/bin/env bash # Regression tests for probe-observability-state.sh. # -# The script exists only to reproduce, from a bundled file, what two pre-compute -# lines used to compute inline (#1687). So every case asserts TWO things: the +# The script exists to reproduce, from a bundled file, what pre-compute lines +# used to compute inline (#1687). The OTEL cases therefore assert TWO things: the # script's output against the expected literal, and the script's output against # the ORIGINAL inline one-liner run in the same environment. The second assertion # is the byte-for-byte equivalence claim, checked rather than reasoned about. +# The hook-events line has no inline original any more: it reads a whole root +# (sessions/*.jsonl plus the shared file) that the pre-compute line never did, +# so its cases assert the literal alone. # # Coverage: # --hook-events -# - present log → ` events`; absent log → the EMPTY sentence verbatim +# - present files → ` events` summed across sessions/*.jsonl and the +# shared hook-events.jsonl; nothing → the EMPTY sentence verbatim # - path resolves under the git toplevel, and under the working directory # when not inside a repo +# - --root moves the root; an unexpanded `${user_config...}` placeholder and +# an empty value read as the default; an uncontained root is INVALID # - no env override (the line it replaces had none), so CC_OTEL_STORE must # not steer it # --otel-store # - CC_OTEL_STORE used verbatim when set; an EMPTY value falls through # - one line per store file, in fixed order, `:B` / `:absent` # - mixed present/absent across the three files +# --pipeline +# - six fixed lines; guard ok / absent / operator-edited / not a checkout; +# newest session by mtime; shared count; prune-pending age WARN; option +# defaults for unexpanded placeholders; the probe never writes the guard # - a CRLF-terminated git toplevel does not leak a stray CR into the path # - mode validation: missing, unknown, and conflicting arguments all exit 3 # @@ -48,13 +58,9 @@ fail() { FAILED=$((FAILED + 1)) } assert_eq() { if [[ "$3" == "$2" ]]; then pass "$1"; else fail "$1" "$2" "$3"; fi; } +assert_contains() { if [[ "$3" == *"$2"* ]]; then pass "$1"; else fail "$1" "contains: $2" "$3"; fi; } -# --- The pre-compute lines this script replaced, verbatim -------------------- -ORIG_HOOK="$TMP/original-hook-events.sh" -cat >"$ORIG_HOOK" <<'ORIG' -f="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.claude/observability/hook-events.jsonl"; if [[ -f "$f" ]]; then echo "$(wc -l < "$f") events"; else echo "EMPTY (no hook-event emitter wired, or no hooks fired yet)"; fi -ORIG - +# --- The OTEL pre-compute line this script replaced, verbatim ---------------- ORIG_OTEL="$TMP/original-otel-store.sh" cat >"$ORIG_OTEL" <<'ORIG' d="${CC_OTEL_STORE:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.claude/observability/otel}"; for f in cc-logs.json cc-metrics.json cc-traces.json; do if [[ -f "$d/$f" ]]; then echo "$f:$(wc -c < "$d/$f" 2>/dev/null || echo 0)B"; else echo "$f:absent"; fi; done 2>/dev/null || echo "unknown" @@ -83,16 +89,27 @@ chmod +x "$STUB/git" export PATH="$STUB:$PATH" # --- Fixtures ---------------------------------------------------------------- -# WIRED: a repo whose hook log has 4 lines and whose store holds two of three files. +# WIRED: a checkout whose hook log root holds two session files (2 + 1 lines), +# a shared file (4 lines), a healthy guard, and whose OTEL store holds two of +# three files. WIRED="$TMP/wired" -mkdir -p "$WIRED/.claude/observability/otel" -printf '{"a":1}\n{"a":2}\n{"a":3}\n{"a":4}\n' >"$WIRED/.claude/observability/hook-events.jsonl" +mkdir -p "$WIRED/.git" "$WIRED/.observability/claude/sessions" "$WIRED/.claude/observability/otel" +printf '*\n' >"$WIRED/.observability/claude/.gitignore" +printf '{"a":1}\n{"a":2}\n' >"$WIRED/.observability/claude/sessions/s-old.jsonl" +sleep 1 +printf '{"a":3}\n' >"$WIRED/.observability/claude/sessions/s-new.jsonl" +printf '{"a":1}\n{"a":2}\n{"a":3}\n{"a":4}\n' >"$WIRED/.observability/claude/hook-events.jsonl" printf '0123456789' >"$WIRED/.claude/observability/otel/cc-logs.json" printf '01234' >"$WIRED/.claude/observability/otel/cc-traces.json" -# BARE: a repo with no observability tree at all. +# BARE: a checkout with no observability tree at all. BARE="$TMP/bare" -mkdir -p "$BARE" +mkdir -p "$BARE/.git" + +# MOVED: the root configured elsewhere, one session file. +MOVED="$TMP/moved" +mkdir -p "$MOVED/.git" "$MOVED/telemetry/hooks/sessions" +printf '{"a":1}\n{"a":2}\n{"a":3}\n' >"$MOVED/telemetry/hooks/sessions/s1.jsonl" # ALTSTORE: a store outside any repo, for the CC_OTEL_STORE override. ALTSTORE="$TMP/altstore" @@ -101,13 +118,12 @@ printf 'xy' >"$ALTSTORE/cc-metrics.json" # Expected byte/line counts come from `wc` itself, not from GNU-shaped literals. # BSD `wc` left-pads its output (` 10`) where GNU does not, and the script -# must keep emitting whatever the host's `wc` produces — that padding is part of -# the output shape the replaced pre-compute line had. Hardcoding `10B` would make -# these cases fail on macOS for a difference the script is required to preserve. +# must keep emitting whatever the host's `wc` produces. wc_c() { wc -c <"$1"; } -wc_l() { wc -l <"$1"; } -WIRED_EVENTS="$(wc_l "$WIRED/.claude/observability/hook-events.jsonl") events" +WIRED_EVENTS="$(cat "$WIRED"/.observability/claude/sessions/*.jsonl "$WIRED/.observability/claude/hook-events.jsonl" | wc -l) events" +MOVED_EVENTS="$(wc -l <"$MOVED/telemetry/hooks/sessions/s1.jsonl") events" +EMPTY_LINE="EMPTY (no hook-event emitter wired, or no hooks fired yet)" WIRED_STORE_LINES="$(printf 'cc-logs.json:%sB\ncc-metrics.json:absent\ncc-traces.json:%sB' \ "$(wc_c "$WIRED/.claude/observability/otel/cc-logs.json")" \ "$(wc_c "$WIRED/.claude/observability/otel/cc-traces.json")")" @@ -126,16 +142,32 @@ run_both() { # --- --hook-events ------------------------------------------------------------ export STUB_GIT_TOPLEVEL="$WIRED" -run_both "hook log present → line count" --hook-events "$ORIG_HOOK" "$WIRED_EVENTS" +assert_eq "hook log present → events summed across session files and the shared file" \ + "$WIRED_EVENTS" "$(bash "$SCRIPT" --hook-events 2>/dev/null)" export STUB_GIT_TOPLEVEL="$BARE" -run_both "hook log absent → EMPTY sentence" --hook-events "$ORIG_HOOK" \ - "EMPTY (no hook-event emitter wired, or no hooks fired yet)" +assert_eq "hook log absent → EMPTY sentence" "$EMPTY_LINE" "$(bash "$SCRIPT" --hook-events 2>/dev/null)" + +export STUB_GIT_TOPLEVEL="$MOVED" +assert_eq "--root moves the root" "$MOVED_EVENTS" \ + "$(bash "$SCRIPT" --hook-events --root telemetry/hooks 2>/dev/null)" +assert_eq "--root with a trailing slash is the same root" "$MOVED_EVENTS" \ + "$(bash "$SCRIPT" --hook-events --root telemetry/hooks/ 2>/dev/null)" +assert_eq "an unexpanded placeholder reads as the default root" "$EMPTY_LINE" \ + "$(bash "$SCRIPT" --hook-events --root '${user_config.session_event_log_dir}' 2>/dev/null)" +assert_eq "an empty --root reads as the default root" "$EMPTY_LINE" \ + "$(bash "$SCRIPT" --hook-events --root '' 2>/dev/null)" +assert_eq "an uncontained root is INVALID, never resolved" \ + "INVALID root (../outside): the hooks write nothing" \ + "$(bash "$SCRIPT" --hook-events --root ../outside 2>/dev/null)" +assert_eq "an absolute root is INVALID" \ + "INVALID root (/tmp/x): the hooks write nothing" \ + "$(bash "$SCRIPT" --hook-events --root /tmp/x 2>/dev/null)" unset STUB_GIT_TOPLEVEL cd "$WIRED" || exit 1 -run_both "not in a repo → hook log resolves under the working directory" \ - --hook-events "$ORIG_HOOK" "$WIRED_EVENTS" +assert_eq "not in a repo → hook log resolves under the working directory" \ + "$WIRED_EVENTS" "$(bash "$SCRIPT" --hook-events 2>/dev/null)" cd "$START_DIR" || exit 1 export STUB_GIT_TOPLEVEL="$WIRED" STUB_GIT_CRLF=1 @@ -145,7 +177,8 @@ unset STUB_GIT_CRLF # The replaced line had no env override; CC_OTEL_STORE must not steer this mode. export CC_OTEL_STORE="$ALTSTORE" -run_both "CC_OTEL_STORE does not steer --hook-events" --hook-events "$ORIG_HOOK" "$WIRED_EVENTS" +assert_eq "CC_OTEL_STORE does not steer --hook-events" "$WIRED_EVENTS" \ + "$(bash "$SCRIPT" --hook-events 2>/dev/null)" unset CC_OTEL_STORE # --- --otel-store ------------------------------------------------------------- @@ -175,6 +208,63 @@ assert_eq "CRLF toplevel is stripped (--otel-store)" "$WIRED_STORE_LINES" \ "$(bash "$SCRIPT" --otel-store 2>/dev/null)" unset STUB_GIT_CRLF +# --- --pipeline --------------------------------------------------------------- +export STUB_GIT_TOPLEVEL="$WIRED" +P_OUT="$(bash "$SCRIPT" --pipeline --enabled true --keep-sessions 5 2>/dev/null)" +assert_eq "pipeline: six lines" "6" "$(printf '%s\n' "$P_OUT" | wc -l | tr -d ' ')" +assert_contains "pipeline: default root named as default" "root: .observability/claude (default)" "$P_OUT" +assert_contains "pipeline: guard ok" "guard: ok" "$P_OUT" +assert_contains "pipeline: session count and newest by mtime" "sessions: 2 file(s), newest s-new" "$P_OUT" +assert_contains "pipeline: shared file count" "shared: 4 event(s) in hook-events.jsonl" "$P_OUT" +assert_contains "pipeline: no pending prune" "prune-pending: none" "$P_OUT" +assert_contains "pipeline: options rendered with defaults filled in" \ + "logging: on; categories: all; keep: 5 sessions or 14 days; pre-prune: none" "$P_OUT" + +P_OUT="$(bash "$SCRIPT" --pipeline --enabled '${user_config.session_event_log_enabled}' \ + --categories '${user_config.session_event_log_categories}' --keep-days '${user_config.session_log_keep_days}' \ + --pre-prune-command 'archive.sh' 2>/dev/null)" +assert_contains "pipeline: unexpanded placeholders read as the manifest defaults" \ + "logging: off; categories: all; keep: 30 sessions or 14 days; pre-prune: set (runs detached at SessionEnd)" "$P_OUT" +if [[ "$P_OUT" != *"archive.sh"* ]]; then + pass "pipeline: the pre-prune command text is never echoed" +else + fail "pipeline: the pre-prune command text is never echoed" "no archive.sh" "$P_OUT" +fi + +export STUB_GIT_TOPLEVEL="$BARE" +P_OUT="$(bash "$SCRIPT" --pipeline 2>/dev/null)" +assert_contains "pipeline: absent guard is reported as healed on first write" \ + "guard: absent (the first write heals it)" "$P_OUT" +assert_contains "pipeline: no sessions" "sessions: none" "$P_OUT" +assert_contains "pipeline: no shared file" "shared: absent" "$P_OUT" +if [[ ! -e "$BARE/.observability" ]]; then + pass "pipeline: the probe never creates the root or the guard" +else + fail "pipeline: the probe never creates the root or the guard" "no .observability" "created" +fi + +EDITED="$TMP/edited" +mkdir -p "$EDITED/.git" "$EDITED/.observability/claude/prune-pending/1000-1" "$EDITED/.observability/claude/prune-pending/2000-2" +printf '# mine\nsessions/\n' >"$EDITED/.observability/claude/.gitignore" +touch -t 202601010000 "$EDITED/.observability/claude/prune-pending/1000-1" +export STUB_GIT_TOPLEVEL="$EDITED" +P_OUT="$(bash "$SCRIPT" --pipeline 2>/dev/null)" +assert_contains "pipeline: an operator-edited guard is named" "guard: operator-edited (writes refused)" "$P_OUT" +assert_contains "pipeline: stale pending prune WARNs" \ + "prune-pending: 2 dir(s), 1 older than 24 h WARN: an archiver is not finishing" "$P_OUT" +assert_eq "pipeline: the operator's guard is left alone" "# mine" "$(head -1 "$EDITED/.observability/claude/.gitignore")" + +NOGIT="$TMP/nogit" +mkdir -p "$NOGIT" +export STUB_GIT_TOPLEVEL="$NOGIT" +P_OUT="$(bash "$SCRIPT" --pipeline 2>/dev/null)" +assert_contains "pipeline: outside a checkout no guard is needed" "guard: not needed (not a git checkout)" "$P_OUT" + +P_OUT="$(bash "$SCRIPT" --pipeline --root ../escape 2>/dev/null)" +assert_contains "pipeline: an uncontained root is INVALID" "root: ../escape INVALID (uncontained; the hooks write nothing)" "$P_OUT" +assert_contains "pipeline: guard is n/a on an invalid root" "guard: n/a (root invalid)" "$P_OUT" +unset STUB_GIT_TOPLEVEL + # --- Mode validation ---------------------------------------------------------- out="$(bash "$SCRIPT" 2>&1)" rc=$? @@ -187,6 +277,9 @@ esac bash "$SCRIPT" --bogus >/dev/null 2>&1 assert_eq "unknown argument exits 3" "3" "$?" +bash "$SCRIPT" --hook-events --root >/dev/null 2>&1 +assert_eq "a flag without its value exits 3" "3" "$?" + out="$(bash "$SCRIPT" --hook-events --otel-store 2>&1)" rc=$? assert_eq "conflicting modes exit 3" "3" "$rc" diff --git a/plugins/claude-ops/skills/setup/SKILL.md b/plugins/claude-ops/skills/setup/SKILL.md index f846923c42..7dade4f25b 100644 --- a/plugins/claude-ops/skills/setup/SKILL.md +++ b/plugins/claude-ops/skills/setup/SKILL.md @@ -1,31 +1,39 @@ --- -description: "Verify claude-ops's personal path configuration for this repository, where the known-issues registry and the skill-usage log resolve, and explain how to change them through Claude Code. Use when: 'set up claude-ops', 'configure claude-ops', 'claude-ops setup', 'where does the known-issues registry live', or 'where is skill usage logged'. Check-only: verifies, reports, and prints reconfiguration guidance; there is nothing setup may write here. Re-runnable and safe." -argument-hint: "check" +description: "Verify claude-ops's personal path configuration for this repository (where the known-issues registry, the skill-usage log and the per-session hook event log resolve), check the self-ignoring guard on the hook log root, detect retired conventions, and explain how to change the options through Claude Code. Use when: 'set up claude-ops', 'configure claude-ops', 'claude-ops setup', 'where does the known-issues registry live', 'where is skill usage logged', 'set up hook logging', 'where does the hook event log live', or 'turn on session event logging'. check (read-only, default) verifies and reports; apply writes exactly one file, the guard inside the hook log root, and runs the gated retired-convention cleanup. Every option itself is reconfigured through Claude Code, never by this skill. Re-runnable and safe." +argument-hint: "check | apply" user-invocable: true disable-model-invocation: true --- ## Purpose -Check-only setup under the Check-only carve-out (`docs/PLUGIN-PHILOSOPHY.md` "Setup is explicit -and repeatable" in the marketplace repository): this plugin's configuration surface contains no -writable artifact, so `check` verifies, reports, and prints the reconfiguration guidance below, -and no `apply` is offered because there is nothing it could conformingly write. `registry_dir` and -`skill_usage_dir` are personal `userConfig` scalars owned by Claude Code's native configuration -surface. Claude Code prompts for them when the plugin is enabled, stores non-sensitive options in -user settings, and ignores `pluginConfigs` entries in project and local settings on current -releases (≥ 2.1.207). This skill never writes them. +Setup under the uniform setup contract (`docs/PLUGIN-PHILOSOPHY.md` "Setup is explicit and +repeatable" in the marketplace repository). This plugin's configuration surface is native +`userConfig` scalars that Claude Code owns (`registry_dir`, `skill_usage_dir`, `skill_usage_scope`, +and the six `session_*` hook-logging options): Claude Code prompts for them when the plugin is +enabled, stores non-sensitive options in user settings, and ignores `pluginConfigs` entries in +project and local settings on current releases (at or above 2.1.207). This skill never writes them. + +One artifact is writable, and `apply` is bounded to it: the self-ignoring `.gitignore` inside the +hook log root (`${user_config.session_event_log_dir}`, default `.observability/claude`). The plugin +defines that file's shape (first non-comment line is `*`), the hooks create it on their first write +when it is missing, and a fresh clone or worktree therefore heals itself; `apply` creates the same +file ahead of the first event so `check` can report a configured state before logging has fired. +The consumer's root `.gitignore` is never touched (config-cascade convention: "No plugin writes the +consumer's `.gitignore`"; the guard lives in a tree the plugin owns, the same shape the topic-docs +memory tier uses for its own root). Official contract (verified 2026-07-18): . -Action routing: no argument or `check` runs the check. Non-interactive, never prompts. +Action routing: no argument or `check` runs the check; `apply` runs the check first, then the two +bounded writes below. Non-interactive, never prompts. ## `check` (read-only) -Read the rendered `${user_config.registry_dir}` and `${user_config.skill_usage_dir}` values from this -skill, never inspect or edit settings files or `pluginConfigs` directly. Report a PASS/FAIL/INFO -table, one remediation line per FAIL. Do not modify anything. +Read the rendered `${user_config.*}` values from this skill, never inspect or edit settings files +or `pluginConfigs` directly. Report a PASS/FAIL/INFO table, one remediation line per FAIL. Do not +modify anything. 1. **`registry_dir`**. Report the effective known-issues-registry destination: - empty or unexpanded: INFO, the registry uses `${CLAUDE_PLUGIN_DATA}` (the zero-config default). @@ -45,52 +53,125 @@ table, one remediation line per FAIL. Do not modify anything. - a configured `skill_usage_dir` (repo/user scopes): validate containment under the scope root. PASS when contained; FAIL when uncontained. 3. **Containment**, a configured value must be a contained relative path under its base (the project - root for `registry_dir` and repo-scope `skill_usage_dir`; `$HOME` for user-scope - `skill_usage_dir`). FAIL any - POSIX/rooted path, Windows drive-qualified or drive-relative path, UNC path, any `..` segment with - either separator, and any existing symlink path that resolves outside that base. Do not normalize - an invalid value into acceptance, and do not run any operation that would use an invalid destination. -4. **Personal-vs-project**. INFO: both options are personal, user-scoped preferences, not tracked team - policy. Note the per-machine-vs-repository-resident tradeoff so the reader can choose a + root for `registry_dir`, repo-scope `skill_usage_dir` and `session_event_log_dir`; `$HOME` for + user-scope `skill_usage_dir`). FAIL any POSIX/rooted path, Windows drive-qualified or + drive-relative path, UNC path, any `..` segment with either separator, and any existing symlink + path that resolves outside that base. Do not normalize an invalid value into acceptance, and do + not run any operation that would use an invalid destination. +4. **Personal-vs-project**. INFO: every option is a personal, user-scoped preference, not tracked + team policy. Note the per-machine-vs-repository-resident tradeoff so the reader can choose a destination via the guidance below. +5. **Hook log root and its guard**. Anchor at the repo root: resolve `REPO_ROOT` once, + `${CLAUDE_PROJECT_DIR}` when set, otherwise `git rev-parse --show-toplevel`, and use that literal + path for every read below. The root is `REPO_ROOT/` where `` is + `${user_config.session_event_log_dir}`, or `.observability/claude` when empty or unexpanded. + - `` uncontained (rule 3): FAIL, the hooks write nothing; remediate through the + reconfiguration guidance. `` root-equivalent (`.`, `./`, or a path that resolves to + `REPO_ROOT`): FAIL, never written, because a `*` guard there would ignore the whole + repository; `apply` refuses it too. + - `REPO_ROOT` is not a git checkout (no `.git` directory or file): INFO, no guard is needed and + the hooks write without one. + - Guard present (`REPO_ROOT//.gitignore` whose first non-blank, non-comment line is exactly + `*`): PASS. Then the tracked-versus-ignored pair, both probed, both reported: + `git -C "$REPO_ROOT" check-ignore -v -- "/.gitignore"` names a rule (the guard ignores + itself), and `git -C "$REPO_ROOT" ls-files --error-unmatch -- ""` fails (nothing under + the root is tracked). A tracked file under the root is FAIL: the guard cannot un-track it, and + the remediation is the operator's own `git rm --cached`, which this skill never runs. + - Guard present but its first non-comment line is not `*`: FAIL. The hooks refuse to write under + an operator-edited guard rather than overwrite it, and so does `apply`; remediation is to + restore the `*` line by hand or move the root through the reconfiguration guidance. + - Guard absent and `${user_config.session_event_log_enabled}` renders `false` or unexpanded: + INFO, logging is off, nothing is written until it is turned on, and the first event then + creates the guard (announced in that session's observability report). + - Guard absent and logging on: FAIL, remediation `apply` (or the next hook event, which heals + it; `apply` is the way to have it in place before that event and to see it verified here). + - Report the six options as rendered (`session_event_log_enabled`, `session_event_log_dir`, + `session_event_log_categories`, `session_log_keep_sessions`, `session_log_keep_days`, + `session_log_pre_prune_command`): INFO rows, so the effective retention and any pre-prune + command are visible in the same table. A non-empty pre-prune command is executed through + `bash -c` at `SessionEnd` and is trusted configuration; say so on its row. +6. **Retired conventions**, when this plugin ships `retirements.yaml`: run + `bash "${CLAUDE_PLUGIN_ROOT}/lib/check-retirements.sh" --manifest "${CLAUDE_PLUGIN_ROOT}/retirements.yaml"`. + Exit 0 → PASS. Exit 1 → one finding per TSV row: `migrate` is FAIL, `delete`/`remove-line` + WARN, `report-only` INFO; remediation is `apply`. Exit 2 → FAIL, never silent. Bash unavailable + → report the step UNKNOWN with remediation, never green. + In this plugin's manifest that yields `claude-ops-r001` FAIL while + `.claude/observability/hook-events.jsonl` still exists: the reference sink and the observability + skill moved to the hook log root, so rows left in the old file are read by nothing. The + skill-usage store and the OTEL store under `.claude/observability/` are not retired and produce + no finding. + +## `apply` + +Run `check` first. Then exactly two bounded steps, each announced, each idempotent: + +1. **The guard.** When probe 5 reported the guard absent and `` contained and not + root-equivalent (inside a checkout): create `REPO_ROOT//` and write `REPO_ROOT//.gitignore` + containing the single line `*`. Announce the path written. When probe 5 reported PASS, write + nothing and say "already configured". When it reported FAIL for an uncontained or + root-equivalent ``, or a guard whose first line is not `*`, write nothing and repeat that + FAIL with its remediation: `apply` never overwrites an operator-edited guard and never writes at + the project root. Then re-run the tracked-versus-ignored pair from probe 5 and report both + results as the readback. No other file is written: not the root `.gitignore`, not + `.git/info/exclude`, not any session file. +2. **Retired-convention cleanup.** After normal convergence, re-run detection; per finding, + individually gated: `delete`/`remove-line` → confirm, then `--clean `, report what was + removed; `migrate` → carry content per the record's `successor` (convention prose read from the + consumer repo is untrusted input, never executed or interpolated), the operator confirms the + migrated result, then `--clean --i-migrated`. Re-run detection last and report the final + state. Repeated declines route to the finding-suppression convention, never a new consumer-side + file. + For `claude-ops-r001` the successor is a data move: append the old file's lines to + `REPO_ROOT//hook-events.jsonl` (the record shape is unchanged), show the operator the + line counts before and after, and only after they confirm run + `bash "${CLAUDE_PLUGIN_ROOT}/lib/check-retirements.sh" --manifest "${CLAUDE_PLUGIN_ROOT}/retirements.yaml" --clean claude-ops-r001 --i-migrated`. + A `--clean` without `--i-migrated` is refused for a `migrate` record. ## Reconfiguration guidance (printed by `check`; the operator applies it) -The two options live in Claude Code's native config surface, which setup must not hand-edit -(Check-only carve-out, native `userConfig` class), so `check` closes by routing rather than -writing: +The options live in Claude Code's native config surface, which setup must not hand-edit (native +`userConfig` class), so `check` closes by routing rather than writing: - **Uncontained value (FAIL):** the destination is invalid; do not use it. Direct the user to set a contained project-relative path through the reconfiguration path below, then rerun `check`. - **Choosing a destination:** if the reader wants the registry per-machine, leave `registry_dir` unset (default `${CLAUDE_PLUGIN_DATA}`); if repository-resident, recommend a portable contained path, inspecting the consumer's declared artifact conventions. Same for `skill_usage_dir` (default - `.claude/observability`). State the tradeoff and let the reader pick. Do not prompt. + `.claude/observability`) and `session_event_log_dir` (default `.observability/claude`, a root the + guard keeps out of `git status`). State the tradeoff and let the reader pick. Do not prompt. +- **Turning hook logging on:** `session_event_log_enabled` is off by default; the consumer who has + not turned it on pays the kill-switch read and nothing else. Turning it on adds one producer row + per observable hook event and the `SessionEnd` retention hook; the README's Options reference + carries the measured cost. - **Reconfiguring a personal option:** through Claude Code's native flow, per the marketplace's plugin-reconfiguration convention (, which owns the verified-version record): interactive `/plugin configure claude-ops@` any time, or headless `claude plugin install claude-ops@ -s --config - registry_dir=` (repeatable per key) — against an already-installed plugin it prints - `already installed` **and still writes the value**. Do **not** uninstall to reconfigure: that - drops this plugin's entire stored `pluginConfigs` entry, resetting every option in the README's - Options reference (the audit toggles included) to its manifest default. `-s` defaults to `user`; - pass the scope `claude plugin list` reports for this plugin, and run from that project's directory - for a `project`/`local` scope, or the write lands at a scope that does not load. This skill never - writes user settings or `pluginConfigs`. Afterwards rerun `check` in a **fresh session** — the - rendered `${user_config.*}` is injected at skill load and each hook receives its - `CLAUDE_PLUGIN_OPTION_*` from an environment fixed at session start, so a same-session `check` - still reports the OLD value; report the observed effective value, never an unobserved change. - -After any reconfiguration, rerun `check` in a **fresh session** and report both observed effective -destinations, never claim an unobserved change, and never read a same-session `check` still showing + registry_dir=` (repeatable per key, `session_event_log_enabled=true` included). Against an + already-installed plugin it prints `already installed` **and still writes the value**. Do **not** + uninstall to reconfigure: that drops this plugin's entire stored `pluginConfigs` entry, resetting + every option in the README's Options reference (the audit toggles included) to its manifest + default. `-s` defaults to `user`; pass the scope `claude plugin list` reports for this plugin, and + run from that project's directory for a `project`/`local` scope, or the write lands at a scope + that does not load. This skill never writes user settings or `pluginConfigs`. Afterwards rerun + `check` in a **fresh session**: the rendered `${user_config.*}` is injected at skill load and each + hook receives its `CLAUDE_PLUGIN_OPTION_*` from an environment fixed at session start, so a + same-session `check` still reports the OLD value; report the observed effective value, never an + unobserved change. + +After any reconfiguration, rerun `check` in a **fresh session** and report every observed effective +destination, never claim an unobserved change, and never read a same-session `check` still showing the old value as a failed write (see the reconfiguration note above for why it does). Re-running -`check` when both destinations are contained (or defaulted) changes nothing and reports -"already configured". +`check` or `apply` when every destination is contained (or defaulted) and the guard is in place +changes nothing and reports "already configured". ## What this skill does NOT do - Run known-issues, registry, or observability operations. Those are the other claude-ops skills and - have their own documented controls. + have their own documented controls; pruning session files is the `SessionEnd` retention hook's + job and `/claude-ops:observability clean`'s, never setup's. - Write the plugin cache, Claude Code user settings, or `pluginConfigs`. +- Write the consumer's root `.gitignore` or `.git/info/exclude`, overwrite a guard an operator + edited, or write anything at the project root. - Invent organization-specific configuration. diff --git a/plugins/claude-ops/skills/setup/evals/evals.json b/plugins/claude-ops/skills/setup/evals/evals.json index 6b176e508e..96bce6d86a 100644 --- a/plugins/claude-ops/skills/setup/evals/evals.json +++ b/plugins/claude-ops/skills/setup/evals/evals.json @@ -5,11 +5,11 @@ "id": 1, "name": "validates-registry-without-settings-edits", "prompt": "/claude-ops:setup", - "expected_output": "Reports the rendered registry_dir and skill_usage_dir or their documented fallbacks, explains that the options are personal, validates containment, and does not read or edit settings files or pluginConfigs.", + "expected_output": "Reports the rendered registry_dir, skill_usage_dir and session_event_log_dir or their documented fallbacks, explains that the options are personal, validates containment, reports the hook log root's guard state, and does not read or edit settings files or pluginConfigs.", "files": [], "expectations": [ "Reads only the rendered user_config values", - "Reports both effective destinations and containment status", + "Reports every effective destination and containment status", "Explains personal versus repository-resident implications", "Does not inspect or edit settings files or pluginConfigs" ] @@ -37,6 +37,45 @@ "Explains the project-containment boundary visibly", "Does not silently fall back or claim to reconfigure the options" ] + }, + { + "id": 4, + "name": "apply-writes-only-the-guard", + "prompt": "/claude-ops:setup apply\n\nsession_event_log_enabled renders true and session_event_log_dir is unset; this is a fresh git worktree and .observability/claude/ does not exist yet.", + "expected_output": "check reports the guard absent with logging on as FAIL, remediation apply. apply creates .observability/claude/.gitignore containing the single line `*`, announces that one path, then reads back the pair: `git check-ignore -v -- .observability/claude/.gitignore` names a rule and `git ls-files --error-unmatch -- .observability/claude` fails. Nothing else is written: not the root .gitignore, not .git/info/exclude, not a session file, not any settings file. A second apply writes nothing and says already configured.", + "files": [], + "expectations": [ + "The only file written is /.gitignore with `*` as its first non-comment line", + "The root .gitignore, .git/info/exclude, settings files and pluginConfigs are never edited", + "The tracked-versus-ignored pair is probed and both results are reported as the readback", + "A re-run reports already configured and produces no diff" + ] + }, + { + "id": 5, + "name": "refuses-a-root-equivalent-log-dir", + "prompt": "/claude-ops:setup apply\n\nMy rendered session_event_log_dir is `.` and session_event_log_enabled is true.", + "expected_output": "check reports session_event_log_dir as FAIL, root-equivalent: a `*` guard at the project root would ignore the whole repository. apply writes nothing at all, repeats the FAIL with the reconfiguration guidance (set a contained subdirectory such as the default .observability/claude through Claude Code's plugin configuration), and does not normalize, join, or create the destination. The same refusal applies to a guard whose first non-comment line an operator changed away from `*`: apply reports it and never overwrites it.", + "files": [], + "expectations": [ + "A root-equivalent session_event_log_dir is FAIL and nothing is written", + "Routes the fix through Claude Code's plugin configuration, never a hand edit of settings", + "An operator-edited guard is reported, never overwritten", + "No file is created at or above the project root" + ] + }, + { + "id": 6, + "name": "retirement-claude-ops-r001-migrates-the-old-hook-events-file", + "prompt": "/claude-ops:setup check, then apply, in a repo that still carries .claude/observability/hook-events.jsonl from before the hook log root moved to .observability/claude.", + "expected_output": "check runs bash \"${CLAUDE_PLUGIN_ROOT}/lib/check-retirements.sh\" --manifest \"${CLAUDE_PLUGIN_ROOT}/retirements.yaml\" and reports the claude-ops-r001 TSV row as FAIL (action migrate: the sink and the observability skill read the new root, so the old file's rows are read by nothing) with apply as the remediation. apply appends the old file's lines to /hook-events.jsonl, shows the line counts before and after, waits for the operator's confirmation, and only then runs --clean claude-ops-r001 --i-migrated; a --clean without --i-migrated is refused (exit 2). A final re-run of detection reports the leftover gone. The skill-usage and OTEL stores under .claude/observability/ produce no finding.", + "files": [], + "expectations": [ + "check reports claude-ops-r001 as FAIL, not WARN or INFO", + "The old file's rows are appended to the new root's hook-events.jsonl before any clean", + "--clean claude-ops-r001 without --i-migrated refuses; the gated clean passes --i-migrated only after the operator confirms the migrated result", + "Detection is re-run last and the final state reported; the skill-usage and OTEL stores are not touched" + ] } ] } diff --git a/scripts/fixtures/hooks-lifecycle-table.md b/scripts/fixtures/hooks-lifecycle-table.md new file mode 100644 index 0000000000..1bd776522e --- /dev/null +++ b/scripts/fixtures/hooks-lifecycle-table.md @@ -0,0 +1,37 @@ +The table below summarizes when each event fires. The Hook events section of the upstream page documents the full input schema and decision control options for each one. + +| Event | When it fires | +| :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SessionStart` | When a session begins or resumes | +| `Setup` | When you start Claude Code with `--init-only`, or with `--init` or `--maintenance` in `-p` mode. For one-time preparation in CI or scripts | +| `UserPromptSubmit` | When you submit a prompt, before Claude processes it | +| `UserPromptExpansion` | When a user-typed command expands into a prompt, before it reaches Claude. Can block the expansion | +| `PreToolUse` | Before a tool call executes. Can block it | +| `PermissionRequest` | When a tool call needs a permission decision | +| `PermissionDenied` | When auto mode denies a tool call, including denials without a classifier verdict. Use JSON `hookSpecificOutput.retry: true` to tell the model it may retry the denied tool call. Claude Code ignores `retry` when the classifier produced no verdict | +| `PostToolUse` | After a tool call succeeds | +| `PostToolUseFailure` | After a tool call fails | +| `PostToolBatch` | After a full batch of parallel tool calls resolves, before the next model call | +| `Notification` | When Claude Code sends a notification | +| `MessageDisplay` | While assistant message text is displayed | +| `SubagentStart` | When a subagent is spawned | +| `SubagentStop` | When a subagent finishes | +| `TaskCreated` | When a task is being created via `TaskCreate` | +| `TaskCompleted` | When a task is being marked as completed | +| `Stop` | When Claude finishes responding | +| `StopFailure` | When the turn ends due to an API error | +| `TeammateIdle` | When an [agent team](/docs/en/agent-teams) teammate is about to go idle | +| `InstructionsLoaded` | When a CLAUDE.md or `.claude/rules/*.md` file is loaded into context. Fires at session start and when files are lazily loaded during a session | +| `ConfigChange` | When a configuration file changes during a session | +| `CwdChanged` | When the working directory changes, for example when Claude executes a `cd` command. Useful for reactive environment management with tools like direnv | +| `DirectoryAdded` | When a working directory is added mid-session via `/add-dir` or the SDK `register_repo_root` control request | +| `FileChanged` | When a watched file changes on disk. The `matcher` field specifies which filenames to watch | +| `WorktreeCreate` | When a worktree is being created via `--worktree`, `isolation: "worktree"`, or for a background session. Replaces default git behavior | +| `WorktreeRemove` | When a worktree is being removed at session exit, when a subagent finishes, or when you delete a background session | +| `PreCompact` | Before context compaction | +| `PostCompact` | After context compaction completes | +| `PreModelSwitch` | Before Claude Code applies a model switch that you or a client requested. Can block the switch | +| `PostModelSwitch` | After the session's model changes, including changes Claude Code makes on its own, such as restoring the model when you resume a session | +| `Elicitation` | When an MCP server requests user input during a tool call | +| `ElicitationResult` | After a user responds to an MCP elicitation, before the response is sent back to the server | +| `SessionEnd` | When a session terminates | diff --git a/scripts/gen-hook-event-registry.sh b/scripts/gen-hook-event-registry.sh new file mode 100755 index 0000000000..43f862c88b --- /dev/null +++ b/scripts/gen-hook-event-registry.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +# Generate the hook event registry the claude-ops per-session event log is +# registered from, and the hooks.json rows that register it. +# +# scripts/gen-hook-event-registry.sh --fetch fetch the Hooks reference, +# rewrite the registry and +# the producer rows +# scripts/gen-hook-event-registry.sh --from same, parsing a saved copy +# of the reference (tests) +# scripts/gen-hook-event-registry.sh --check OFFLINE: re-derive the +# producer rows from the +# committed registry and +# fail on drift (CI) +# +# Why generated, never hand-maintained: the event list is an upstream fact +# (https://code.claude.com/docs/en/hooks, the lifecycle table under "Hook +# lifecycle"), and a hardcoded list drifts silently as events are added or +# renamed. Every registry entry is a four-part record per +# docs/conventions/upstream-drift (claim, basis, as-of date, recheck trigger), +# and per docs/conventions/native-references it states what the reference +# documented on the as-of date, never that the running binary fires the event: +# a producer row on an event the binary does not fire costs nothing. +# +# Not every documented event is observable by a logging hook. Three are +# excluded with their reason stamped in the registry, because a registered +# hook on them changes behavior rather than observing it: +# WorktreeCreate configuring one REPLACES the default git worktree creation, +# and a hook that prints no path fails the worktree +# MessageDisplay Claude Code holds each streamed batch until the hook returns +# FileChanged the matcher builds the watch list; a matcherless row +# watches nothing +# An event this script does not know is excluded as `unclassified` with a +# warning, never registered by default: classify it here first. +# +# The parse refuses to write when the table yields fewer than 25 rows: a page +# whose shape changed would otherwise produce an empty registry and silently +# unregister the producer. The committed registry stays authoritative until a +# human re-runs --fetch. Recheck trigger for every entry: each +# `/claude-ops:changelog` ingest of a Claude Code release whose notes touch +# hooks re-runs `--fetch --check`; a read-time re-fetch finding the table +# changed also fires. +# +# Exit 0 = written / clean; 1 = drift (--check) or a usage error; 2 = the +# reference could not be fetched or parsed (nothing written). +set -euo pipefail + +usage() { sed -n '2,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; } + +MODE="" +FROM="" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +AS_OF="" +while (($# > 0)); do + case "$1" in + --fetch) MODE=fetch ;; + --check) MODE=check ;; + --from) + MODE=from + FROM="${2:?--from needs a file}" + shift + ;; + --root) + ROOT="${2:?--root needs a directory}" + shift + ;; + --as-of) + AS_OF="${2:?--as-of needs a date}" + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "gen-hook-event-registry: unknown argument: $1" >&2 + exit 1 + ;; + esac + shift +done +[[ -n "$MODE" ]] || { + echo "gen-hook-event-registry: one of --fetch, --from , --check is required" >&2 + exit 1 +} +command -v jq >/dev/null 2>&1 || { + echo "gen-hook-event-registry: jq is required" >&2 + exit 2 +} + +URL="https://code.claude.com/docs/en/hooks.md" +BASIS="https://code.claude.com/docs/en/hooks#hook-lifecycle" +REGISTRY="$ROOT/plugins/claude-ops/hooks/hook-events.registry.json" +HOOKS_JSON="$ROOT/plugins/claude-ops/hooks/hooks.json" +# shellcheck disable=SC2016 # the literal hooks.json command text; Claude Code expands it, not this script +PRODUCER='"${CLAUDE_PLUGIN_ROOT}"/hooks/session-event-log.sh' +# shellcheck disable=SC2016 +RETENTION='"${CLAUDE_PLUGIN_ROOT}"/hooks/session-retention.sh' +RECHECK="each /claude-ops:changelog ingest of a Claude Code release whose notes touch hooks re-runs scripts/gen-hook-event-registry.sh --fetch --check; a read-time re-fetch finding the lifecycle table changed also fires" +MIN_ROWS=25 + +# classify_to : the category (the same table +# plugins/claude-ops/hooks/session-log-lib.sh carries, pinned by the test) and +# whether the producer may register on it. +classify_to() { + local c p + case "$3" in + SessionStart | SessionEnd | Setup) c=session ;; + UserPromptSubmit | UserPromptExpansion) c=prompt ;; + PreToolUse | PostToolUse | PostToolUseFailure | PostToolBatch) c=tool ;; + PermissionRequest | PermissionDenied) c=permission ;; + SubagentStart | SubagentStop | TeammateIdle) c=agent ;; + TaskCreated | TaskCompleted) c=task ;; + Stop | StopFailure | Notification) c=turn ;; + InstructionsLoaded | ConfigChange | CwdChanged | DirectoryAdded | FileChanged) c=config ;; + WorktreeCreate | WorktreeRemove) c=worktree ;; + PreCompact | PostCompact) c=compaction ;; + PreModelSwitch | PostModelSwitch) c=model ;; + Elicitation | ElicitationResult) c=mcp ;; + MessageDisplay) c=display ;; + *) c=other ;; + esac + p=observe + case "$3" in + WorktreeCreate) p="exclude: configuring a WorktreeCreate hook replaces the default git worktree creation, and a hook that prints no path fails the worktree" ;; + MessageDisplay) p="exclude: Claude Code holds each streamed batch until the hook returns" ;; + FileChanged) p="exclude: the matcher builds the watch list, so a matcherless row watches nothing" ;; + *) [[ "$c" == other ]] && p="exclude: unclassified by scripts/gen-hook-event-registry.sh; classify it there before registering" ;; + esac + printf -v "$1" '%s' "$c" + printf -v "$2" '%s' "$p" +} + +# parse_table: markdown on stdin -> `namewhen` per row of the lifecycle +# table (the rows after the `| Event | When it fires |` header, up to the first +# blank line; the name is the first backticked cell, the description the second +# cell, trimmed). +parse_table() { + awk ' + /^\| *Event *\| *When it fires *\|/ { inside = 1; next } + inside && /^\| *:?-+/ { next } + inside && /^[[:space:]]*$/ { exit } + inside && /^\| *`[A-Za-z]+` *\|/ { + line = $0 + sub(/^\| *`/, "", line) + name = line; sub(/`.*/, "", name) + when = line; sub(/^[^`]*` *\| */, "", when); sub(/ *\|[[:space:]]*$/, "", when) + gsub(/\t/, " ", when) + print name "\t" when + }' +} + +# build_registry -> registry JSON on stdout +build_registry() { + local rows="$1" as_of="$2" name when cat prod tmp + tmp=$(mktemp) + while IFS=$'\t' read -r name when; do + [[ -n "$name" ]] || continue + classify_to cat prod "$name" + [[ "$prod" == "exclude: unclassified"* ]] && + echo "gen-hook-event-registry: WARN unknown event '$name' excluded; classify it in classify_to" >&2 + printf '%s\t%s\t%s\t%s\n' "$name" "$when" "$cat" "$prod" >>"$tmp" + done <"$rows" + jq -Rn --arg as_of "$as_of" --arg basis "$BASIS" --arg recheck "$RECHECK" ' + [inputs | split("\t") | { + name: .[0], when: .[1], category: .[2], producer: .[3], + claim: ("hook event " + .[0] + " is documented in the Hooks reference lifecycle table"), + basis: ($basis + " (raw markdown of hooks.md, fetched with curl -sS -L)"), + as_of: $as_of, recheck: $recheck }] + | sort_by(.name)' <"$tmp" + rm -f "$tmp" +} + +# regen_rows -> hooks.json on stdout with +# the producer rows re-derived: every row naming the producer or the retention +# hook is stripped, then one producer row per observable event and one +# retention row on SessionEnd are appended; the existing handlers and their +# order are untouched. +regen_rows() { + jq --indent 2 --arg prod "$PRODUCER" --arg ret "$RETENTION" --slurpfile reg "$1" ' + def strip: map(select(any(.hooks[]?; .command == $prod or .command == $ret) | not)); + .hooks |= (with_entries(.value |= strip) | with_entries(select(.value | length > 0))) + | reduce ($reg[0][] | select(.producer == "observe")) as $e (.; + .hooks[$e.name] = ((.hooks[$e.name] // []) + [{hooks: [{type: "command", command: $prod, timeout: 5, + statusMessage: ("Logging the " + $e.name + " event...")}]}])) + | .hooks.SessionEnd = ((.hooks.SessionEnd // []) + [{hooks: [{type: "command", command: $ret, + statusMessage: "Pruning the session event log..."}]}]) + ' "$2" +} + +case "$MODE" in +fetch | from) + rows=$(mktemp) + trap 'rm -f "$rows"' EXIT + if [[ "$MODE" == fetch ]]; then + page=$(mktemp) + trap 'rm -f "$rows" "$page"' EXIT + if ! curl -sS -L --max-time 30 "$URL" >"$page"; then + echo "gen-hook-event-registry: fetch of $URL failed" >&2 + exit 2 + fi + parse_table <"$page" >"$rows" + else + parse_table <"$FROM" >"$rows" + fi + [[ -n "$AS_OF" ]] || AS_OF=$(date -u +%Y-%m-%d) + n=$(wc -l <"$rows" | tr -d ' ') + if ((n < MIN_ROWS)); then + echo "gen-hook-event-registry: parsed only $n lifecycle rows (need $MIN_ROWS); the page shape may have changed. Nothing written." >&2 + exit 2 + fi + build_registry "$rows" "$AS_OF" >"$REGISTRY.tmp" + mv "$REGISTRY.tmp" "$REGISTRY" + regen_rows "$REGISTRY" "$HOOKS_JSON" >"$HOOKS_JSON.tmp" + mv "$HOOKS_JSON.tmp" "$HOOKS_JSON" + observed=$(jq '[.[] | select(.producer == "observe")] | length' "$REGISTRY") + echo "gen-hook-event-registry: $n events in the registry ($observed observable), as of $AS_OF; producer rows rewritten in $HOOKS_JSON" + ;; +check) + [[ -f "$REGISTRY" ]] || { + echo "gen-hook-event-registry: no registry at $REGISTRY" >&2 + exit 1 + } + bad=$(jq '[.[] | select((has("name") and has("when") and has("category") and has("producer") and has("claim") and has("basis") and has("as_of") and has("recheck")) | not)] | length' "$REGISTRY") + if [[ "$bad" != 0 ]]; then + echo "gen-hook-event-registry: $bad registry entries lack one of the required parts (name, when, category, producer, claim, basis, as_of, recheck)" >&2 + exit 1 + fi + expected=$(regen_rows "$REGISTRY" "$HOOKS_JSON" | jq -S .) + actual=$(jq -S . "$HOOKS_JSON") + if [[ "$expected" != "$actual" ]]; then + echo "gen-hook-event-registry: hooks.json producer rows drift from the registry; re-run --fetch (or --from) to regenerate:" >&2 + diff <(printf '%s\n' "$expected") <(printf '%s\n' "$actual") >&2 || true + exit 1 + fi + echo "gen-hook-event-registry: hooks.json producer rows match the registry ($(jq length "$REGISTRY") events)" + ;; +*) + usage >&2 + exit 1 + ;; +esac diff --git a/scripts/gen-hook-event-registry.test.sh b/scripts/gen-hook-event-registry.test.sh new file mode 100755 index 0000000000..6febb3f343 --- /dev/null +++ b/scripts/gen-hook-event-registry.test.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Unit tests for gen-hook-event-registry.sh. Builds a fixture tree per case +# (a plugins/claude-ops/hooks with a copy of the real hooks.json and the real +# session-log-lib.sh) and runs the generator against a saved copy of the +# Hooks reference lifecycle table, so nothing here touches the network. +set -uo pipefail + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$SELF_DIR/.." && pwd)" +SCRIPT="$SELF_DIR/gen-hook-event-registry.sh" +TABLE="$SELF_DIR/fixtures/hooks-lifecycle-table.md" +REAL_HOOKS_JSON="$REPO/plugins/claude-ops/hooks/hooks.json" +LIB="$REPO/plugins/claude-ops/hooks/session-log-lib.sh" + +# shellcheck source=lib/test-harness.sh +. "$SELF_DIR/lib/test-harness.sh" + +FIXTURES=() +cleanup() { + local d + for d in ${FIXTURES[@]+"${FIXTURES[@]}"}; do rm -rf "$d"; done +} +trap cleanup EXIT + +# shellcheck disable=SC2016 # literal hooks.json command text, never expanded here +PRODUCER='"${CLAUDE_PLUGIN_ROOT}"/hooks/session-event-log.sh' +# shellcheck disable=SC2016 +RETENTION='"${CLAUDE_PLUGIN_ROOT}"/hooks/session-retention.sh' + +# new_fixture -> a repo root carrying the real hooks.json with every producer +# row stripped (so the base is the nine handlers alone) and the lib. +new_fixture() { + local dir + dir="$(mktemp -d)" + mkdir -p "$dir/plugins/claude-ops/hooks" + jq --indent 2 --arg prod "$PRODUCER" --arg ret "$RETENTION" ' + .hooks |= (with_entries(.value |= map(select(any(.hooks[]?; .command == $prod or .command == $ret) | not))) + | with_entries(select(.value | length > 0)))' "$REAL_HOOKS_JSON" >"$dir/plugins/claude-ops/hooks/hooks.json" + cp "$LIB" "$dir/plugins/claude-ops/hooks/" + FIXTURES+=("$dir") + printf '%s' "$dir" +} + +# --- a full run from the saved table ----------------------------------------- +f="$(new_fixture)" +BASE_HANDLERS=$(jq -S '[.hooks[][] | .hooks[] | .command] | sort' "$f/plugins/claude-ops/hooks/hooks.json") +out=$(bash "$SCRIPT" --from "$TABLE" --root "$f" --as-of 2026-09-05 2>&1) +rc=$? +REG="$f/plugins/claude-ops/hooks/hook-events.registry.json" +HJ="$f/plugins/claude-ops/hooks/hooks.json" +if ((rc == 0)) && [[ -s "$REG" ]]; then + ok "a run from the saved table writes the registry" +else + fail "a run from the saved table writes the registry (rc=$rc): $out" +fi + +n=$(jq length "$REG") +if ((n == 33)); then ok "33 events parsed from the table"; else fail "expected 33 events, got $n"; fi + +incomplete=$(jq '[.[] | select((has("name") and has("when") and has("category") and has("producer") and has("claim") and has("basis") and has("as_of") and has("recheck")) | not)] | length' "$REG") +if [[ "$incomplete" == 0 ]]; then ok "every entry carries the four-part record plus category and producer"; else fail "$incomplete entries are incomplete"; fi + +as_of=$(jq -r '[.[].as_of] | unique | join(",")' "$REG") +if [[ "$as_of" == "2026-09-05" ]]; then ok "as-of date stamped on every entry"; else fail "as_of: $as_of"; fi + +if jq -e '[.[] | .recheck | test("changelog")] | all' "$REG" >/dev/null; then + ok "every recheck trigger names an observable occasion" +else + fail "a recheck trigger is not the changelog-ingest occasion" +fi + +# The three behavior-changing events are excluded with a reason; the rest observe. +for ev in WorktreeCreate MessageDisplay FileChanged; do + p=$(jq -r --arg e "$ev" '.[] | select(.name == $e) | .producer' "$REG") + if [[ "$p" == exclude:* ]]; then ok "$ev is excluded ($p)"; else fail "$ev should be excluded, got: $p"; fi + rows=$(jq -r --arg e "$ev" --arg prod "$PRODUCER" '[.hooks[$e][]? | .hooks[] | select(.command == $prod)] | length' "$HJ") + if [[ "$rows" == 0 ]]; then ok "$ev has no producer row in hooks.json"; else fail "$ev has $rows producer rows"; fi +done +observed=$(jq '[.[] | select(.producer == "observe")] | length' "$REG") +if ((observed == 30)); then ok "30 events are observable"; else fail "expected 30 observable events, got $observed"; fi + +# One producer row per observable event, with statusMessage and timeout. +missing=0 +while IFS= read -r ev; do + c=$(jq -r --arg e "$ev" --arg prod "$PRODUCER" '[.hooks[$e][]? | .hooks[] | select(.command == $prod and .timeout == 5 and (.statusMessage | length > 0))] | length' "$HJ") + [[ "$c" == 1 ]] || missing=$((missing + 1)) +done < <(jq -r '.[] | select(.producer == "observe") | .name' "$REG") +if ((missing == 0)); then ok "every observable event has exactly one producer row"; else fail "$missing observable events lack their producer row"; fi + +ret=$(jq -r --arg ret "$RETENTION" '[.hooks.SessionEnd[]? | .hooks[] | select(.command == $ret)] | length' "$HJ") +if [[ "$ret" == 1 ]]; then ok "SessionEnd carries the retention row once"; else fail "retention rows on SessionEnd: $ret"; fi +ret_timeout=$(jq -r --arg ret "$RETENTION" '.hooks.SessionEnd[] | .hooks[] | select(.command == $ret) | has("timeout")' "$HJ") +if [[ "$ret_timeout" == false ]]; then ok "the retention row carries no timeout (a plugin timeout only lowers the cap)"; else fail "retention row has a timeout"; fi + +# The nine existing handlers survive the merge, in order. +AFTER_HANDLERS=$(jq -S --arg prod "$PRODUCER" --arg ret "$RETENTION" '[.hooks[][] | .hooks[] | select(.command != $prod and .command != $ret) | .command] | sort' "$HJ") +if [[ "$BASE_HANDLERS" == "$AFTER_HANDLERS" ]]; then ok "the existing handlers survive the regeneration"; else fail "existing handlers changed"; fi +first_key=$(jq -r '.hooks | keys_unsorted[0]' "$HJ") +if [[ "$first_key" == StopFailure ]]; then ok "existing key order preserved (StopFailure first)"; else fail "first key is $first_key"; fi + +# Idempotent: a second run changes nothing. +before=$(jq -S . "$HJ") +bash "$SCRIPT" --from "$TABLE" --root "$f" --as-of 2026-09-05 >/dev/null 2>&1 +after=$(jq -S . "$HJ") +if [[ "$before" == "$after" ]]; then ok "a second run is idempotent"; else fail "a second run changed hooks.json"; fi + +# --check passes on the generated tree and fails when a row is removed. +out=$(bash "$SCRIPT" --check --root "$f" 2>&1) +rc=$? +if ((rc == 0)); then ok "--check is clean on a generated tree"; else fail "--check on a clean tree (rc=$rc): $out"; fi +jq --indent 2 '.hooks.PostToolUse |= .[:-1]' "$HJ" >"$HJ.drift" && mv "$HJ.drift" "$HJ" +out=$(bash "$SCRIPT" --check --root "$f" 2>&1) +rc=$? +if ((rc == 1)) && [[ "$out" == *drift* ]]; then ok "--check fails on a removed producer row"; else fail "--check on drift (rc=$rc): $out"; fi + +# --- the category table agrees with session-log-lib.sh ------------------------- +# shellcheck source=../plugins/claude-ops/hooks/session-log-lib.sh +source "$LIB" +disagree=0 +while IFS=$'\t' read -r ev cat; do + slog_category_to c "$ev" + [[ "$c" == "$cat" ]] || { + disagree=$((disagree + 1)) + echo " $ev: registry=$cat lib=$c" >&2 + } +done < <(jq -r '.[] | [.name, .category] | @tsv' "$REG") +if ((disagree == 0)); then ok "registry categories agree with slog_category_to"; else fail "$disagree events disagree with slog_category_to"; fi + +# --- the under-25-rows refusal --------------------------------------------------- +f="$(new_fixture)" +short="$(mktemp)" +FIXTURES+=("$short") +head -12 "$TABLE" >"$short" +out=$(bash "$SCRIPT" --from "$short" --root "$f" 2>&1) +rc=$? +if ((rc == 2)) && [[ ! -e "$f/plugins/claude-ops/hooks/hook-events.registry.json" ]]; then + ok "fewer than 25 rows: exit 2 and nothing written" +else + fail "short table (rc=$rc): $out" +fi + +# --- an unknown event is excluded with a warning, never registered ------------------ +f="$(new_fixture)" +odd="$(mktemp)" +FIXTURES+=("$odd") +{ cat "$TABLE"; } >"$odd" +# shellcheck disable=SC2016 # the backticks are markdown table text, not a substitution +sed 's/^| `SessionEnd` *|/| `MysteryEvent` |/' "$odd" >"$odd.2" && mv "$odd.2" "$odd" +out=$(bash "$SCRIPT" --from "$odd" --root "$f" 2>&1) +if [[ "$out" == *"WARN unknown event 'MysteryEvent'"* ]]; then ok "an unknown event warns"; else fail "no warning for an unknown event: $out"; fi +p=$(jq -r '.[] | select(.name == "MysteryEvent") | .producer' "$f/plugins/claude-ops/hooks/hook-events.registry.json") +if [[ "$p" == "exclude: unclassified"* ]]; then ok "an unknown event is excluded"; else fail "unknown event producer: $p"; fi +rows=$(jq -r --arg prod "$PRODUCER" '[.hooks.MysteryEvent[]? | .hooks[] | select(.command == $prod)] | length' "$f/plugins/claude-ops/hooks/hooks.json") +if [[ "$rows" == 0 ]]; then ok "an unknown event gets no row"; else fail "unknown event got $rows rows"; fi + +test_harness::report diff --git a/scripts/sync-check-retirements.sh b/scripts/sync-check-retirements.sh index a35eb07e12..89681b1a08 100755 --- a/scripts/sync-check-retirements.sh +++ b/scripts/sync-check-retirements.sh @@ -11,9 +11,9 @@ # scripts/cross-plugin-source-registry.txt). Tests live beside the canonical copy only. # # Every plugin that ships a retirements.yaml enrolls its copy here. Carriers -# are source-control (Phase 2c) and plugin-quality (Phase 2d pilot). Every mode -# is a no-op over zero copies rather than a failure, so the gate existed -# before its first carrier did. +# are source-control (Phase 2c), plugin-quality (Phase 2d pilot) and claude-ops +# (the hook-events.jsonl move). Every mode is a no-op over zero copies rather +# than a failure, so the gate existed before its first carrier did. # # The three modes live in scripts/lib/sync-cluster.sh, shared with the sibling # sync-*.sh gates; this file supplies the check-retirements cluster's parameters. @@ -29,6 +29,7 @@ src="plugins/claude-config/lib/check-retirements.sh" copies=( plugins/source-control/lib/check-retirements.sh plugins/plugin-quality/lib/check-retirements.sh + plugins/claude-ops/lib/check-retirements.sh ) sync_cluster_manifest_strip='/lib/*' sync_cluster_noun="Canonical"