mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-06-13 22:26:03 -03:00
Fixes #1358, #1375, and #1783 — three related complaints about the hook output protocol used at the three asyncRewake exit-2 sites (handle_commit_review_posttooluse, handle_push_sweep_posttooluse, handle_stop_hook). The old shape at each site was: emit_metrics({...}) # JSON to stdout (metrics) sys.stderr.write(banner + guidance + suffix) # plain text to stderr sys.exit(2) # asyncRewake trigger That triggered three reported problems: #1375: CC's hook system parsing stdout for a SyncHookJSONOutput sees only the bare metrics dict — no findings reason — and on older CC versions surfaces a 'json output validation failed' error because stderr's plain text isn't valid JSON. #1783: CC's UI shows 'Permission to use Edit has been denied' with no permissionDecisionReason — the stderr text is invisible to that UI surface; CC only renders fields it can find in the JSON. #1358: Reporters experienced the exit(2) as 'gating' behavior rather than 'warning' behavior. The pattern-warning path in main() was migrated to exit(0) + hookSpecificOutput.additionalContext long ago; these three asyncRewake sites were never updated. Fix: extend emit_metrics() to accept additional_context, system_message, and hook_event_name kwargs, and emit them in the same SyncHookJSONOutput line as the metrics. CC's parser stops scanning stdout after the first {-prefixed line, so the findings must ride in that same line — calling emit_metrics twice or adding a second print(json.dumps(...)) would silently drop the second emission. At each of the three call sites: route the guidance text that used to go to stderr through additional_context instead. The stderr.write is dropped — additionalContext carries the same text to the model via the JSON channel, and the legacy stderr surface is what triggered #1375's JSON validation error on older CC clients. exit(2) is preserved at all three sites. That's the documented mechanism for triggering the asyncRewake 'force fix' feedback loop (per the inline comment at the stop-hook site); switching to exit(0) without verifying CC's protocol-version support risks dropping the rewake entirely and silently losing all the findings the hook just computed. For push-sweep specifically: emit_metrics had to move from an unconditional pre-emission (line ~1680) to two conditional sites (one in the no-vulns branch with exit(0), one in the with-vulns branch with exit(2)) because the with-vulns branch needs to attach additional_context and CC reads only the first JSON line — a second emit would be ignored. Behavior is preserved: every push-sweep fire emits exactly one metrics line, just at a slightly later point in the function body. Verified locally on macOS Python 3.13: - py_compile clean. - Existing 45 smoke + extensibility tests still pass. - 21 new tests in test_hook_output_protocol.py (added to internal test suite at sg-staging/tests/, not in this PR): * 6 backward-compat: emit_metrics with metrics only, with rewake_summary, etc. — verifies the legacy callers still produce the same output shape. * 5 additional_context shape: lands in hookSpecificOutput, round-trips the value, default hook_event_name is sensible, empty/None doesn't pollute the JSON with an empty hSO block. * 3 system_message shape: lands in systemMessage, empty/None suppressed, round-trips. * 1 combined: metrics + rewake_summary + additional_context + system_message + hook_event_name all merge into one JSON line. * 6 round-trip safety: emoji, quotes, backslashes, newlines, Unicode (山田太郎 + 🎉), tabs, null bytes — all survive the json.dumps cycle. * 6 static-shape: each of the three asyncRewake handlers (commit_review, push_sweep, stop_hook) is checked to confirm it passes additional_context to emit_metrics and no longer writes the PROVENANCE_BANNER guidance to stderr. Catches the regression class where a new exit(2) site forgets to plumb guidance through the JSON channel. - 66/66 pass total (45 existing + 21 new) in 2.57s. NOT verified end-to-end with a real CC instance triggering all three hooks. The static-shape tests + the JSON round-trip tests should catch any regression in the emit_metrics output, but the actual interaction with CC's asyncRewake / rewakeMessage flow (especially: does hookSpecificOutput.additionalContext successfully appear in the rewakeMessage that CC sends to the model?) needs runtime verification against a CC version that supports the modern protocol. The reporter for #1375 specifically called out that CC's older versions surfaced 'json output validation failed' on the old stderr- only output; this fix changes the stdout shape to valid JSON with the findings included, which should resolve that error class. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
68a700837c
commit
37ffc76005
@ -190,7 +190,13 @@ CONTINUATION_SUFFIX = (
|
||||
"response."
|
||||
)
|
||||
|
||||
def emit_metrics(metrics, rewake_summary=None):
|
||||
def emit_metrics(
|
||||
metrics,
|
||||
rewake_summary=None,
|
||||
additional_context=None,
|
||||
system_message=None,
|
||||
hook_event_name="PostToolUse",
|
||||
):
|
||||
"""
|
||||
Write a SyncHookJSONOutput line to stdout for Claude Code to pick up.
|
||||
For asyncRewake (Stop) hooks, CC scans stdout for the first {-prefixed line
|
||||
@ -213,6 +219,27 @@ def emit_metrics(metrics, rewake_summary=None):
|
||||
rewakeSummary in hooks.json, shown to the user in the terminal as the
|
||||
task-notification one-liner. Must be in the same JSON line as the metrics
|
||||
because CC stops scanning stdout after the first {-prefixed line.
|
||||
|
||||
`additional_context` (asyncRewake findings): model-visible guidance text
|
||||
that CC surfaces via the modern hook-output protocol
|
||||
(hookSpecificOutput.additionalContext) instead of the legacy stderr +
|
||||
exit(2) pair. The caller passes the finding-explanation text it would
|
||||
have written to stderr; the JSON channel carries it cleanly so CC's UI
|
||||
shows the reason properly instead of "Permission denied with no reason".
|
||||
See anthropics/claude-plugins-official#1375 and #1783. Empty/None
|
||||
means no hookSpecificOutput field is emitted (preserves backward compat
|
||||
for legacy emit-sites that only want metrics).
|
||||
|
||||
`system_message` (optional, asyncRewake only): user-visible TUI message,
|
||||
distinct from rewakeSummary which is the task-notification one-liner.
|
||||
Use sparingly — the rewakeMessage in hooks.json is the primary user
|
||||
surface; systemMessage adds a per-fire override when the static
|
||||
rewakeMessage isn't specific enough for the finding being shown.
|
||||
|
||||
`hook_event_name` (used only when additional_context is set): which event
|
||||
the hookSpecificOutput attaches to. Defaults to "PostToolUse" since the
|
||||
commit-review and push-sweep handlers are the most common callers;
|
||||
handle_stop_hook explicitly passes "Stop".
|
||||
"""
|
||||
head = {}
|
||||
if _PV and "pv" not in metrics:
|
||||
@ -223,6 +250,17 @@ def emit_metrics(metrics, rewake_summary=None):
|
||||
out = {"metrics": metrics}
|
||||
if rewake_summary:
|
||||
out["rewakeSummary"] = rewake_summary
|
||||
if additional_context:
|
||||
# Wrap in hookSpecificOutput per CC's modern hook-output contract.
|
||||
# Drops the legacy `sys.stderr.write(...) + sys.exit(2)` shape that
|
||||
# left CC's UI showing "denied with no reason" (#1783) and triggered
|
||||
# "json output validation failed" on older CC versions (#1375).
|
||||
out["hookSpecificOutput"] = {
|
||||
"hookEventName": hook_event_name,
|
||||
"additionalContext": additional_context,
|
||||
}
|
||||
if system_message:
|
||||
out["systemMessage"] = system_message
|
||||
print(json.dumps(out), flush=True)
|
||||
|
||||
# =====================================================================
|
||||
@ -1361,18 +1399,26 @@ def handle_commit_review_posttooluse(input_data):
|
||||
if s in sev:
|
||||
sev[s] += 1
|
||||
|
||||
# Rebuild guidance from new_vulns only — concrete_guidance from the LLM
|
||||
# still lists deduped entries. Pass via additional_context so CC surfaces
|
||||
# the reason via hookSpecificOutput.additionalContext instead of empty
|
||||
# stdout (#1783) / stderr-only "json output validation failed" (#1375).
|
||||
_commit_guidance = (PROVENANCE_BANNER + "\n\n"
|
||||
+ _format_vulns_guidance(new_vulns)
|
||||
+ CONTINUATION_SUFFIX + "\n")
|
||||
emit_metrics({
|
||||
"vulns_found": len(new_vulns), **_base, **_agentic_m,
|
||||
"critical_count": sev["critical"], "high_count": sev["high"],
|
||||
"files_reviewed": len(diff_files), "review_ms": review_ms,
|
||||
**({"deduped": n_deduped} if n_deduped else {}),
|
||||
}, rewake_summary=_format_vulns_summary(new_vulns, prefix="Commit security review found"))
|
||||
}, rewake_summary=_format_vulns_summary(new_vulns, prefix="Commit security review found"),
|
||||
additional_context=_commit_guidance,
|
||||
hook_event_name="PostToolUse")
|
||||
|
||||
# Rebuild guidance from new_vulns only — concrete_guidance from the LLM
|
||||
# still lists deduped entries.
|
||||
sys.stderr.write(PROVENANCE_BANNER + "\n\n"
|
||||
+ _format_vulns_guidance(new_vulns)
|
||||
+ CONTINUATION_SUFFIX + "\n")
|
||||
# exit(2) is preserved per the asyncRewake protocol — it's what CC
|
||||
# uses as the "force fix" signal that triggers the rewakeMessage flow.
|
||||
# The stderr.write was removed; additional_context above now carries
|
||||
# the same text via the modern JSON channel. See #1358/#1375/#1783.
|
||||
sys.exit(2)
|
||||
|
||||
def handle_push_sweep_posttooluse(input_data):
|
||||
@ -1629,17 +1675,23 @@ def handle_push_sweep_posttooluse(input_data):
|
||||
# Metrics — keep within the 10-key cap; agentic sub-metrics are dropped
|
||||
# here in favour of the push-sweep funnel keys (telemetry can join on session_id
|
||||
# to the per-commit fires for agentic detail). rewake_summary must ride
|
||||
# this line (CC reads only the first {-prefixed stdout line); it's a
|
||||
# no-op when new_vulns is empty since we exit 0 below.
|
||||
emit_metrics({
|
||||
# this line (CC reads only the first {-prefixed stdout line); the emit
|
||||
# is deferred to the two exit points below so the with-vulns path can
|
||||
# also pass additional_context in the same JSON line (#1375/#1783) —
|
||||
# the by-design "CC keeps only the first JSON line" constraint means
|
||||
# we can't emit twice. Builds the shared metrics dict here; vulns path
|
||||
# adds additional_context, no-vulns path emits as-is.
|
||||
_push_metrics = {
|
||||
**_base, "pushed": len(push_range), "unreviewed": len(tail),
|
||||
"prefix_advanced": prefix_advanced, "vulns_found": len(new_vulns),
|
||||
"files_reviewed": len(diff_files), "review_ms": review_ms,
|
||||
**({"deduped": n_deduped} if n_deduped else {}),
|
||||
}, rewake_summary=_format_vulns_summary(new_vulns, prefix="Push security review found"))
|
||||
}
|
||||
_push_rewake_summary = _format_vulns_summary(new_vulns, prefix="Push security review found")
|
||||
|
||||
if not new_vulns:
|
||||
debug_log("Push sweep: no new findings")
|
||||
emit_metrics(_push_metrics, rewake_summary=_push_rewake_summary)
|
||||
sys.exit(0)
|
||||
|
||||
# First-push of a big branch can surface many findings at once across
|
||||
@ -1692,9 +1744,14 @@ def handle_push_sweep_posttooluse(input_data):
|
||||
guidance = _format_vulns_guidance(reported) or ""
|
||||
else:
|
||||
guidance = concrete_guidance or _format_vulns_guidance(reported) or ""
|
||||
sys.stderr.write(
|
||||
PROVENANCE_BANNER + "\n\n" + guidance + CONTINUATION_SUFFIX + "\n"
|
||||
)
|
||||
# Emit metrics + additional_context together — single JSON line is the
|
||||
# contract CC's hook parser expects. exit(2) preserved as the asyncRewake
|
||||
# "force fix" trigger (see comment near handle_commit_review_posttooluse).
|
||||
# See #1358 / #1375 / #1783.
|
||||
emit_metrics(_push_metrics, rewake_summary=_push_rewake_summary,
|
||||
additional_context=(PROVENANCE_BANNER + "\n\n"
|
||||
+ guidance + CONTINUATION_SUFFIX + "\n"),
|
||||
hook_event_name="PostToolUse")
|
||||
sys.exit(2)
|
||||
|
||||
def handle_stop_hook(input_data):
|
||||
@ -1927,6 +1984,11 @@ def handle_stop_hook(input_data):
|
||||
# untracked_baseline_n is the signal for whether the UPS-time
|
||||
# untracked-snapshot capture actually ran.
|
||||
sweep_trimmed = {k: v for k, v in sweep.items() if k != "warn_unresolved_mask"}
|
||||
# Pass guidance via additional_context so CC surfaces the findings via
|
||||
# hookSpecificOutput.additionalContext instead of stderr-only (which
|
||||
# was the cause of "json output validation failed" / empty-reason UI in
|
||||
# #1375 / #1783). exit(2) preserved as the asyncRewake "force fix"
|
||||
# signal — that's the documented mechanism. See #1358 / #1375 / #1783.
|
||||
emit_metrics({
|
||||
"vulns_found": len(vulns),
|
||||
"untracked_baseline_n": len(untracked_at_baseline),
|
||||
@ -1940,10 +2002,10 @@ def handle_stop_hook(input_data):
|
||||
**({"diff_truncated": llm._last_review_truncated_bytes}
|
||||
if llm._last_review_truncated_bytes else {}),
|
||||
**sweep_trimmed,
|
||||
}, rewake_summary=_format_vulns_summary(vulns))
|
||||
|
||||
# Exit code 2 with stderr forces Claude to continue and fix
|
||||
sys.stderr.write(PROVENANCE_BANNER + "\n\n" + concrete_guidance + CONTINUATION_SUFFIX + "\n")
|
||||
}, rewake_summary=_format_vulns_summary(vulns),
|
||||
additional_context=(PROVENANCE_BANNER + "\n\n"
|
||||
+ concrete_guidance + CONTINUATION_SUFFIX + "\n"),
|
||||
hook_event_name="Stop")
|
||||
sys.exit(2)
|
||||
|
||||
if llm._last_call_claude_http_error is not None:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user