From 4ec661124a79d096179ee3e49ed0c58bf7d294f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 18:01:07 +0000 Subject: [PATCH 1/2] feat(formatters): emit data.changed so the per-session rewrote report fills The per-session observability report's "Rewrote" block reads a changed boolean the sink copies from data.changed, and no producer sent it. The shared rewrite guard now records a byte verdict at the take (HOOK_REWRITE_CHANGED: true, false, or unknown when it could not snapshot), its six carriers add the key to their telemetry data, and markdown-format and typos-format derive it from their own rewrite signals (the fix-count line, applied corrections). biome, go, powershell and ruff took the disclosure after emitting on some arms; those arms now take first, with stdout unchanged. bash-format takes unconditionally before its emit so a run where shfmt never ran reports false rather than nothing. Every data schema gains the optional key; each suite pins it on a rewriting run and a no-op run; the observability fixture carries one changed row and the case that asserted the block empty now asserts the file is named. Nine patch bumps: bash 0.7.37, biome 0.6.35, eol 0.6.36, go 0.3.39, powershell 0.7.38, ruff 0.6.36, markdown 0.11.45, typos 0.6.43, claude-ops 0.42.7. Closes #3755 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme --- .../data/bash-format.schema.json | 4 +++ .../data/biome-format.schema.json | 4 +++ .../data/eol-normalizer.schema.json | 4 +++ .../hook-telemetry/data/go-format.schema.json | 4 +++ .../data/markdown-format.schema.json | 4 +++ .../data/powershell-format.schema.json | 4 +++ .../data/ruff-format.schema.json | 4 +++ .../data/typos-format.schema.json | 4 +++ lib/rewrite-guard.sh | 34 +++++++++++++++---- lib/rewrite-guard.test.sh | 34 +++++++++++++++++++ .../bash-format/.claude-plugin/plugin.json | 2 +- plugins/bash-format/CHANGELOG.md | 14 ++++++++ plugins/bash-format/hooks/bash-format.sh | 11 +++++- plugins/bash-format/hooks/bash-format.test.sh | 28 +++++++++++++++ plugins/bash-format/hooks/rewrite-guard.sh | 34 +++++++++++++++---- .../biome-format/.claude-plugin/plugin.json | 2 +- plugins/biome-format/CHANGELOG.md | 17 ++++++++++ plugins/biome-format/hooks/biome-format.sh | 9 +++-- .../biome-format/hooks/biome-format.test.sh | 18 ++++++++++ plugins/biome-format/hooks/rewrite-guard.sh | 34 +++++++++++++++---- plugins/claude-ops/.claude-plugin/plugin.json | 2 +- plugins/claude-ops/CHANGELOG.md | 14 ++++++++ .../claude-observability.test.sh | 15 +++++--- .../observability/context/data-sources.md | 10 ++++-- .../observability/context/output-format.md | 4 ++- .../eol-normalizer/.claude-plugin/plugin.json | 2 +- plugins/eol-normalizer/CHANGELOG.md | 15 ++++++++ .../eol-normalizer/hooks/eol-normalizer.sh | 4 ++- .../hooks/eol-normalizer.test.sh | 17 ++++++++++ plugins/eol-normalizer/hooks/rewrite-guard.sh | 34 +++++++++++++++---- plugins/go-format/.claude-plugin/plugin.json | 2 +- plugins/go-format/CHANGELOG.md | 16 +++++++++ plugins/go-format/hooks/go-format.sh | 21 ++++++++---- plugins/go-format/hooks/go-format.test.sh | 17 ++++++++++ plugins/go-format/hooks/rewrite-guard.sh | 34 +++++++++++++++---- .../.claude-plugin/plugin.json | 2 +- plugins/markdown-format/CHANGELOG.md | 13 +++++++ .../markdown-format/hooks/markdown-format.sh | 13 ++++++- .../hooks/markdown-format.test.sh | 24 +++++++++++++ .../.claude-plugin/plugin.json | 2 +- plugins/powershell-format/CHANGELOG.md | 16 +++++++++ .../hooks/powershell-format.sh | 14 +++++--- .../hooks/powershell-format.test.sh | 3 ++ .../powershell-format/hooks/rewrite-guard.sh | 34 +++++++++++++++---- .../ruff-format/.claude-plugin/plugin.json | 2 +- plugins/ruff-format/CHANGELOG.md | 14 ++++++++ plugins/ruff-format/hooks/rewrite-guard.sh | 34 +++++++++++++++---- plugins/ruff-format/hooks/ruff-format.sh | 9 +++-- plugins/ruff-format/hooks/ruff-format.test.sh | 17 ++++++++++ .../typos-format/.claude-plugin/plugin.json | 2 +- plugins/typos-format/CHANGELOG.md | 13 +++++++ plugins/typos-format/hooks/typos-format.sh | 13 +++++-- .../typos-format/hooks/typos-format.test.sh | 10 ++++++ 53 files changed, 629 insertions(+), 82 deletions(-) diff --git a/docs/conventions/hook-telemetry/data/bash-format.schema.json b/docs/conventions/hook-telemetry/data/bash-format.schema.json index 57c0b360b5..09b74c09eb 100644 --- a/docs/conventions/hook-telemetry/data/bash-format.schema.json +++ b/docs/conventions/hook-telemetry/data/bash-format.schema.json @@ -19,6 +19,10 @@ "type": "array", "items": { "type": "string" }, "description": "ShellCheck findings remaining for the file, one per line. Empty array = clean." + }, + "changed": { + "type": "boolean", + "description": "Whether this run rewrote the file: true when the bytes on disk differed from the pre-run snapshot after shfmt ran, false when they were identical or no rewrite was attempted. Absent when the verdict is unknown (a skip arm before the formatter, or the snapshot could not be taken). Added in bash-format 0.7.37." } } } diff --git a/docs/conventions/hook-telemetry/data/biome-format.schema.json b/docs/conventions/hook-telemetry/data/biome-format.schema.json index 3d62a2e479..4f2c350a44 100644 --- a/docs/conventions/hook-telemetry/data/biome-format.schema.json +++ b/docs/conventions/hook-telemetry/data/biome-format.schema.json @@ -19,6 +19,10 @@ "type": "array", "items": { "type": "string" }, "description": "Biome diagnostics remaining after formatting, one per line. Empty array = clean." + }, + "changed": { + "type": "boolean", + "description": "Whether this run rewrote the file: true when the bytes on disk differed from the pre-run snapshot after Biome ran, false when they were identical. Absent when the verdict is unknown (a skip arm before the formatter, or the snapshot could not be taken). Added in biome-format 0.6.35." } } } diff --git a/docs/conventions/hook-telemetry/data/eol-normalizer.schema.json b/docs/conventions/hook-telemetry/data/eol-normalizer.schema.json index 95d3962a12..62a16707ae 100644 --- a/docs/conventions/hook-telemetry/data/eol-normalizer.schema.json +++ b/docs/conventions/hook-telemetry/data/eol-normalizer.schema.json @@ -18,6 +18,10 @@ "action": { "type": "string", "description": "Outcome for the file's line endings: \"skip\" (already canonical or not applicable) | \"lf\" (normalized to LF) | \"crlf\" (normalized to CRLF). Documented value set, not a closed enum." + }, + "changed": { + "type": "boolean", + "description": "Whether this run rewrote the file: true when the bytes on disk differed from the pre-run snapshot after normalization, false when no rewrite was attempted (no eol attribute, -text, binary) or the rewrite changed no bytes. Absent when the snapshot could not be taken. Added in eol-normalizer 0.6.36." } } } diff --git a/docs/conventions/hook-telemetry/data/go-format.schema.json b/docs/conventions/hook-telemetry/data/go-format.schema.json index 850e88a17a..d1896d8585 100644 --- a/docs/conventions/hook-telemetry/data/go-format.schema.json +++ b/docs/conventions/hook-telemetry/data/go-format.schema.json @@ -19,6 +19,10 @@ "type": "array", "items": { "type": "string" }, "description": "goimports syntax-error diagnostic lines when the file could not be parsed, one line per diagnostic. Empty array = clean or successfully autofixed (a successful format/import fix carries no findings)." + }, + "changed": { + "type": "boolean", + "description": "Whether this run rewrote the file: true when the bytes on disk differed from the pre-run snapshot after goimports ran, false when they were identical. Absent when the verdict is unknown (a skip arm before the formatter, or the snapshot could not be taken). Added in go-format 0.3.39." } } } diff --git a/docs/conventions/hook-telemetry/data/markdown-format.schema.json b/docs/conventions/hook-telemetry/data/markdown-format.schema.json index cd718165f6..07137d21b2 100644 --- a/docs/conventions/hook-telemetry/data/markdown-format.schema.json +++ b/docs/conventions/hook-telemetry/data/markdown-format.schema.json @@ -19,6 +19,10 @@ "type": "array", "items": { "type": "string" }, "description": "Unfixable markdownlint violations remaining after --fix, one per line. Empty array = clean." + }, + "changed": { + "type": "boolean", + "description": "Whether this run rewrote the file: true when markdownlint-cli2 --fix reported a non-zero count of fixes written (its \"Attempted: N fixes\" line, the same signal that drives the user-channel disclosure), false when the fix pass ran and reported none. Absent on a skip arm, where no fix pass ran. Added in markdown-format 0.11.45." } } } diff --git a/docs/conventions/hook-telemetry/data/powershell-format.schema.json b/docs/conventions/hook-telemetry/data/powershell-format.schema.json index 6bf699cf7b..68b590ff79 100644 --- a/docs/conventions/hook-telemetry/data/powershell-format.schema.json +++ b/docs/conventions/hook-telemetry/data/powershell-format.schema.json @@ -19,6 +19,10 @@ "type": "array", "items": { "type": "string" }, "description": "PSScriptAnalyzer findings remaining for the file, one per line. Empty array = clean." + }, + "changed": { + "type": "boolean", + "description": "Whether this run rewrote the file: true when the bytes on disk differed from the pre-run snapshot after Invoke-Formatter ran, false when they were identical. Absent when the verdict is unknown (a skip arm before the formatter, or the snapshot could not be taken). Added in powershell-format 0.7.38." } } } diff --git a/docs/conventions/hook-telemetry/data/ruff-format.schema.json b/docs/conventions/hook-telemetry/data/ruff-format.schema.json index eb462b601d..92bd0fcfcf 100644 --- a/docs/conventions/hook-telemetry/data/ruff-format.schema.json +++ b/docs/conventions/hook-telemetry/data/ruff-format.schema.json @@ -19,6 +19,10 @@ "type": "array", "items": { "type": "string" }, "description": "Residual Ruff diagnostics remaining after check --fix and format, one concise line per diagnostic. Empty array = clean." + }, + "changed": { + "type": "boolean", + "description": "Whether this run rewrote the file: true when the bytes on disk differed from the pre-run snapshot after the check --fix and format passes ran, false when they were identical. Absent when the verdict is unknown (a skip arm before the formatter, or the snapshot could not be taken). Added in ruff-format 0.6.36." } } } diff --git a/docs/conventions/hook-telemetry/data/typos-format.schema.json b/docs/conventions/hook-telemetry/data/typos-format.schema.json index 723808e08a..cbdaf1d988 100644 --- a/docs/conventions/hook-telemetry/data/typos-format.schema.json +++ b/docs/conventions/hook-telemetry/data/typos-format.schema.json @@ -15,6 +15,10 @@ "type": "string", "description": "Path of the checked file, relative to the consuming repo root." }, + "changed": { + "type": "boolean", + "description": "Whether this run rewrote the file: true when `applied` is non-empty (typos wrote at least one correction), false when typos ran and applied none (clean, or report-only mode). Absent on a skip arm, where typos never ran. Added in typos-format 0.6.43." + }, "applied": { "type": "array", "description": "Corrections this run wrote into the file. Empty array = nothing was modified (clean, or the hook is in report-only mode).", diff --git a/lib/rewrite-guard.sh b/lib/rewrite-guard.sh index 329acccdf5..8dd945f442 100644 --- a/lib/rewrite-guard.sh +++ b/lib/rewrite-guard.sh @@ -51,7 +51,14 @@ readonly _HOOK_REWRITE_GUARD_LOADED=1 _HOOK_REWRITE_BEFORE="" _HOOK_REWRITE_PREV_EXIT_TRAP="" +_HOOK_REWRITE_SNAPSHOT_FAILED=0 HOOK_REWRITE_MESSAGE="" +# The byte verdict of the take, for the telemetry `data.changed` key (#3755): +# "true" when the file differs from the snapshot, "false" when it is identical +# or no rewrite was ever attempted (begin never ran), and "" when the answer +# is unknown because begin could not snapshot. A producer sends the key only +# when the verdict is known, so an unknown never reads as "not rewritten". +HOOK_REWRITE_CHANGED="" # The EXIT handler begin installs: release the snapshot, then run whatever # EXIT handler the caller had armed before begin (captured below). The @@ -72,11 +79,14 @@ hook::_rewrite_guard_on_exit() { # hook::rewrite_guard_begin "$FILE" hook::rewrite_guard_begin() { _HOOK_REWRITE_BEFORE="" + _HOOK_REWRITE_SNAPSHOT_FAILED=1 HOOK_REWRITE_MESSAGE="" + HOOK_REWRITE_CHANGED="" local snap="" if snap=$(mktemp 2>/dev/null); then if cp "$1" "$snap" 2>/dev/null; then _HOOK_REWRITE_BEFORE="$snap" + _HOOK_REWRITE_SNAPSHOT_FAILED=0 # Capture the caller's current EXIT handler so ours can chain it. # `trap -p EXIT` prints `trap -- '' EXIT`; strip the frame # and eval the remaining shell-quoted literal back into a plain string. @@ -101,18 +111,30 @@ hook::rewrite_guard_begin() { } # Compare against the snapshot, record in -# HOOK_REWRITE_MESSAGE when it changed (empty otherwise), and release the -# snapshot. Destructive read — see the lifecycle block above. The caller -# passes HOOK_REWRITE_MESSAGE as the systemMessage argument of its ONE -# hook::emit_channels call, so a run that both rewrote and found things puts -# both channels in one JSON document. +# HOOK_REWRITE_MESSAGE when it changed (empty otherwise), set the +# HOOK_REWRITE_CHANGED verdict, and release the snapshot. Destructive read for +# the message — see the lifecycle block above; the verdict of the first take +# after begin survives a later take, so a producer that emits telemetry after +# its take still reads the answer. The caller passes HOOK_REWRITE_MESSAGE as +# the systemMessage argument of its ONE hook::emit_channels call, so a run +# that both rewrote and found things puts both channels in one JSON document. # hook::rewrite_take_disclosure "$FILE" "my-plugin: reformatted $(basename "$FILE") via tool." hook::rewrite_take_disclosure() { local file="$1" message="$2" HOOK_REWRITE_MESSAGE="" - [[ -n "$_HOOK_REWRITE_BEFORE" ]] || return 0 + if [[ -z "$_HOOK_REWRITE_BEFORE" ]]; then + # No snapshot: either begin never ran (no rewrite attempted, so the file + # is unchanged by this hook) or it ran and could not snapshot (unknown). + if [[ -z "$HOOK_REWRITE_CHANGED" && "$_HOOK_REWRITE_SNAPSHOT_FAILED" -eq 0 ]]; then + HOOK_REWRITE_CHANGED="false" + fi + return 0 + fi if ! cmp -s "$_HOOK_REWRITE_BEFORE" "$file" 2>/dev/null; then HOOK_REWRITE_MESSAGE="$message" + HOOK_REWRITE_CHANGED="true" + else + HOOK_REWRITE_CHANGED="false" fi rm -f "$_HOOK_REWRITE_BEFORE" _HOOK_REWRITE_BEFORE="" diff --git a/lib/rewrite-guard.test.sh b/lib/rewrite-guard.test.sh index cc56eb4e6a..58596eb10c 100755 --- a/lib/rewrite-guard.test.sh +++ b/lib/rewrite-guard.test.sh @@ -60,9 +60,19 @@ if [[ "$(scratch_count)" == "0" ]]; then else fail "take left $(scratch_count) file(s) behind (unchanged path)" fi +if [[ "$HOOK_REWRITE_CHANGED" == "false" ]]; then + ok "take on unchanged file sets the changed verdict to false" +else + fail "take on unchanged file set HOOK_REWRITE_CHANGED='$HOOK_REWRITE_CHANGED'" +fi # --- changed file: message set, snapshot released ---------------------------- hook::rewrite_guard_begin "$target" +if [[ -z "$HOOK_REWRITE_CHANGED" ]]; then + ok "begin resets the changed verdict to unknown" +else + fail "begin left HOOK_REWRITE_CHANGED='$HOOK_REWRITE_CHANGED'" +fi printf 'two\n' >"$target" hook::rewrite_take_disclosure "$target" "msg-changed" if [[ "$HOOK_REWRITE_MESSAGE" == "msg-changed" ]]; then @@ -70,6 +80,11 @@ if [[ "$HOOK_REWRITE_MESSAGE" == "msg-changed" ]]; then else fail "take on changed file yielded '$HOOK_REWRITE_MESSAGE'" fi +if [[ "$HOOK_REWRITE_CHANGED" == "true" ]]; then + ok "take on changed file sets the changed verdict to true" +else + fail "take on changed file set HOOK_REWRITE_CHANGED='$HOOK_REWRITE_CHANGED'" +fi if [[ "$(scratch_count)" == "0" ]]; then ok "take releases the snapshot (changed path)" else @@ -83,6 +98,20 @@ if [[ -z "$HOOK_REWRITE_MESSAGE" ]]; then else fail "second take yielded '$HOOK_REWRITE_MESSAGE'" fi +if [[ "$HOOK_REWRITE_CHANGED" == "true" ]]; then + ok "second take keeps the first take's changed verdict" +else + fail "second take reset HOOK_REWRITE_CHANGED to '$HOOK_REWRITE_CHANGED'" +fi + +# --- take with no begin at all: nothing was attempted, so not changed -------- +HOOK_REWRITE_CHANGED="" +hook::rewrite_take_disclosure "$target" "msg-never-armed" +if [[ -z "$HOOK_REWRITE_MESSAGE" && "$HOOK_REWRITE_CHANGED" == "false" ]]; then + ok "take without begin yields empty message and a false verdict" +else + fail "take without begin yielded message='$HOOK_REWRITE_MESSAGE' changed='$HOOK_REWRITE_CHANGED'" +fi # --- exit without take: the EXIT trap releases the snapshot ------------------ # The #3401/#3405 leak class: an arm that exits without releasing. Run in a @@ -172,6 +201,11 @@ if [[ -z "$HOOK_REWRITE_MESSAGE" ]]; then else fail "take after failed snapshot yielded '$HOOK_REWRITE_MESSAGE'" fi +if [[ -z "$HOOK_REWRITE_CHANGED" ]]; then + ok "take after failed snapshot leaves the changed verdict unknown" +else + fail "take after failed snapshot set HOOK_REWRITE_CHANGED='$HOOK_REWRITE_CHANGED'" +fi # --- disclose emits one systemMessage-only document on change ---------------- hook::rewrite_guard_begin "$target" diff --git a/plugins/bash-format/.claude-plugin/plugin.json b/plugins/bash-format/.claude-plugin/plugin.json index f7f9f4a5af..682b6e75bb 100644 --- a/plugins/bash-format/.claude-plugin/plugin.json +++ b/plugins/bash-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "bash-format", - "version": "0.7.36", + "version": "0.7.37", "description": "Auto-format and lint shell scripts on edit via shfmt + ShellCheck, using the consuming repo's own .editorconfig and .shellcheckrc.", "author": { "name": "Melodic Software", diff --git a/plugins/bash-format/CHANGELOG.md b/plugins/bash-format/CHANGELOG.md index b3b159071d..eb58f0586f 100644 --- a/plugins/bash-format/CHANGELOG.md +++ b/plugins/bash-format/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to the `bash-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.7.37] + +### Added + +- **Telemetry `data.changed`.** The envelope's `data` carries `changed: true|false`, + the byte verdict the shared rewrite guard already takes for the user-channel + disclosure: true when shfmt rewrote the file, false when the bytes were + identical or no format was attempted. The key is omitted, never guessed, on a + skip arm before the formatter and when the snapshot could not be taken. This + is what fills the per-session observability report's "Rewrote" block (#3755). + `docs/conventions/hook-telemetry/data/bash-format.schema.json` gains the + optional key, and the suite pins it on a reformatting run and a no-op run. + Carries the synced `rewrite-guard.sh` that records the verdict. + ## [0.7.36] ### Changed diff --git a/plugins/bash-format/hooks/bash-format.sh b/plugins/bash-format/hooks/bash-format.sh index 4b758bfa78..789a1b31d6 100755 --- a/plugins/bash-format/hooks/bash-format.sh +++ b/plugins/bash-format/hooks/bash-format.sh @@ -98,7 +98,9 @@ build_data_json() { --arg tool "$TOOL" \ --arg file "$FILE_REL" \ --argjson findings "$1" \ - '{tool:$tool,file:$file,findings:$findings}' 2>/dev/null || + --arg changed "${HOOK_REWRITE_CHANGED:-}" \ + '{tool:$tool,file:$file,findings:$findings} + + (if $changed == "" then {} else {changed: ($changed == "true")} end)' 2>/dev/null || printf '{"tool":"","file":"","findings":[]}' } @@ -303,6 +305,13 @@ if [[ -n "$NOTICE" ]]; then fi hook::emit_channels PostToolUse "$CTX" "$SYSMSG" +# Settle the data.changed verdict for the telemetry emit below. On a run where +# shfmt formatted, the take inside that branch already recorded it and this +# call keeps it; on a run where shfmt never ran (no .editorconfig opt-in, no +# binary), no snapshot was ever taken and the verdict is false: this hook did +# not rewrite the file. The message this resets was consumed above. +hook::rewrite_take_disclosure "$FILE" "" + status="ok" [[ $ran_any -eq 0 ]] && status="skipped" emit_tel "$status" "$FINDINGS_JSON" diff --git a/plugins/bash-format/hooks/bash-format.test.sh b/plugins/bash-format/hooks/bash-format.test.sh index 8ba5cb03a3..52136edeee 100755 --- a/plugins/bash-format/hooks/bash-format.test.sh +++ b/plugins/bash-format/hooks/bash-format.test.sh @@ -543,6 +543,8 @@ if [[ -s "$TEL" ]]; then if [[ "$(jq -r '.schema_version' "$TEL")" == "1.0" ]]; then ok "envelope: schema_version 1.0"; else fail "envelope: schema_version=$(jq -r '.schema_version' "$TEL")"; fi if [[ "$(jq '.data.findings | length' "$TEL")" -ge 1 ]]; then ok "envelope: findings populated"; else fail "envelope: findings empty ($(jq '.data.findings' "$TEL"))"; fi if jq -e '.data.findings | any(test("SC2154"))' "$TEL" >/dev/null 2>&1; then ok "envelope: findings name SC2154"; else fail "envelope: findings missing SC2154 ($(jq '.data.findings' "$TEL"))"; fi + # No .editorconfig opt-in in this repo, so shfmt never ran: the hook did not rewrite the file. + if [[ "$(jq -r '.data.changed' "$TEL")" == "false" ]]; then ok "envelope: data.changed false (shfmt gate off, nothing rewritten)"; else fail "envelope: data.changed=$(jq -c '.data.changed' "$TEL")"; fi FREL=$(jq -r '.data.file' "$TEL") if [[ -n "$FREL" && "$FREL" != /* && "$FREL" != ?:* ]]; then ok "envelope: data.file repo-relative ($FREL)"; else fail "envelope: data.file not repo-relative: $FREL"; fi if jq -e '.duration_ms | type == "number" and . >= 0 and floor == .' "$TEL" >/dev/null 2>&1; then ok "envelope: duration_ms non-negative int"; else fail "envelope: duration_ms invalid ($(jq .duration_ms "$TEL"))"; fi @@ -564,6 +566,32 @@ else fi rm -f "$TELC" +# --- Stub sink + shfmt rewrite -> data.changed true (#3755) ------------------- +if [[ $HAVE_SHFMT -eq 1 ]]; then + printf '#!/usr/bin/env bash\nif true; then\necho tel\nfi\n' >"$REPO_YES/src/tel-fmt.sh" + TELF="$(mktemp)" + SINKF="$(make_sink "cat >\"$TELF\"")" + run_hook_env "$REPO_YES/src/tel-fmt.sh" CLAUDE_PLUGIN_OPTION_BASH_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$SINKF" >/dev/null + wait_for_sink "$TELF" + if [[ -s "$TELF" ]]; then + if [[ "$(jq -r '.data.changed' "$TELF")" == "true" ]]; then ok "telemetry/rewrite: data.changed true after shfmt reindented the file"; else fail "telemetry/rewrite: data.changed=$(jq -c '.data.changed' "$TELF")"; fi + else + fail "telemetry/rewrite: no envelope written" + fi + rm -f "$TELF" + # The same file again is already formatted: shfmt ran and changed no bytes. + TELF2="$(mktemp)" + SINKF2="$(make_sink "cat >\"$TELF2\"")" + run_hook_env "$REPO_YES/src/tel-fmt.sh" CLAUDE_PLUGIN_OPTION_BASH_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$SINKF2" >/dev/null + wait_for_sink "$TELF2" + if [[ -s "$TELF2" ]]; then + if [[ "$(jq -r '.data.changed' "$TELF2")" == "false" ]]; then ok "telemetry/rewrite: data.changed false when shfmt ran and changed nothing"; else fail "telemetry/rewrite: second run data.changed=$(jq -c '.data.changed' "$TELF2")"; fi + else + fail "telemetry/rewrite: no envelope written on the second run" + fi + rm -f "$TELF2" +fi + # --- Missing-tool visibility (dim-9 doctrine) -------------------------------- # Fake-bin dir of exec wrappers so individual tools can be removed from PATH # without losing the coreutils the hook and the notice dedup need. diff --git a/plugins/bash-format/hooks/rewrite-guard.sh b/plugins/bash-format/hooks/rewrite-guard.sh index 329acccdf5..8dd945f442 100644 --- a/plugins/bash-format/hooks/rewrite-guard.sh +++ b/plugins/bash-format/hooks/rewrite-guard.sh @@ -51,7 +51,14 @@ readonly _HOOK_REWRITE_GUARD_LOADED=1 _HOOK_REWRITE_BEFORE="" _HOOK_REWRITE_PREV_EXIT_TRAP="" +_HOOK_REWRITE_SNAPSHOT_FAILED=0 HOOK_REWRITE_MESSAGE="" +# The byte verdict of the take, for the telemetry `data.changed` key (#3755): +# "true" when the file differs from the snapshot, "false" when it is identical +# or no rewrite was ever attempted (begin never ran), and "" when the answer +# is unknown because begin could not snapshot. A producer sends the key only +# when the verdict is known, so an unknown never reads as "not rewritten". +HOOK_REWRITE_CHANGED="" # The EXIT handler begin installs: release the snapshot, then run whatever # EXIT handler the caller had armed before begin (captured below). The @@ -72,11 +79,14 @@ hook::_rewrite_guard_on_exit() { # hook::rewrite_guard_begin "$FILE" hook::rewrite_guard_begin() { _HOOK_REWRITE_BEFORE="" + _HOOK_REWRITE_SNAPSHOT_FAILED=1 HOOK_REWRITE_MESSAGE="" + HOOK_REWRITE_CHANGED="" local snap="" if snap=$(mktemp 2>/dev/null); then if cp "$1" "$snap" 2>/dev/null; then _HOOK_REWRITE_BEFORE="$snap" + _HOOK_REWRITE_SNAPSHOT_FAILED=0 # Capture the caller's current EXIT handler so ours can chain it. # `trap -p EXIT` prints `trap -- '' EXIT`; strip the frame # and eval the remaining shell-quoted literal back into a plain string. @@ -101,18 +111,30 @@ hook::rewrite_guard_begin() { } # Compare against the snapshot, record in -# HOOK_REWRITE_MESSAGE when it changed (empty otherwise), and release the -# snapshot. Destructive read — see the lifecycle block above. The caller -# passes HOOK_REWRITE_MESSAGE as the systemMessage argument of its ONE -# hook::emit_channels call, so a run that both rewrote and found things puts -# both channels in one JSON document. +# HOOK_REWRITE_MESSAGE when it changed (empty otherwise), set the +# HOOK_REWRITE_CHANGED verdict, and release the snapshot. Destructive read for +# the message — see the lifecycle block above; the verdict of the first take +# after begin survives a later take, so a producer that emits telemetry after +# its take still reads the answer. The caller passes HOOK_REWRITE_MESSAGE as +# the systemMessage argument of its ONE hook::emit_channels call, so a run +# that both rewrote and found things puts both channels in one JSON document. # hook::rewrite_take_disclosure "$FILE" "my-plugin: reformatted $(basename "$FILE") via tool." hook::rewrite_take_disclosure() { local file="$1" message="$2" HOOK_REWRITE_MESSAGE="" - [[ -n "$_HOOK_REWRITE_BEFORE" ]] || return 0 + if [[ -z "$_HOOK_REWRITE_BEFORE" ]]; then + # No snapshot: either begin never ran (no rewrite attempted, so the file + # is unchanged by this hook) or it ran and could not snapshot (unknown). + if [[ -z "$HOOK_REWRITE_CHANGED" && "$_HOOK_REWRITE_SNAPSHOT_FAILED" -eq 0 ]]; then + HOOK_REWRITE_CHANGED="false" + fi + return 0 + fi if ! cmp -s "$_HOOK_REWRITE_BEFORE" "$file" 2>/dev/null; then HOOK_REWRITE_MESSAGE="$message" + HOOK_REWRITE_CHANGED="true" + else + HOOK_REWRITE_CHANGED="false" fi rm -f "$_HOOK_REWRITE_BEFORE" _HOOK_REWRITE_BEFORE="" diff --git a/plugins/biome-format/.claude-plugin/plugin.json b/plugins/biome-format/.claude-plugin/plugin.json index bb77ee6023..28ea15ac2c 100644 --- a/plugins/biome-format/.claude-plugin/plugin.json +++ b/plugins/biome-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "biome-format", - "version": "0.6.34", + "version": "0.6.35", "description": "Auto-format and lint JS/TS/JSX/JSON on edit via Biome, only when a biome.json governs the repo — using the consuming repo's own Biome config.", "author": { "name": "Melodic Software", diff --git a/plugins/biome-format/CHANGELOG.md b/plugins/biome-format/CHANGELOG.md index 6d0a93b3d6..fbaa764300 100644 --- a/plugins/biome-format/CHANGELOG.md +++ b/plugins/biome-format/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to the `biome-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.35] + +### Added + +- **Telemetry `data.changed`.** The envelope's `data` carries `changed: true|false`, + the byte verdict the shared rewrite guard already takes for the user-channel + disclosure: true when Biome rewrote the file, false when the bytes were + identical. The key is omitted, never guessed, on a skip arm before the + formatter and when the snapshot could not be taken. This is what fills the + per-session observability report's "Rewrote" block (#3755). The clean arm now + takes the disclosure before it emits telemetry so the verdict is known when + the envelope is built; stdout is unchanged (one systemMessage-only document + on a rewrite, nothing otherwise). + `docs/conventions/hook-telemetry/data/biome-format.schema.json` gains the + optional key, and the suite pins it on a reformatting run and a no-op run. + Carries the synced `rewrite-guard.sh` that records the verdict. + ## [0.6.34] ### Changed diff --git a/plugins/biome-format/hooks/biome-format.sh b/plugins/biome-format/hooks/biome-format.sh index 2230cce2c6..1317acc588 100755 --- a/plugins/biome-format/hooks/biome-format.sh +++ b/plugins/biome-format/hooks/biome-format.sh @@ -102,7 +102,9 @@ build_data_json() { --arg tool "$TOOL" \ --arg file "$FILE_REL" \ --argjson findings "$1" \ - '{tool:$tool,file:$file,findings:$findings}' 2>/dev/null || + --arg changed "${HOOK_REWRITE_CHANGED:-}" \ + '{tool:$tool,file:$file,findings:$findings} + + (if $changed == "" then {} else {changed: ($changed == "true")} end)' 2>/dev/null || printf '{"tool":"","file":"","findings":[]}' } @@ -211,8 +213,11 @@ BIOME_REWRITE_MESSAGE="biome-format: auto-fixed and/or reformatted $(basename "$ hook::rewrite_guard_begin "$FILE" if OUTPUT=$(cd "$CONFIG_DIR" && env -u BIOME_CONFIG_PATH "$BIOME_BIN" check --write --error-on-warnings --reporter=github "$BIOME_ARG" 2>&1); then + # Take before the telemetry emit so data.changed carries the byte verdict; + # the disclosure itself is still one systemMessage-only document, or nothing. + hook::rewrite_take_disclosure "$FILE" "$BIOME_REWRITE_MESSAGE" emit_tel "ok" '[]' - hook::rewrite_disclose PostToolUse "$FILE" "$BIOME_REWRITE_MESSAGE" + [[ -z "$HOOK_REWRITE_MESSAGE" ]] || hook::emit_channels PostToolUse "" "$HOOK_REWRITE_MESSAGE" exit 0 fi diff --git a/plugins/biome-format/hooks/biome-format.test.sh b/plugins/biome-format/hooks/biome-format.test.sh index 9bb6d1c86e..b9be9290ce 100755 --- a/plugins/biome-format/hooks/biome-format.test.sh +++ b/plugins/biome-format/hooks/biome-format.test.sh @@ -388,6 +388,8 @@ if [[ -s "$TEL" ]]; then if [[ "$(jq -r '.status' "$TEL")" == "ok" ]]; then ok "envelope: status ok"; else fail "envelope: status=$(jq -r '.status' "$TEL")"; fi if [[ "$(jq -r '.schema_version' "$TEL")" == "1.0" ]]; then ok "envelope: schema_version 1.0"; else fail "envelope: schema_version=$(jq -r '.schema_version' "$TEL")"; fi if [[ "$(jq '.data.findings | length' "$TEL")" -ge 1 ]]; then ok "envelope: findings populated"; else fail "envelope: findings empty ($(jq '.data.findings' "$TEL"))"; fi + # An unused binding is a finding, not a fix, and the line is already formatted: no bytes moved. + if [[ "$(jq -r '.data.changed' "$TEL")" == "false" ]]; then ok "envelope: data.changed false (nothing rewritten)"; else fail "envelope: data.changed=$(jq -c '.data.changed' "$TEL")"; fi FREL=$(jq -r '.data.file' "$TEL") if [[ -n "$FREL" && "$FREL" != /* && "$FREL" != ?:* ]]; then ok "envelope: data.file repo-relative ($FREL)"; else fail "envelope: data.file not repo-relative: $FREL"; fi if jq -e '.duration_ms | type == "number" and . >= 0 and floor == .' "$TEL" >/dev/null 2>&1; then ok "envelope: duration_ms non-negative int"; else fail "envelope: duration_ms invalid ($(jq .duration_ms "$TEL"))"; fi @@ -396,6 +398,22 @@ else fi rm -f "$TEL" +# --- Stub sink + format rewrite -> data.changed true (#3755) ---------------- +# Exported, so no unused-binding finding; the missing spaces and semicolon are +# what Biome's formatter rewrites. +printf 'export const telFmt=1\n' >"$REPO/tel-fmt.ts" +TELF="$(mktemp)" +SINKF="$(make_sink "cat >\"$TELF\"")" +OUT_F=$(run_hook_env "$REPO/tel-fmt.ts" CLAUDE_PLUGIN_OPTION_BIOME_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$SINKF") +wait_for_sink "$TELF" +if [[ -s "$TELF" ]]; then + if [[ "$(jq -r '.data.changed' "$TELF")" == "true" ]]; then ok "telemetry/rewrite: data.changed true after Biome reformatted the file"; else fail "telemetry/rewrite: data.changed=$(jq -c '.data.changed' "$TELF") status=$(jq -r '.status' "$TELF")"; fi + if [[ "$OUT_F" == *'"systemMessage"'* ]]; then ok "telemetry/rewrite: the disclosure still reaches stdout"; else fail "telemetry/rewrite: disclosure missing from stdout: $OUT_F"; fi +else + fail "telemetry/rewrite: no envelope written" +fi +rm -f "$TELF" + # --- Stub sink + gate OFF -> status skipped --------------------------------- printf 'const s=1;var t=2\n' >"$REPO_NO/tel2.ts" TELS="$(mktemp)" diff --git a/plugins/biome-format/hooks/rewrite-guard.sh b/plugins/biome-format/hooks/rewrite-guard.sh index 329acccdf5..8dd945f442 100644 --- a/plugins/biome-format/hooks/rewrite-guard.sh +++ b/plugins/biome-format/hooks/rewrite-guard.sh @@ -51,7 +51,14 @@ readonly _HOOK_REWRITE_GUARD_LOADED=1 _HOOK_REWRITE_BEFORE="" _HOOK_REWRITE_PREV_EXIT_TRAP="" +_HOOK_REWRITE_SNAPSHOT_FAILED=0 HOOK_REWRITE_MESSAGE="" +# The byte verdict of the take, for the telemetry `data.changed` key (#3755): +# "true" when the file differs from the snapshot, "false" when it is identical +# or no rewrite was ever attempted (begin never ran), and "" when the answer +# is unknown because begin could not snapshot. A producer sends the key only +# when the verdict is known, so an unknown never reads as "not rewritten". +HOOK_REWRITE_CHANGED="" # The EXIT handler begin installs: release the snapshot, then run whatever # EXIT handler the caller had armed before begin (captured below). The @@ -72,11 +79,14 @@ hook::_rewrite_guard_on_exit() { # hook::rewrite_guard_begin "$FILE" hook::rewrite_guard_begin() { _HOOK_REWRITE_BEFORE="" + _HOOK_REWRITE_SNAPSHOT_FAILED=1 HOOK_REWRITE_MESSAGE="" + HOOK_REWRITE_CHANGED="" local snap="" if snap=$(mktemp 2>/dev/null); then if cp "$1" "$snap" 2>/dev/null; then _HOOK_REWRITE_BEFORE="$snap" + _HOOK_REWRITE_SNAPSHOT_FAILED=0 # Capture the caller's current EXIT handler so ours can chain it. # `trap -p EXIT` prints `trap -- '' EXIT`; strip the frame # and eval the remaining shell-quoted literal back into a plain string. @@ -101,18 +111,30 @@ hook::rewrite_guard_begin() { } # Compare against the snapshot, record in -# HOOK_REWRITE_MESSAGE when it changed (empty otherwise), and release the -# snapshot. Destructive read — see the lifecycle block above. The caller -# passes HOOK_REWRITE_MESSAGE as the systemMessage argument of its ONE -# hook::emit_channels call, so a run that both rewrote and found things puts -# both channels in one JSON document. +# HOOK_REWRITE_MESSAGE when it changed (empty otherwise), set the +# HOOK_REWRITE_CHANGED verdict, and release the snapshot. Destructive read for +# the message — see the lifecycle block above; the verdict of the first take +# after begin survives a later take, so a producer that emits telemetry after +# its take still reads the answer. The caller passes HOOK_REWRITE_MESSAGE as +# the systemMessage argument of its ONE hook::emit_channels call, so a run +# that both rewrote and found things puts both channels in one JSON document. # hook::rewrite_take_disclosure "$FILE" "my-plugin: reformatted $(basename "$FILE") via tool." hook::rewrite_take_disclosure() { local file="$1" message="$2" HOOK_REWRITE_MESSAGE="" - [[ -n "$_HOOK_REWRITE_BEFORE" ]] || return 0 + if [[ -z "$_HOOK_REWRITE_BEFORE" ]]; then + # No snapshot: either begin never ran (no rewrite attempted, so the file + # is unchanged by this hook) or it ran and could not snapshot (unknown). + if [[ -z "$HOOK_REWRITE_CHANGED" && "$_HOOK_REWRITE_SNAPSHOT_FAILED" -eq 0 ]]; then + HOOK_REWRITE_CHANGED="false" + fi + return 0 + fi if ! cmp -s "$_HOOK_REWRITE_BEFORE" "$file" 2>/dev/null; then HOOK_REWRITE_MESSAGE="$message" + HOOK_REWRITE_CHANGED="true" + else + HOOK_REWRITE_CHANGED="false" fi rm -f "$_HOOK_REWRITE_BEFORE" _HOOK_REWRITE_BEFORE="" diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index f9ec64c8d4..91df4d9c61 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.42.6", + "version": "0.42.7", "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", diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 07fc5e84ef..c5e34f9549 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,20 @@ 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.7] + +### Changed + +- **Per-session report: the "Rewrote" block is live.** The eight rewriting + formatters (bash, biome, eol-normalizer, go, markdown, powershell, ruff, + typos) now send `data.changed`, which the reference sink already copied to + the per-session row when present. The observability skill's context renders + a row per `changed == true` envelope, `_nothing rewritten_` when rows carry + the key and every value is false, and the no-data line only when no row in + the session carries it. The suite's per-session fixture carries one + `changed: true` row and one `changed: false` row, and the case that asserted + the block empty now asserts the rewritten file is named (#3755). + ## [0.42.6] ### Added diff --git a/plugins/claude-ops/skills/observability/claude-observability.test.sh b/plugins/claude-ops/skills/observability/claude-observability.test.sh index 3e22b87f0f..b612fbf542 100755 --- a/plugins/claude-ops/skills/observability/claude-observability.test.sh +++ b/plugins/claude-ops/skills/observability/claude-observability.test.sh @@ -59,10 +59,11 @@ 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" \ + --arg subject "$6" --arg status "$7" --arg changed "${8:-}" \ '{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"}' \ + exit_code:$exit_code, subject:$subject, tool:"Write"} + + (if $changed == "" then {} else {changed: ($changed == "true")} end)' \ >>"$SESSION_LOG" } # Append one per-session event-log row (session-event-log.sh's shape). No hook. @@ -99,8 +100,8 @@ emit_event "2025-01-01T00:00:00.000Z" old-hook Write 100 0 old.sh success # 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:01:00.000Z" bash-format PostToolUse 130 0 s.sh success false +emit_session_event "2026-04-29T12:02:00.000Z" bash-format PostToolUse 140 0 t.sh success true 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 @@ -174,7 +175,11 @@ assert_eq "per-session: one blocked row" "1" "$(printf '%s\n' "$BLOCKED" | grep 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" +assert_eq "per-session: one rewrote row, the run whose producer sent changed=true" "1" "$(printf '%s\n' "$REWROTE" | grep -c .)" +assert_contains "per-session: rewrote row names the rewritten file" "$REWROTE" "t.sh" +assert_not_contains "per-session: a changed=false run is not a rewrite" "$REWROTE" "s.sh" +assert_eq "per-session: rows carrying the key are distinguishable from rows without it" "2" \ + "$(jq -s "$HOOK_NORM"' | map(select(has("changed"))) | length' "${SESSION_FILES[0]}")" TIMELINE=$(jq -sr '.[] | select(.source == "event-log") | [.ts, .hook_event_name, .category, (.tool_name // ""), (.file_path // ""), (.agent_id // "")] diff --git a/plugins/claude-ops/skills/observability/context/data-sources.md b/plugins/claude-ops/skills/observability/context/data-sources.md index fee8856f0c..62814ab3ba 100644 --- a/plugins/claude-ops/skills/observability/context/data-sources.md +++ b/plugins/claude-ops/skills/observability/context/data-sources.md @@ -169,9 +169,13 @@ 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". +**Rewrote:** what a formatter changed. `changed` is the per-row boolean the sink copies from a +producer's `data.changed`; the eight rewriting formatters (bash, biome, eol-normalizer, go, +markdown, powershell, ruff, typos) send it on every run that reached the formatter, so a row with +`changed == true` is a file the hook rewrote. A session whose envelope rows all predate those +producer versions, or whose formatters all stopped before the formatter ran, has no such rows; +render that as `_no data — no producer in this session reported a rewrite verdict_` when no row +carries the key at all, and as `_nothing rewritten_` when rows carry it and every value is false. ```bash jq -sc "$HOOK_NORM"' | .[] | select(.changed == true) diff --git a/plugins/claude-ops/skills/observability/context/output-format.md b/plugins/claude-ops/skills/observability/context/output-format.md index cda07cf921..f7485fed12 100644 --- a/plugins/claude-ops/skills/observability/context/output-format.md +++ b/plugins/claude-ops/skills/observability/context/output-format.md @@ -147,7 +147,9 @@ emit `data.session_id`; other hooks appear only in the whole-root report. ## Rewrote -- (or) `_no data — no producer reports rewrites yet_` +- `` `` — `` +- (or) `_nothing rewritten_` when rows carry `changed` and every value is false +- (or) `_no data — no producer in this session reported a rewrite verdict_` when no row carries it ## Event timeline diff --git a/plugins/eol-normalizer/.claude-plugin/plugin.json b/plugins/eol-normalizer/.claude-plugin/plugin.json index 7e38371f9d..00464aaa85 100644 --- a/plugins/eol-normalizer/.claude-plugin/plugin.json +++ b/plugins/eol-normalizer/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "eol-normalizer", - "version": "0.6.35", + "version": "0.6.36", "description": "Normalize a written file's working-tree line endings to its .gitattributes eol value on edit \u2014 symmetric CRLF/LF driven by git check-attr, advisory and never blocking.", "author": { "name": "Melodic Software", diff --git a/plugins/eol-normalizer/CHANGELOG.md b/plugins/eol-normalizer/CHANGELOG.md index 091dc1d909..3f698332a0 100644 --- a/plugins/eol-normalizer/CHANGELOG.md +++ b/plugins/eol-normalizer/CHANGELOG.md @@ -3,6 +3,21 @@ All notable changes to the `eol-normalizer` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.36] + +### Added + +- **Telemetry `data.changed`.** The envelope's `data` carries `changed: true|false`, + the byte verdict the shared rewrite guard already takes for the user-channel + disclosure: true when the line endings were rewritten, false when no rewrite + was attempted (no `eol` attribute, `-text`, binary) or the rewrite changed no + bytes. `action` keeps its meaning (the arm that applies to the file). The key + is omitted only when the snapshot could not be taken. This is what fills the + per-session observability report's "Rewrote" block (#3755). + `docs/conventions/hook-telemetry/data/eol-normalizer.schema.json` gains the + optional key, and the suite pins it on a normalizing run and a no-op run. + Carries the synced `rewrite-guard.sh` that records the verdict. + ## [0.6.35] ### Changed diff --git a/plugins/eol-normalizer/hooks/eol-normalizer.sh b/plugins/eol-normalizer/hooks/eol-normalizer.sh index 18650f5c0b..639d107d1b 100755 --- a/plugins/eol-normalizer/hooks/eol-normalizer.sh +++ b/plugins/eol-normalizer/hooks/eol-normalizer.sh @@ -116,7 +116,9 @@ build_data_json() { --arg tool "$TOOL" \ --arg file "$FILE_REL" \ --arg action "$1" \ - '{tool:$tool,file:$file,action:$action}' 2>/dev/null || + --arg changed "${HOOK_REWRITE_CHANGED:-}" \ + '{tool:$tool,file:$file,action:$action} + + (if $changed == "" then {} else {changed: ($changed == "true")} end)' 2>/dev/null || printf '{"tool":"","file":"","action":""}' } diff --git a/plugins/eol-normalizer/hooks/eol-normalizer.test.sh b/plugins/eol-normalizer/hooks/eol-normalizer.test.sh index 5e6a7f429d..e8b54cba9d 100755 --- a/plugins/eol-normalizer/hooks/eol-normalizer.test.sh +++ b/plugins/eol-normalizer/hooks/eol-normalizer.test.sh @@ -221,6 +221,7 @@ if [[ -s "$TEL" ]]; then if [[ "$(jq -r '.status' "$TEL")" == "ok" ]]; then ok "envelope: status ok"; else fail "envelope: status=$(jq -r '.status' "$TEL")"; fi if [[ "$(jq -r '.schema_version' "$TEL")" == "1.0" ]]; then ok "envelope: schema_version 1.0"; else fail "envelope: schema_version=$(jq -r '.schema_version' "$TEL")"; fi if [[ "$(jq -r '.data.action' "$TEL")" == "lf" ]]; then ok "envelope: data.action lf"; else fail "envelope: data.action=$(jq -r '.data.action' "$TEL")"; fi + if [[ "$(jq -r '.data.changed' "$TEL")" == "true" ]]; then ok "envelope: data.changed true (CRLF file was rewritten to LF)"; else fail "envelope: data.changed=$(jq -c '.data.changed' "$TEL")"; fi FREL=$(jq -r '.data.file' "$TEL") if [[ -n "$FREL" && "$FREL" != /* && "$FREL" != ?:* ]]; then ok "envelope: data.file repo-relative ($FREL)"; else fail "envelope: data.file not repo-relative: $FREL"; fi if jq -e '.duration_ms | type == "number" and . >= 0 and floor == .' "$TEL" >/dev/null 2>&1; then ok "envelope: duration_ms non-negative int"; else fail "envelope: duration_ms invalid ($(jq .duration_ms "$TEL"))"; fi @@ -229,6 +230,22 @@ else fi rm -f "$TEL" +# --- Stub sink + already-LF .sh -> action lf, data.changed false (#3755) ------ +# The attribute still resolves to lf (action names the arm that applies), but +# the plan finds no work, no snapshot is armed, and the verdict is "not changed". +printf 'echo already\n' >"$REPO/tel3.sh" +TEL3="$(mktemp)" +SINK3="$(make_sink "cat >\"$TEL3\"")" +run_hook_env "$REPO/tel3.sh" CLAUDE_PLUGIN_OPTION_EOL_NORMALIZER_ENABLED=true HOOK_TELEMETRY_SINK="$SINK3" >/dev/null +wait_for_sink "$TEL3" +if [[ -s "$TEL3" ]]; then + if [[ "$(jq -r '.status' "$TEL3")" == "skipped" ]]; then ok "telemetry/no-op: status skipped"; else fail "telemetry/no-op: status=$(jq -r '.status' "$TEL3")"; fi + if [[ "$(jq -r '.data.changed' "$TEL3")" == "false" ]]; then ok "telemetry/no-op: data.changed false (already LF, nothing rewritten)"; else fail "telemetry/no-op: data.changed=$(jq -c '.data.changed' "$TEL3")"; fi +else + fail "telemetry/no-op: no envelope written" +fi +rm -f "$TEL3" + # --- Normalized .sh -> systemMessage names the target ending (#1596) ---------- # Fresh file: tel2.sh was already normalized in the telemetry stub-sink case above. printf 'echo r\r\n' >"$REPO/tel2b.sh" diff --git a/plugins/eol-normalizer/hooks/rewrite-guard.sh b/plugins/eol-normalizer/hooks/rewrite-guard.sh index 329acccdf5..8dd945f442 100644 --- a/plugins/eol-normalizer/hooks/rewrite-guard.sh +++ b/plugins/eol-normalizer/hooks/rewrite-guard.sh @@ -51,7 +51,14 @@ readonly _HOOK_REWRITE_GUARD_LOADED=1 _HOOK_REWRITE_BEFORE="" _HOOK_REWRITE_PREV_EXIT_TRAP="" +_HOOK_REWRITE_SNAPSHOT_FAILED=0 HOOK_REWRITE_MESSAGE="" +# The byte verdict of the take, for the telemetry `data.changed` key (#3755): +# "true" when the file differs from the snapshot, "false" when it is identical +# or no rewrite was ever attempted (begin never ran), and "" when the answer +# is unknown because begin could not snapshot. A producer sends the key only +# when the verdict is known, so an unknown never reads as "not rewritten". +HOOK_REWRITE_CHANGED="" # The EXIT handler begin installs: release the snapshot, then run whatever # EXIT handler the caller had armed before begin (captured below). The @@ -72,11 +79,14 @@ hook::_rewrite_guard_on_exit() { # hook::rewrite_guard_begin "$FILE" hook::rewrite_guard_begin() { _HOOK_REWRITE_BEFORE="" + _HOOK_REWRITE_SNAPSHOT_FAILED=1 HOOK_REWRITE_MESSAGE="" + HOOK_REWRITE_CHANGED="" local snap="" if snap=$(mktemp 2>/dev/null); then if cp "$1" "$snap" 2>/dev/null; then _HOOK_REWRITE_BEFORE="$snap" + _HOOK_REWRITE_SNAPSHOT_FAILED=0 # Capture the caller's current EXIT handler so ours can chain it. # `trap -p EXIT` prints `trap -- '' EXIT`; strip the frame # and eval the remaining shell-quoted literal back into a plain string. @@ -101,18 +111,30 @@ hook::rewrite_guard_begin() { } # Compare against the snapshot, record in -# HOOK_REWRITE_MESSAGE when it changed (empty otherwise), and release the -# snapshot. Destructive read — see the lifecycle block above. The caller -# passes HOOK_REWRITE_MESSAGE as the systemMessage argument of its ONE -# hook::emit_channels call, so a run that both rewrote and found things puts -# both channels in one JSON document. +# HOOK_REWRITE_MESSAGE when it changed (empty otherwise), set the +# HOOK_REWRITE_CHANGED verdict, and release the snapshot. Destructive read for +# the message — see the lifecycle block above; the verdict of the first take +# after begin survives a later take, so a producer that emits telemetry after +# its take still reads the answer. The caller passes HOOK_REWRITE_MESSAGE as +# the systemMessage argument of its ONE hook::emit_channels call, so a run +# that both rewrote and found things puts both channels in one JSON document. # hook::rewrite_take_disclosure "$FILE" "my-plugin: reformatted $(basename "$FILE") via tool." hook::rewrite_take_disclosure() { local file="$1" message="$2" HOOK_REWRITE_MESSAGE="" - [[ -n "$_HOOK_REWRITE_BEFORE" ]] || return 0 + if [[ -z "$_HOOK_REWRITE_BEFORE" ]]; then + # No snapshot: either begin never ran (no rewrite attempted, so the file + # is unchanged by this hook) or it ran and could not snapshot (unknown). + if [[ -z "$HOOK_REWRITE_CHANGED" && "$_HOOK_REWRITE_SNAPSHOT_FAILED" -eq 0 ]]; then + HOOK_REWRITE_CHANGED="false" + fi + return 0 + fi if ! cmp -s "$_HOOK_REWRITE_BEFORE" "$file" 2>/dev/null; then HOOK_REWRITE_MESSAGE="$message" + HOOK_REWRITE_CHANGED="true" + else + HOOK_REWRITE_CHANGED="false" fi rm -f "$_HOOK_REWRITE_BEFORE" _HOOK_REWRITE_BEFORE="" diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json index 12f94c2f2c..ba488b9376 100644 --- a/plugins/go-format/.claude-plugin/plugin.json +++ b/plugins/go-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "go-format", - "version": "0.3.38", + "version": "0.3.39", "description": "Auto-fix Go formatting and import management on edit via goimports \u2014 runs unconditionally (no consumer-config gate), skipping generated files.", "author": { "name": "Melodic Software", diff --git a/plugins/go-format/CHANGELOG.md b/plugins/go-format/CHANGELOG.md index 364aff0a3e..0b14e4276a 100644 --- a/plugins/go-format/CHANGELOG.md +++ b/plugins/go-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `go-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.3.39] + +### Added + +- **Telemetry `data.changed`.** The envelope's `data` carries `changed: true|false`, + the byte verdict the shared rewrite guard already takes for the user-channel + disclosure: true when goimports rewrote the file, false when the bytes were + identical. The key is omitted, never guessed, on a skip arm before the + formatter and when the snapshot could not be taken. This is what fills the + per-session observability report's "Rewrote" block (#3755). The findings arm + and the tool-break arm now take the disclosure before they emit telemetry so + the verdict is known when the envelope is built; stdout is unchanged. + `docs/conventions/hook-telemetry/data/go-format.schema.json` gains the + optional key, and the suite pins it on a reformatting run and a no-op run. + Carries the synced `rewrite-guard.sh` that records the verdict. + ## [0.3.38] ### Changed diff --git a/plugins/go-format/hooks/go-format.sh b/plugins/go-format/hooks/go-format.sh index 2fac69f157..27011ec017 100755 --- a/plugins/go-format/hooks/go-format.sh +++ b/plugins/go-format/hooks/go-format.sh @@ -116,7 +116,9 @@ build_data_json() { --arg tool "$TOOL" \ --arg file "$FILE_REL" \ --argjson findings "$1" \ - '{tool:$tool,file:$file,findings:$findings}' 2>/dev/null || + --arg changed "${HOOK_REWRITE_CHANGED:-}" \ + '{tool:$tool,file:$file,findings:$findings} + + (if $changed == "" then {} else {changed: ($changed == "true")} end)' 2>/dev/null || printf '{"tool":"","file":"","findings":[]}' } @@ -244,9 +246,12 @@ RC=$? if [[ $RC -eq 0 ]]; then # Clean, or fixed silently (formatting/import changes carry no advisory - # noise — same posture as a successful ruff/typos autofix pass). + # noise — same posture as a successful ruff/typos autofix pass). The take + # precedes the telemetry emit so data.changed carries its verdict; the + # disclosure is still one systemMessage-only document, or nothing. + hook::rewrite_take_disclosure "$FILE" "$GO_REWRITE_MESSAGE" emit_tel "ok" '[]' - hook::rewrite_disclose PostToolUse "$FILE" "$GO_REWRITE_MESSAGE" + [[ -z "$HOOK_REWRITE_MESSAGE" ]] || hook::emit_channels PostToolUse "" "$HOOK_REWRITE_MESSAGE" exit 0 fi @@ -266,10 +271,11 @@ if [[ $RC -eq 2 && -n "$STDERR" ]]; then if [[ -n "$findings_raw" ]]; then FINDINGS_JSON=$(printf '%s' "$findings_raw" | jq -R . | jq -s . 2>/dev/null) || FINDINGS_JSON='[]' fi - emit_tel "ok" "$FINDINGS_JSON" # Findings AND a rewrite disclosure compose into one document (#3406 class); - # the take also releases the snapshot this arm would otherwise leak (#3405). + # the take also releases the snapshot this arm would otherwise leak (#3405), + # and precedes the telemetry emit so data.changed carries its verdict. hook::rewrite_take_disclosure "$FILE" "$GO_REWRITE_MESSAGE" + emit_tel "ok" "$FINDINGS_JSON" hook::emit_channels PostToolUse "$GO_CTX" "$HOOK_REWRITE_MESSAGE" exit 0 fi @@ -283,10 +289,11 @@ while IFS= read -r line; do [[ -n "$line" ]] || continue GO_CTX+=$'\n'" $line" done <<<"$STDERR" -emit_tel "skipped" '[]' # goimports may have written the file before breaking; take the disclosure # (which also releases the snapshot this arm would otherwise leak, #3405) and -# compose it with the tool-break context as one document. +# compose it with the tool-break context as one document. Taken before the +# telemetry emit so data.changed records the rewrite the break left behind. hook::rewrite_take_disclosure "$FILE" "$GO_REWRITE_MESSAGE" +emit_tel "skipped" '[]' hook::emit_channels PostToolUse "$GO_CTX" "$HOOK_REWRITE_MESSAGE" exit 0 diff --git a/plugins/go-format/hooks/go-format.test.sh b/plugins/go-format/hooks/go-format.test.sh index b3f45dc52d..223c6ec4fa 100755 --- a/plugins/go-format/hooks/go-format.test.sh +++ b/plugins/go-format/hooks/go-format.test.sh @@ -404,6 +404,8 @@ if [[ -s "$TEL" ]]; then if [[ "$(jq -r '.schema_version' "$TEL")" == "1.0" ]]; then ok "envelope: schema_version 1.0"; else fail "envelope: schema_version=$(jq -r '.schema_version' "$TEL")"; fi if [[ "$(jq '.data.findings | length' "$TEL")" -ge 1 ]]; then ok "envelope: findings populated"; else fail "envelope: findings empty ($(jq '.data.findings' "$TEL"))"; fi if jq -e '.data.findings[0] | type == "string"' "$TEL" >/dev/null 2>&1; then ok "envelope: findings are flat strings"; else fail "envelope: findings[0] wrong type ($(jq '.data.findings[0]' "$TEL"))"; fi + # goimports could not parse the file, so it wrote nothing back. + if [[ "$(jq -r '.data.changed' "$TEL")" == "false" ]]; then ok "envelope: data.changed false (syntax error, nothing rewritten)"; else fail "envelope: data.changed=$(jq -c '.data.changed' "$TEL")"; fi FREL=$(jq -r '.data.file' "$TEL") if [[ -n "$FREL" && "$FREL" != /* && "$FREL" != ?:* ]]; then ok "envelope: data.file repo-relative ($FREL)"; else fail "envelope: data.file not repo-relative: $FREL"; fi if jq -e '.duration_ms | type == "number" and . >= 0 and floor == .' "$TEL" >/dev/null 2>&1; then ok "envelope: duration_ms non-negative int"; else fail "envelope: duration_ms invalid ($(jq .duration_ms "$TEL"))"; fi @@ -421,6 +423,21 @@ else fi rm -f "$TEL" +# --- Stub sink + layout rewrite -> data.changed true (#3755) ------------------ +printf 'package main\n\nimport "fmt"\n\nfunc main() {fmt.Println("tel")}\n' >"$REPO/tel-fmt.go" +TELF="$(mktemp)" +SINKF="$(make_sink "cat >\"$TELF\"")" +OUT_F=$(run_hook_env "$REPO/tel-fmt.go" PATH="$(dirname "$REAL_GOIMPORTS"):$PATH" CLAUDE_PLUGIN_OPTION_GO_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$SINKF") +wait_for_sink "$TELF" +if [[ -s "$TELF" ]]; then + if [[ "$(jq -r '.status' "$TELF")" == "ok" ]]; then ok "telemetry/rewrite: status ok"; else fail "telemetry/rewrite: status=$(jq -r '.status' "$TELF")"; fi + if [[ "$(jq -r '.data.changed' "$TELF")" == "true" ]]; then ok "telemetry/rewrite: data.changed true after goimports reformatted the file"; else fail "telemetry/rewrite: data.changed=$(jq -c '.data.changed' "$TELF")"; fi + if [[ "$OUT_F" == *'"systemMessage"'* ]]; then ok "telemetry/rewrite: the disclosure still reaches stdout"; else fail "telemetry/rewrite: disclosure missing from stdout: $OUT_F"; fi +else + fail "telemetry/rewrite: no envelope written" +fi +rm -f "$TELF" + # --- Stub sink + kill switch -> status skipped ------------------------------- printf 'package main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/tel2.go" TELS="$(mktemp)" diff --git a/plugins/go-format/hooks/rewrite-guard.sh b/plugins/go-format/hooks/rewrite-guard.sh index 329acccdf5..8dd945f442 100644 --- a/plugins/go-format/hooks/rewrite-guard.sh +++ b/plugins/go-format/hooks/rewrite-guard.sh @@ -51,7 +51,14 @@ readonly _HOOK_REWRITE_GUARD_LOADED=1 _HOOK_REWRITE_BEFORE="" _HOOK_REWRITE_PREV_EXIT_TRAP="" +_HOOK_REWRITE_SNAPSHOT_FAILED=0 HOOK_REWRITE_MESSAGE="" +# The byte verdict of the take, for the telemetry `data.changed` key (#3755): +# "true" when the file differs from the snapshot, "false" when it is identical +# or no rewrite was ever attempted (begin never ran), and "" when the answer +# is unknown because begin could not snapshot. A producer sends the key only +# when the verdict is known, so an unknown never reads as "not rewritten". +HOOK_REWRITE_CHANGED="" # The EXIT handler begin installs: release the snapshot, then run whatever # EXIT handler the caller had armed before begin (captured below). The @@ -72,11 +79,14 @@ hook::_rewrite_guard_on_exit() { # hook::rewrite_guard_begin "$FILE" hook::rewrite_guard_begin() { _HOOK_REWRITE_BEFORE="" + _HOOK_REWRITE_SNAPSHOT_FAILED=1 HOOK_REWRITE_MESSAGE="" + HOOK_REWRITE_CHANGED="" local snap="" if snap=$(mktemp 2>/dev/null); then if cp "$1" "$snap" 2>/dev/null; then _HOOK_REWRITE_BEFORE="$snap" + _HOOK_REWRITE_SNAPSHOT_FAILED=0 # Capture the caller's current EXIT handler so ours can chain it. # `trap -p EXIT` prints `trap -- '' EXIT`; strip the frame # and eval the remaining shell-quoted literal back into a plain string. @@ -101,18 +111,30 @@ hook::rewrite_guard_begin() { } # Compare against the snapshot, record in -# HOOK_REWRITE_MESSAGE when it changed (empty otherwise), and release the -# snapshot. Destructive read — see the lifecycle block above. The caller -# passes HOOK_REWRITE_MESSAGE as the systemMessage argument of its ONE -# hook::emit_channels call, so a run that both rewrote and found things puts -# both channels in one JSON document. +# HOOK_REWRITE_MESSAGE when it changed (empty otherwise), set the +# HOOK_REWRITE_CHANGED verdict, and release the snapshot. Destructive read for +# the message — see the lifecycle block above; the verdict of the first take +# after begin survives a later take, so a producer that emits telemetry after +# its take still reads the answer. The caller passes HOOK_REWRITE_MESSAGE as +# the systemMessage argument of its ONE hook::emit_channels call, so a run +# that both rewrote and found things puts both channels in one JSON document. # hook::rewrite_take_disclosure "$FILE" "my-plugin: reformatted $(basename "$FILE") via tool." hook::rewrite_take_disclosure() { local file="$1" message="$2" HOOK_REWRITE_MESSAGE="" - [[ -n "$_HOOK_REWRITE_BEFORE" ]] || return 0 + if [[ -z "$_HOOK_REWRITE_BEFORE" ]]; then + # No snapshot: either begin never ran (no rewrite attempted, so the file + # is unchanged by this hook) or it ran and could not snapshot (unknown). + if [[ -z "$HOOK_REWRITE_CHANGED" && "$_HOOK_REWRITE_SNAPSHOT_FAILED" -eq 0 ]]; then + HOOK_REWRITE_CHANGED="false" + fi + return 0 + fi if ! cmp -s "$_HOOK_REWRITE_BEFORE" "$file" 2>/dev/null; then HOOK_REWRITE_MESSAGE="$message" + HOOK_REWRITE_CHANGED="true" + else + HOOK_REWRITE_CHANGED="false" fi rm -f "$_HOOK_REWRITE_BEFORE" _HOOK_REWRITE_BEFORE="" diff --git a/plugins/markdown-format/.claude-plugin/plugin.json b/plugins/markdown-format/.claude-plugin/plugin.json index ee8994da13..b47eca0c9b 100644 --- a/plugins/markdown-format/.claude-plugin/plugin.json +++ b/plugins/markdown-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "markdown-format", - "version": "0.11.44", + "version": "0.11.45", "description": "Auto-format and lint Markdown on edit via markdownlint-cli2 \u2014 only in repos that carry their own markdownlint config.", "author": { "name": "Melodic Software", diff --git a/plugins/markdown-format/CHANGELOG.md b/plugins/markdown-format/CHANGELOG.md index 4c52e34e2a..c21c6b7d78 100644 --- a/plugins/markdown-format/CHANGELOG.md +++ b/plugins/markdown-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `markdown-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.11.45] + +### Added + +- **Telemetry `data.changed`.** The envelope's `data` carries `changed: true|false` + on every run that reached the fix pass: true when markdownlint-cli2 reported + fixes written (its "Attempted: N fixes" line, the same signal that already + drives the user-channel disclosure), false when it reported none. The key is + omitted on a skip arm, where no fix pass ran. This is what fills the + per-session observability report's "Rewrote" block (#3755). + `docs/conventions/hook-telemetry/data/markdown-format.schema.json` gains the + optional key, and the suite pins it on a fixing run and a no-op run. + ## [0.11.44] ### Changed diff --git a/plugins/markdown-format/hooks/markdown-format.sh b/plugins/markdown-format/hooks/markdown-format.sh index 5efd2447dc..d747813b2b 100755 --- a/plugins/markdown-format/hooks/markdown-format.sh +++ b/plugins/markdown-format/hooks/markdown-format.sh @@ -385,11 +385,17 @@ fi # findings for the noisiest files in the repository. A payload that lies is # worse than no payload. TOOL and FILE_REL stay as arguments: both are bounded # by a path length. +# MD_CHANGED is set on the path that ran the fix pass ("true" when +# markdownlint-cli2 reported fixes written, "false" otherwise) and stays empty +# on every skip arm, where the key is omitted rather than guessed. +MD_CHANGED="" build_data_json() { printf '%s' "$1" | jq -c \ --arg tool "$TOOL" \ --arg file "$FILE_REL" \ - '{tool:$tool,file:$file,findings:.}' 2>/dev/null || + --arg changed "$MD_CHANGED" \ + '{tool:$tool,file:$file,findings:.} + + (if $changed == "" then {} else {changed: ($changed == "true")} end)' 2>/dev/null || printf '{"tool":"","file":"","findings":[]}' } @@ -1287,6 +1293,11 @@ while IFS= read -r line; do esac done <<<"$FIX_OUTPUT" +# The same count line that drives the disclosure below is the telemetry +# verdict: the fix pass ran, and it either wrote fixes or reported none. +MD_CHANGED="false" +[[ -n "$FIXES_LINE" ]] && MD_CHANGED="true" + CTX="" SYSMSG="" diff --git a/plugins/markdown-format/hooks/markdown-format.test.sh b/plugins/markdown-format/hooks/markdown-format.test.sh index 9912d52d0e..1f242a84fd 100755 --- a/plugins/markdown-format/hooks/markdown-format.test.sh +++ b/plugins/markdown-format/hooks/markdown-format.test.sh @@ -1894,6 +1894,8 @@ if [[ -s "$TEL_FILE" ]]; then # status must be "ok" TEL_STATUS="$(jq -r '.status' "$TEL_FILE")" if [[ "$TEL_STATUS" == "ok" ]]; then ok "telemetry/envelope: status ok"; else fail "telemetry/envelope: status expected ok, got $TEL_STATUS"; fi + # MD024 is not auto-fixable, so the fix pass ran and wrote nothing. + if [[ "$(jq -r '.data.changed' "$TEL_FILE")" == "false" ]]; then ok "telemetry/envelope: data.changed false (unfixable finding only)"; else fail "telemetry/envelope: data.changed=$(jq -c '.data.changed' "$TEL_FILE")"; fi # data.findings must contain exactly the MD024 violation line (not banner noise). # Schema: "Unfixable markdownlint violations remaining after --fix, one per line." # Banner lines (version, Finding:, Linting:, Summary:) must be excluded. @@ -2511,6 +2513,28 @@ if printf '%s' "$SYS_F" | grep -q 'Attempted: 7 fixes'; then else fail "bounded/fixes: no user-channel disclosure: $SYS_F" fi +# The same count line is the telemetry verdict: data.changed true on a run that +# wrote fixes, false on a run that reported none (#3755). +TEL_FIX="$(mktemp)" +SINK_FIX="$(make_sink "cat >\"$TEL_FIX\"")" +run_noisy "$FF" STUB_FIX_COUNT=7 HOOK_TELEMETRY_SINK="$SINK_FIX" >/dev/null +wait_for_sink "$TEL_FIX" +if [[ "$(jq -r '.data.changed' "$TEL_FIX" 2>/dev/null)" == "true" ]]; then + ok "bounded/fixes: telemetry data.changed true when fixes were written" +else + fail "bounded/fixes: telemetry data.changed=$(jq -c '.data.changed' "$TEL_FIX" 2>/dev/null)" +fi +rm -f "$TEL_FIX" +TEL_FIX0="$(mktemp)" +SINK_FIX0="$(make_sink "cat >\"$TEL_FIX0\"")" +run_noisy "$FF" STUB_FIX_COUNT=0 HOOK_TELEMETRY_SINK="$SINK_FIX0" >/dev/null +wait_for_sink "$TEL_FIX0" +if [[ "$(jq -r '.data.changed' "$TEL_FIX0" 2>/dev/null)" == "false" ]]; then + ok "bounded/fixes: telemetry data.changed false when the fix pass wrote nothing" +else + fail "bounded/fixes: telemetry data.changed=$(jq -c '.data.changed' "$TEL_FIX0" 2>/dev/null)" +fi +rm -f "$TEL_FIX0" OUT_F0=$(run_noisy "$FF" STUB_FIX_COUNT=0) if [[ -z "$OUT_F0" ]]; then ok "bounded/fixes: a run that changed nothing stays silent" diff --git a/plugins/powershell-format/.claude-plugin/plugin.json b/plugins/powershell-format/.claude-plugin/plugin.json index 41230286f7..8661cb61fe 100644 --- a/plugins/powershell-format/.claude-plugin/plugin.json +++ b/plugins/powershell-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "powershell-format", - "version": "0.7.37", + "version": "0.7.38", "description": "Auto-format and lint PowerShell on edit via PSScriptAnalyzer, only when a PSScriptAnalyzerSettings.psd1 governs the repo \u2014 using the consuming repo's own analyzer settings.", "author": { "name": "Melodic Software", diff --git a/plugins/powershell-format/CHANGELOG.md b/plugins/powershell-format/CHANGELOG.md index c4a330b46c..ffac1c3f6b 100644 --- a/plugins/powershell-format/CHANGELOG.md +++ b/plugins/powershell-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `powershell-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.7.38] + +### Added + +- **Telemetry `data.changed`.** The envelope's `data` carries `changed: true|false`, + the byte verdict the shared rewrite guard already takes for the user-channel + disclosure: true when Invoke-Formatter rewrote the file, false when the bytes + were identical. The key is omitted, never guessed, on a skip arm before the + formatter and when the snapshot could not be taken. This is what fills the + per-session observability report's "Rewrote" block (#3755). The findings arm + and the pwsh tool-break arm now take the disclosure before they emit + telemetry so the verdict is known when the envelope is built; stdout is + unchanged. `docs/conventions/hook-telemetry/data/powershell-format.schema.json` + gains the optional key, and the suite pins it on a reformatting run and a + no-op run. Carries the synced `rewrite-guard.sh` that records the verdict. + ## [0.7.37] ### Changed diff --git a/plugins/powershell-format/hooks/powershell-format.sh b/plugins/powershell-format/hooks/powershell-format.sh index 285fb3b252..11367fede0 100755 --- a/plugins/powershell-format/hooks/powershell-format.sh +++ b/plugins/powershell-format/hooks/powershell-format.sh @@ -108,7 +108,9 @@ build_data_json() { --arg tool "$TOOL" \ --arg file "$FILE_REL" \ --argjson findings "$1" \ - '{tool:$tool,file:$file,findings:$findings}' 2>/dev/null || + --arg changed "${HOOK_REWRITE_CHANGED:-}" \ + '{tool:$tool,file:$file,findings:$findings} + + (if $changed == "" then {} else {changed: ($changed == "true")} end)' 2>/dev/null || printf '{"tool":"","file":"","findings":[]}' } @@ -664,11 +666,12 @@ case $PWSH_EXIT in if [[ -n "$findings_raw" ]]; then FINDINGS_JSON=$(printf '%s' "$findings_raw" | jq -R . | jq -s . 2>/dev/null) || FINDINGS_JSON='[]' fi - emit_tel "ok" "$FINDINGS_JSON" # Findings AND a rewrite disclosure compose into one document. Emitting the # context and the systemMessage as two objects would break the single-JSON-doc - # stdout contract, which is what hook::emit_channels exists to prevent. + # stdout contract, which is what hook::emit_channels exists to prevent. The + # take precedes the telemetry emit so data.changed carries its verdict. hook::rewrite_take_disclosure "$FILE" "$PS_REWRITE_MESSAGE_TEXT" + emit_tel "ok" "$FINDINGS_JSON" hook::emit_channels PostToolUse "$PS_CTX" "$HOOK_REWRITE_MESSAGE" exit 0 ;; @@ -760,13 +763,14 @@ case $PWSH_EXIT in [[ -n "$line" ]] || continue PS_CTX+=$'\n'" $line" done <<<"$PSSA_OUTPUT" - emit_tel "skipped" '[]' # Invoke-Formatter writes back BEFORE Invoke-ScriptAnalyzer runs, and both sit # inside the same try/catch that raises exit 4 — so a rewrite can already be on # disk when pwsh breaks. Take the disclosure (which also releases the snapshot # on the changed and unchanged paths alike) and emit it WITH the tool-break - # context as one document, rather than exiting on a silent rewrite. + # context as one document, rather than exiting on a silent rewrite. Taken + # before the telemetry emit so data.changed records that rewrite too. hook::rewrite_take_disclosure "$FILE" "$PS_REWRITE_MESSAGE_TEXT" + emit_tel "skipped" '[]' hook::emit_channels PostToolUse "$PS_CTX" "$HOOK_REWRITE_MESSAGE" exit 0 ;; diff --git a/plugins/powershell-format/hooks/powershell-format.test.sh b/plugins/powershell-format/hooks/powershell-format.test.sh index a6cf0a71ea..ffae406185 100755 --- a/plugins/powershell-format/hooks/powershell-format.test.sh +++ b/plugins/powershell-format/hooks/powershell-format.test.sh @@ -945,6 +945,9 @@ if [[ -s "$TEL" ]]; then if [[ "$(jq -r '.status' "$TEL")" == "ok" ]]; then ok "envelope: status ok"; else fail "envelope: status=$(jq -r '.status' "$TEL")"; fi if [[ "$(jq -r '.schema_version' "$TEL")" == "1.0" ]]; then ok "envelope: schema_version 1.0"; else fail "envelope: schema_version=$(jq -r '.schema_version' "$TEL")"; fi if [[ "$(jq '.data.findings | length' "$TEL")" -ge 1 ]]; then ok "envelope: findings populated"; else fail "envelope: findings empty ($(jq '.data.findings' "$TEL"))"; fi + # The findings arm takes the rewrite verdict before it emits, so the key is + # always present here; its value depends on the analyzer's formatter settings. + if jq -e '.data.changed | type == "boolean"' "$TEL" >/dev/null 2>&1; then ok "envelope: data.changed is a boolean verdict"; else fail "envelope: data.changed missing or not boolean ($(jq -c '.data.changed' "$TEL"))"; fi FREL=$(jq -r '.data.file' "$TEL") if [[ -n "$FREL" && "$FREL" != /* && "$FREL" != ?:* ]]; then ok "envelope: data.file repo-relative ($FREL)"; else fail "envelope: data.file not repo-relative: $FREL"; fi if jq -e '.duration_ms | type == "number" and . >= 0 and floor == .' "$TEL" >/dev/null 2>&1; then ok "envelope: duration_ms non-negative int"; else fail "envelope: duration_ms invalid ($(jq .duration_ms "$TEL"))"; fi diff --git a/plugins/powershell-format/hooks/rewrite-guard.sh b/plugins/powershell-format/hooks/rewrite-guard.sh index 329acccdf5..8dd945f442 100644 --- a/plugins/powershell-format/hooks/rewrite-guard.sh +++ b/plugins/powershell-format/hooks/rewrite-guard.sh @@ -51,7 +51,14 @@ readonly _HOOK_REWRITE_GUARD_LOADED=1 _HOOK_REWRITE_BEFORE="" _HOOK_REWRITE_PREV_EXIT_TRAP="" +_HOOK_REWRITE_SNAPSHOT_FAILED=0 HOOK_REWRITE_MESSAGE="" +# The byte verdict of the take, for the telemetry `data.changed` key (#3755): +# "true" when the file differs from the snapshot, "false" when it is identical +# or no rewrite was ever attempted (begin never ran), and "" when the answer +# is unknown because begin could not snapshot. A producer sends the key only +# when the verdict is known, so an unknown never reads as "not rewritten". +HOOK_REWRITE_CHANGED="" # The EXIT handler begin installs: release the snapshot, then run whatever # EXIT handler the caller had armed before begin (captured below). The @@ -72,11 +79,14 @@ hook::_rewrite_guard_on_exit() { # hook::rewrite_guard_begin "$FILE" hook::rewrite_guard_begin() { _HOOK_REWRITE_BEFORE="" + _HOOK_REWRITE_SNAPSHOT_FAILED=1 HOOK_REWRITE_MESSAGE="" + HOOK_REWRITE_CHANGED="" local snap="" if snap=$(mktemp 2>/dev/null); then if cp "$1" "$snap" 2>/dev/null; then _HOOK_REWRITE_BEFORE="$snap" + _HOOK_REWRITE_SNAPSHOT_FAILED=0 # Capture the caller's current EXIT handler so ours can chain it. # `trap -p EXIT` prints `trap -- '' EXIT`; strip the frame # and eval the remaining shell-quoted literal back into a plain string. @@ -101,18 +111,30 @@ hook::rewrite_guard_begin() { } # Compare against the snapshot, record in -# HOOK_REWRITE_MESSAGE when it changed (empty otherwise), and release the -# snapshot. Destructive read — see the lifecycle block above. The caller -# passes HOOK_REWRITE_MESSAGE as the systemMessage argument of its ONE -# hook::emit_channels call, so a run that both rewrote and found things puts -# both channels in one JSON document. +# HOOK_REWRITE_MESSAGE when it changed (empty otherwise), set the +# HOOK_REWRITE_CHANGED verdict, and release the snapshot. Destructive read for +# the message — see the lifecycle block above; the verdict of the first take +# after begin survives a later take, so a producer that emits telemetry after +# its take still reads the answer. The caller passes HOOK_REWRITE_MESSAGE as +# the systemMessage argument of its ONE hook::emit_channels call, so a run +# that both rewrote and found things puts both channels in one JSON document. # hook::rewrite_take_disclosure "$FILE" "my-plugin: reformatted $(basename "$FILE") via tool." hook::rewrite_take_disclosure() { local file="$1" message="$2" HOOK_REWRITE_MESSAGE="" - [[ -n "$_HOOK_REWRITE_BEFORE" ]] || return 0 + if [[ -z "$_HOOK_REWRITE_BEFORE" ]]; then + # No snapshot: either begin never ran (no rewrite attempted, so the file + # is unchanged by this hook) or it ran and could not snapshot (unknown). + if [[ -z "$HOOK_REWRITE_CHANGED" && "$_HOOK_REWRITE_SNAPSHOT_FAILED" -eq 0 ]]; then + HOOK_REWRITE_CHANGED="false" + fi + return 0 + fi if ! cmp -s "$_HOOK_REWRITE_BEFORE" "$file" 2>/dev/null; then HOOK_REWRITE_MESSAGE="$message" + HOOK_REWRITE_CHANGED="true" + else + HOOK_REWRITE_CHANGED="false" fi rm -f "$_HOOK_REWRITE_BEFORE" _HOOK_REWRITE_BEFORE="" diff --git a/plugins/ruff-format/.claude-plugin/plugin.json b/plugins/ruff-format/.claude-plugin/plugin.json index 265d5db632..9061783403 100644 --- a/plugins/ruff-format/.claude-plugin/plugin.json +++ b/plugins/ruff-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "ruff-format", - "version": "0.6.35", + "version": "0.6.36", "description": "Auto-format and lint Python on edit via Ruff, only when a Ruff config governs the repo \u2014 using the consuming repo's own Ruff config.", "author": { "name": "Melodic Software", diff --git a/plugins/ruff-format/CHANGELOG.md b/plugins/ruff-format/CHANGELOG.md index de549a8672..c1ff7f7b7e 100644 --- a/plugins/ruff-format/CHANGELOG.md +++ b/plugins/ruff-format/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to the `ruff-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.36] + +### Added + +- **Telemetry `data.changed`.** The envelope's `data` carries `changed: true|false`, + the byte verdict the shared rewrite guard already takes for the user-channel + disclosure: true when the `check --fix` or `format` pass rewrote the file, + false when the bytes were identical. The key is omitted, never guessed, on a + skip arm before the formatter and when the snapshot could not be taken. This + is what fills the per-session observability report's "Rewrote" block (#3755). + `docs/conventions/hook-telemetry/data/ruff-format.schema.json` gains the + optional key, and the suite pins it on a reformatting run and a no-op run. + Carries the synced `rewrite-guard.sh` that records the verdict. + ## [0.6.35] ### Changed diff --git a/plugins/ruff-format/hooks/rewrite-guard.sh b/plugins/ruff-format/hooks/rewrite-guard.sh index 329acccdf5..8dd945f442 100644 --- a/plugins/ruff-format/hooks/rewrite-guard.sh +++ b/plugins/ruff-format/hooks/rewrite-guard.sh @@ -51,7 +51,14 @@ readonly _HOOK_REWRITE_GUARD_LOADED=1 _HOOK_REWRITE_BEFORE="" _HOOK_REWRITE_PREV_EXIT_TRAP="" +_HOOK_REWRITE_SNAPSHOT_FAILED=0 HOOK_REWRITE_MESSAGE="" +# The byte verdict of the take, for the telemetry `data.changed` key (#3755): +# "true" when the file differs from the snapshot, "false" when it is identical +# or no rewrite was ever attempted (begin never ran), and "" when the answer +# is unknown because begin could not snapshot. A producer sends the key only +# when the verdict is known, so an unknown never reads as "not rewritten". +HOOK_REWRITE_CHANGED="" # The EXIT handler begin installs: release the snapshot, then run whatever # EXIT handler the caller had armed before begin (captured below). The @@ -72,11 +79,14 @@ hook::_rewrite_guard_on_exit() { # hook::rewrite_guard_begin "$FILE" hook::rewrite_guard_begin() { _HOOK_REWRITE_BEFORE="" + _HOOK_REWRITE_SNAPSHOT_FAILED=1 HOOK_REWRITE_MESSAGE="" + HOOK_REWRITE_CHANGED="" local snap="" if snap=$(mktemp 2>/dev/null); then if cp "$1" "$snap" 2>/dev/null; then _HOOK_REWRITE_BEFORE="$snap" + _HOOK_REWRITE_SNAPSHOT_FAILED=0 # Capture the caller's current EXIT handler so ours can chain it. # `trap -p EXIT` prints `trap -- '' EXIT`; strip the frame # and eval the remaining shell-quoted literal back into a plain string. @@ -101,18 +111,30 @@ hook::rewrite_guard_begin() { } # Compare against the snapshot, record in -# HOOK_REWRITE_MESSAGE when it changed (empty otherwise), and release the -# snapshot. Destructive read — see the lifecycle block above. The caller -# passes HOOK_REWRITE_MESSAGE as the systemMessage argument of its ONE -# hook::emit_channels call, so a run that both rewrote and found things puts -# both channels in one JSON document. +# HOOK_REWRITE_MESSAGE when it changed (empty otherwise), set the +# HOOK_REWRITE_CHANGED verdict, and release the snapshot. Destructive read for +# the message — see the lifecycle block above; the verdict of the first take +# after begin survives a later take, so a producer that emits telemetry after +# its take still reads the answer. The caller passes HOOK_REWRITE_MESSAGE as +# the systemMessage argument of its ONE hook::emit_channels call, so a run +# that both rewrote and found things puts both channels in one JSON document. # hook::rewrite_take_disclosure "$FILE" "my-plugin: reformatted $(basename "$FILE") via tool." hook::rewrite_take_disclosure() { local file="$1" message="$2" HOOK_REWRITE_MESSAGE="" - [[ -n "$_HOOK_REWRITE_BEFORE" ]] || return 0 + if [[ -z "$_HOOK_REWRITE_BEFORE" ]]; then + # No snapshot: either begin never ran (no rewrite attempted, so the file + # is unchanged by this hook) or it ran and could not snapshot (unknown). + if [[ -z "$HOOK_REWRITE_CHANGED" && "$_HOOK_REWRITE_SNAPSHOT_FAILED" -eq 0 ]]; then + HOOK_REWRITE_CHANGED="false" + fi + return 0 + fi if ! cmp -s "$_HOOK_REWRITE_BEFORE" "$file" 2>/dev/null; then HOOK_REWRITE_MESSAGE="$message" + HOOK_REWRITE_CHANGED="true" + else + HOOK_REWRITE_CHANGED="false" fi rm -f "$_HOOK_REWRITE_BEFORE" _HOOK_REWRITE_BEFORE="" diff --git a/plugins/ruff-format/hooks/ruff-format.sh b/plugins/ruff-format/hooks/ruff-format.sh index e727b3176d..7221c57a1d 100755 --- a/plugins/ruff-format/hooks/ruff-format.sh +++ b/plugins/ruff-format/hooks/ruff-format.sh @@ -111,7 +111,9 @@ build_data_json() { --arg tool "$TOOL" \ --arg file "$FILE_REL" \ --argjson findings "$1" \ - '{tool:$tool,file:$file,findings:$findings}' 2>/dev/null || + --arg changed "${HOOK_REWRITE_CHANGED:-}" \ + '{tool:$tool,file:$file,findings:$findings} + + (if $changed == "" then {} else {changed: ($changed == "true")} end)' 2>/dev/null || printf '{"tool":"","file":"","findings":[]}' } @@ -252,8 +254,11 @@ OUTPUT=$(cd "$RUN_DIR" && "$RUFF_BIN" check --no-fix --output-format concise "${ RC=$? if [[ $RC -eq 0 ]]; then + # Take before the telemetry emit so data.changed carries the byte verdict; + # the disclosure is still one systemMessage-only document, or nothing. + hook::rewrite_take_disclosure "$FILE" "$RUFF_REWRITE_MESSAGE" emit_tel "ok" '[]' - hook::rewrite_disclose PostToolUse "$FILE" "$RUFF_REWRITE_MESSAGE" + [[ -z "$HOOK_REWRITE_MESSAGE" ]] || hook::emit_channels PostToolUse "" "$HOOK_REWRITE_MESSAGE" exit 0 fi diff --git a/plugins/ruff-format/hooks/ruff-format.test.sh b/plugins/ruff-format/hooks/ruff-format.test.sh index ddf9e255e5..35d0e5c516 100755 --- a/plugins/ruff-format/hooks/ruff-format.test.sh +++ b/plugins/ruff-format/hooks/ruff-format.test.sh @@ -415,6 +415,8 @@ if [[ -s "$TEL" ]]; then if [[ "$(jq -r '.status' "$TEL")" == "ok" ]]; then ok "envelope: status ok"; else fail "envelope: status=$(jq -r '.status' "$TEL")"; fi if [[ "$(jq -r '.schema_version' "$TEL")" == "1.0" ]]; then ok "envelope: schema_version 1.0"; else fail "envelope: schema_version=$(jq -r '.schema_version' "$TEL")"; fi if [[ "$(jq '.data.findings | length' "$TEL")" -ge 1 ]]; then ok "envelope: findings populated"; else fail "envelope: findings empty ($(jq '.data.findings' "$TEL"))"; fi + # An undefined name is not auto-fixable and the line is already formatted: no bytes moved. + if [[ "$(jq -r '.data.changed' "$TEL")" == "false" ]]; then ok "envelope: data.changed false (nothing rewritten)"; else fail "envelope: data.changed=$(jq -c '.data.changed' "$TEL")"; fi FREL=$(jq -r '.data.file' "$TEL") if [[ -n "$FREL" && "$FREL" != /* && "$FREL" != ?:* ]]; then ok "envelope: data.file repo-relative ($FREL)"; else fail "envelope: data.file not repo-relative: $FREL"; fi if jq -e '.duration_ms | type == "number" and . >= 0 and floor == .' "$TEL" >/dev/null 2>&1; then ok "envelope: duration_ms non-negative int"; else fail "envelope: duration_ms invalid ($(jq .duration_ms "$TEL"))"; fi @@ -423,6 +425,21 @@ else fi rm -f "$TEL" +# --- Stub sink + format rewrite -> data.changed true (#3755) ------------------ +printf 'x=1\n' >"$REPO/tel-fmt.py" +TELF="$(mktemp)" +SINKF="$(make_sink "cat >\"$TELF\"")" +OUT_F=$(run_hook_env "$REPO/tel-fmt.py" CLAUDE_PLUGIN_OPTION_RUFF_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$SINKF") +wait_for_sink "$TELF" +if [[ -s "$TELF" ]]; then + if [[ "$(jq -r '.status' "$TELF")" == "ok" ]]; then ok "telemetry/rewrite: status ok"; else fail "telemetry/rewrite: status=$(jq -r '.status' "$TELF")"; fi + if [[ "$(jq -r '.data.changed' "$TELF")" == "true" ]]; then ok "telemetry/rewrite: data.changed true after ruff format rewrote the file"; else fail "telemetry/rewrite: data.changed=$(jq -c '.data.changed' "$TELF")"; fi + if [[ "$OUT_F" == *'"systemMessage"'* ]]; then ok "telemetry/rewrite: the disclosure still reaches stdout"; else fail "telemetry/rewrite: disclosure missing from stdout: $OUT_F"; fi +else + fail "telemetry/rewrite: no envelope written" +fi +rm -f "$TELF" + # --- Stub sink + gate OFF -> status skipped ----------------------------------- printf 's=1\n' >"$REPO_NO/tel2.py" TELS="$(mktemp)" diff --git a/plugins/typos-format/.claude-plugin/plugin.json b/plugins/typos-format/.claude-plugin/plugin.json index 5f750f4245..e8e8d0950c 100644 --- a/plugins/typos-format/.claude-plugin/plugin.json +++ b/plugins/typos-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "typos-format", - "version": "0.6.42", + "version": "0.6.43", "description": "Spell-check on edit via typos-cli, unconditionally \u2014 report-only by default, honoring the consuming repo's own typos configuration when one is present.", "author": { "name": "Melodic Software", diff --git a/plugins/typos-format/CHANGELOG.md b/plugins/typos-format/CHANGELOG.md index ccd1f928f9..20797614f9 100644 --- a/plugins/typos-format/CHANGELOG.md +++ b/plugins/typos-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `typos-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.43] + +### Added + +- **Telemetry `data.changed`.** The envelope's `data` carries `changed: true|false` + on every run that reached typos: true when `applied` is non-empty (at least + one correction was written into the file), false when typos ran and applied + none, which is every run in the default report-only mode. The key is omitted + on a skip arm, where typos never ran. This is what fills the per-session + observability report's "Rewrote" block (#3755). + `docs/conventions/hook-telemetry/data/typos-format.schema.json` gains the + optional key, and the suite pins it on a rewriting run and a no-op run. + ## [0.6.42] ### Changed diff --git a/plugins/typos-format/hooks/typos-format.sh b/plugins/typos-format/hooks/typos-format.sh index 2a29f0dbd7..fb794fc432 100755 --- a/plugins/typos-format/hooks/typos-format.sh +++ b/plugins/typos-format/hooks/typos-format.sh @@ -106,7 +106,7 @@ start=${EPOCHREALTIME:-} emit_tel() { [[ -n "$start" ]] || return 0 hook::telemetry_enabled || return 0 - hook::emit_telemetry "typos-format" "PostToolUse" "$1" "$start" "$(build_data_json "$2" "${3:-[]}")" "$REPO_ROOT" + hook::emit_telemetry "typos-format" "PostToolUse" "$1" "$start" "$(build_data_json "$2" "${3:-[]}" "${4:-}")" "$REPO_ROOT" } INPUT=$(hook::buffer_stdin) || exit 0 @@ -214,12 +214,17 @@ FILE_REL="$(hook::repo_relative_path "$FILE" "$REPO_ROOT")" || FILE_REL_DEGRADED # lossy, so a dropped envelope is inside contract; one that arrives claiming a # heavily-rewritten file was untouched is not. TOOL and FILE_REL stay as # arguments: both are bounded by a path length. +# $3 is the rewrite verdict for data.changed: "true" when typos applied at +# least one correction to the file, "false" when it ran and applied none, and +# empty on a skip arm, where the key is omitted rather than guessed. build_data_json() { printf '{"findings":%s,"applied":%s}' "$1" "${2:-[]}" | jq -c \ --arg tool "$TOOL" \ --arg file "$FILE_REL" \ - '{tool:$tool,file:$file,findings:.findings,applied:.applied}' 2>/dev/null || + --arg changed "${3:-}" \ + '{tool:$tool,file:$file,findings:.findings,applied:.applied} + + (if $changed == "" then {} else {changed: ($changed == "true")} end)' 2>/dev/null || printf '{"tool":"","file":"","findings":[],"applied":[]}' } @@ -711,5 +716,7 @@ hook::emit_channels PostToolUse "$CTX" "$SYSMSG" # data.findings, applied rewrites in data.applied), mirroring the sibling # formatter plugins where status reflects whether the tool ran, not whether it # was clean. -emit_tel "ok" "$FINDINGS_JSON" "$APPLIED_JSON" +TYPOS_CHANGED="false" +((APPLIED_COUNT > 0)) && TYPOS_CHANGED="true" +emit_tel "ok" "$FINDINGS_JSON" "$APPLIED_JSON" "$TYPOS_CHANGED" exit 0 diff --git a/plugins/typos-format/hooks/typos-format.test.sh b/plugins/typos-format/hooks/typos-format.test.sh index 60ffffe891..6bb223b525 100755 --- a/plugins/typos-format/hooks/typos-format.test.sh +++ b/plugins/typos-format/hooks/typos-format.test.sh @@ -603,6 +603,11 @@ if wait_for_sink "$TELRF" 50; then else fail "stub/reflow: telemetry claims rewrites: $(jq -s -c '.[-1].data.applied' "$TELRF")" fi + if [[ "$(jq -s -r '.[-1].data.changed' "$TELRF" 2>/dev/null)" == "false" ]]; then + ok "stub/reflow: data.changed false when nothing was applied" + else + fail "stub/reflow: data.changed=$(jq -s -c '.[-1].data.changed' "$TELRF")" + fi else fail "stub/reflow: telemetry sink never populated — the assertion below it never ran" fi @@ -1083,6 +1088,11 @@ if [[ -s "$TELA" ]]; then else fail "stub/telemetry: data.applied.line wrong: $(jq -c '.data.applied' "$TELA")" fi + if [[ "$(jq -r '.data.changed' "$TELA")" == "true" ]]; then + ok "stub/telemetry: data.changed true when a correction was applied (#3755)" + else + fail "stub/telemetry: data.changed=$(jq -c '.data.changed' "$TELA")" + fi if [[ "$(jq -r '.data.findings[0].typo' "$TELA")" == "disallowme" ]]; then ok "stub/telemetry: data.findings still carries residual findings only" else From b082fbf2da134cf03623230cee7097db5b3d81aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 18:13:58 +0000 Subject: [PATCH 2/2] fix(formatters): settle the rewrite verdict on two more arms typos-format's clean arm reached typos and knows the answer, so it now sends changed: false instead of omitting the key; an all-clean session reads "nothing rewritten" rather than "no data". ruff-format's tool-break arm emitted telemetry before taking the disclosure, so a rewrite the fix or format pass had already written was not recorded; the take now precedes the emit as on every other arm. One suite case pins the clean verdict. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme --- plugins/ruff-format/hooks/ruff-format.sh | 5 +++-- plugins/typos-format/hooks/typos-format.sh | 5 +++-- plugins/typos-format/hooks/typos-format.test.sh | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/plugins/ruff-format/hooks/ruff-format.sh b/plugins/ruff-format/hooks/ruff-format.sh index 7221c57a1d..e65a202824 100755 --- a/plugins/ruff-format/hooks/ruff-format.sh +++ b/plugins/ruff-format/hooks/ruff-format.sh @@ -295,10 +295,11 @@ while IFS= read -r line; do [[ -n "$line" ]] || continue RUFF_CTX+=$'\n'" $line" done <<<"$OUTPUT" -emit_tel "skipped" '[]' # The fix/format passes may already have rewritten the file before the verify # pass broke; take the disclosure and compose it with the tool-break context -# as one document (#3406). +# as one document (#3406). Taken before the telemetry emit so data.changed +# records that rewrite too. hook::rewrite_take_disclosure "$FILE" "$RUFF_REWRITE_MESSAGE" +emit_tel "skipped" '[]' hook::emit_channels PostToolUse "$RUFF_CTX" "$HOOK_REWRITE_MESSAGE" exit 0 diff --git a/plugins/typos-format/hooks/typos-format.sh b/plugins/typos-format/hooks/typos-format.sh index fb794fc432..39ff94d174 100755 --- a/plugins/typos-format/hooks/typos-format.sh +++ b/plugins/typos-format/hooks/typos-format.sh @@ -407,8 +407,9 @@ emit_tool_break() { if [[ $SCAN_RC -eq 0 ]]; then # Clean, or excluded by the repo's own typos config. Nothing was changed and - # there is nothing to disclose. - emit_tel "ok" '[]' + # there is nothing to disclose; typos ran, so the rewrite verdict is a + # known false rather than an omitted key. + emit_tel "ok" '[]' '[]' false exit 0 fi diff --git a/plugins/typos-format/hooks/typos-format.test.sh b/plugins/typos-format/hooks/typos-format.test.sh index 6bb223b525..51221b0fbb 100755 --- a/plugins/typos-format/hooks/typos-format.test.sh +++ b/plugins/typos-format/hooks/typos-format.test.sh @@ -1584,6 +1584,21 @@ else fail "telemetry/sink-unset: rc=$RC_NS out=$OUT_NS" fi +# --- Stub sink + clean file -> status ok, data.changed false (#3755) ---------- +# typos ran and had nothing to apply: a known false, not an omitted key, so an +# all-clean session reads "nothing rewritten" rather than "no data". +TELCL="$(mktemp)" +SINKCL="$(make_sink "cat >\"$TELCL\"")" +run_hook_env "$REPO/tel-clean.txt" PATH="$(dirname "$REAL_TYPOS"):$PATH" CLAUDE_PLUGIN_OPTION_TYPOS_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$SINKCL" >/dev/null +wait_for_sink "$TELCL" +if [[ -s "$TELCL" ]]; then + if [[ "$(jq -r '.status' "$TELCL")" == "ok" ]]; then ok "telemetry/clean: status ok"; else fail "telemetry/clean: status=$(jq -r '.status' "$TELCL")"; fi + if [[ "$(jq -r '.data.changed' "$TELCL")" == "false" ]]; then ok "telemetry/clean: data.changed false on a clean run"; else fail "telemetry/clean: data.changed=$(jq -c '.data.changed' "$TELCL")"; fi +else + fail "telemetry/clean: no envelope written" +fi +rm -f "$TELCL" + # --- Stub sink + unfixable finding -> envelope status ok with findings ------- printf 'this has a disallowme term\n' >"$REPO/tel.txt" TEL="$(mktemp)"