From 10396781286de516c7ed3847dcae7c1c48646e95 Mon Sep 17 00:00:00 2001 From: Nestor Colt Date: Tue, 5 May 2026 14:59:41 +0200 Subject: [PATCH 01/10] feat(broadcast): per-injection size budget, empty-body suppression, content-hash dedup, severity display label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0.1 — Cap PreToolUse injections at 4096 bytes and UserPromptSubmit at 8192 bytes (configurable via BROADCAST_MAX_BYTES_PRETOOL / _PROMPT). Claude Code's additionalContext has a 10K hard limit; exceeding it makes the harness write to a temp file and the model receives a filepath instead of body content. format_broadcast_banner and format_critical_context now truncate per message and enforce a cumulative byte cap, with [...truncated] marker. P0.2 — Suppress broadcasts whose body conveys no signal ("No active signals.", "No inject blocks.", "No data.", "None.", empty/whitespace). New helper _body_is_empty() applied in check_and_inject_broadcasts loop and format_critical_context. Eliminates the [ALERT] No active signals. PreToolUse injection that issue #34713 documents as a hook-error contamination vector. A.1 — Per-call dedup across messages with the same content_hash inside one formatting pass. When multiple messages share content_hash, keep only the newest by created_at. Falls back to _msg_hash() when content_hash field is absent so existing broadcasts dedup too. A.3 — Display-label map decouples canonical severity (info/alert/critical) from header rendering. Prevents low-priority info content from rendering with [ALERT] framing. Co-Authored-By: Claude Opus 4.7 --- hooks/config.py | 6 ++ hooks/context/broadcast.py | 96 +++++++++++++++++++++++++-- tests/context/test_broadcast.py | 113 ++++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 5 deletions(-) diff --git a/hooks/config.py b/hooks/config.py index 2e1ae83..484b0b3 100644 --- a/hooks/config.py +++ b/hooks/config.py @@ -365,6 +365,12 @@ def _env_bool(key: str, default: str = "false") -> bool: # Per-prompt cap. Raised 2→6→8 so brain feed (5 entries) + amygdala (1-2) # + profile transitions all land in the same prompt without contention. BROADCAST_MAX_PER_PROMPT: int = int(os.getenv("BROADCAST_MAX_PER_PROMPT", "8")) +# Per-injection byte caps. Claude Code's additionalContext has a 10,000-char +# hard limit; exceeding it makes the harness write to a temp file and the +# model receives a filepath instead of the body. Cap below the limit with +# envelope headroom. +BROADCAST_MAX_BYTES_PRETOOL: int = int(os.getenv("BROADCAST_MAX_BYTES_PRETOOL", "4096")) +BROADCAST_MAX_BYTES_PROMPT: int = int(os.getenv("BROADCAST_MAX_BYTES_PROMPT", "8192")) BROADCAST_PERSISTENT_THROTTLE = _env_bool("BROADCAST_PERSISTENT_THROTTLE", "true") BROADCAST_DELIVERY_STATE_FILE: str = os.getenv( "BROADCAST_DELIVERY_STATE_FILE", diff --git a/hooks/context/broadcast.py b/hooks/context/broadcast.py index 207302f..7f12645 100644 --- a/hooks/context/broadcast.py +++ b/hooks/context/broadcast.py @@ -18,6 +18,8 @@ BROADCAST_DELIVERY_STATE_FILE, BROADCAST_ENABLED, BROADCAST_FILE, + BROADCAST_MAX_BYTES_PRETOOL, + BROADCAST_MAX_BYTES_PROMPT, BROADCAST_MAX_MESSAGES, BROADCAST_MAX_PER_PROMPT, BROADCAST_MIN_INTERVAL_SEC, @@ -26,6 +28,61 @@ _SEVERITY_RANK = {"nuclear": 0, "critical": 1, "alert": 2, "warning": 3, "info": 4, "resolved": 5} +# Display label for the BROADCAST header. Source severity stays canonical +# (info/alert/critical) for routing; the display map prevents low-priority +# `info` content from rendering as `[ALERT]`-style framing that Claude Code +# treats as a hook-error contamination signal (issue #34713). +_SEVERITY_DISPLAY_LABEL = { + "nuclear": "NUCLEAR", + "critical": "CRITICAL", + "alert": "ALERT", + "warning": "WARNING", + "info": "INFO", + "resolved": "RESOLVED", +} + +# Bodies that signal "no content to deliver" — emitted by the brain adapter +# when the upstream feed is empty. Suppressed before formatting so the model +# does not see noise framed as an alert. +_EMPTY_BODY_SENTINELS = frozenset( + { + "no active signals.", + "no inject blocks.", + "no data.", + "none.", + "", + } +) + + +def _body_is_empty(msg: dict) -> bool: + """Return True for messages whose body conveys no signal.""" + body = (msg.get("message") or "").strip() + if not body: + return True + # Strip leading "[Title]\n" prefix if present so titled empty bodies + # like "[Active Signals]\nNo active signals." are also caught. + if body.startswith("[") and "]\n" in body: + body = body.split("]\n", 1)[1].strip() + return body.lower() in _EMPTY_BODY_SENTINELS + + +def _truncate_body(body: str, max_bytes: int) -> str: + """Truncate utf-8 body to max_bytes with a marker, preserving char boundaries.""" + if max_bytes <= 0: + return "" + encoded = body.encode("utf-8") + if len(encoded) <= max_bytes: + return body + marker = "\n[...truncated]" + keep = max(0, max_bytes - len(marker.encode("utf-8"))) + truncated = encoded[:keep].decode("utf-8", errors="ignore") + return truncated + marker + + +def _display_label(severity: str) -> str: + return _SEVERITY_DISPLAY_LABEL.get(severity.lower(), severity.upper()) + def _delivery_state_path() -> Path: return Path(BROADCAST_DELIVERY_STATE_FILE).expanduser() @@ -640,9 +697,10 @@ def get_active_sessions(cleanup: bool = False, include_all: bool = False) -> dic def format_broadcast_banner(msg: dict) -> str: - severity = msg.get("severity", "alert").upper() + severity_raw = msg.get("severity", "alert") + severity = _display_label(severity_raw) source = msg.get("source", "unknown") - message = msg.get("message", "") + message = _truncate_body(msg.get("message", ""), BROADCAST_MAX_BYTES_PROMPT) msg_id = msg.get("id", "") lines = [ @@ -658,11 +716,37 @@ def format_broadcast_banner(msg: dict) -> str: def format_critical_context(msgs: list[dict]) -> str: if not msgs: return "" - lines = ["BROADCAST ALERTS (PreToolUse):"] + # Drop empty-body messages — `[ALERT] No active signals.` is a #34713 + # contamination vector and conveys zero signal. + msgs = [m for m in msgs if not _body_is_empty(m)] + if not msgs: + return "" + # Dedup within one formatting pass: when multiple messages share a + # content_hash, keep only the most recent (highest created_at). + seen: dict[str, dict] = {} for m in msgs: + h = m.get("content_hash") or _msg_hash(m) + prev = seen.get(h) + if prev is None or (m.get("created_at", "") > prev.get("created_at", "")): + seen[h] = m + deduped = list(seen.values()) + deduped.sort(key=lambda m: _SEVERITY_RANK.get(m.get("severity", "info"), 9)) + + header = "BROADCAST ALERTS (PreToolUse):" + budget = max(0, BROADCAST_MAX_BYTES_PRETOOL - len(header.encode("utf-8")) - 1) + lines = [header] + for m in deduped: msg_id = m.get("id", "") - sev = m.get("severity", "critical").upper() - lines.append(f" - [{sev}] (id:{msg_id}) {m['message']}") + sev = _display_label(m.get("severity", "alert")) + body = _truncate_body(m.get("message", ""), max(0, budget // max(1, len(deduped)))) + line = f" - [{sev}] (id:{msg_id}) {body}" + line_bytes = len(line.encode("utf-8")) + 1 + if line_bytes > budget: + break + lines.append(line) + budget -= line_bytes + if len(lines) == 1: + return "" return "\n".join(lines) @@ -689,6 +773,8 @@ def check_and_inject_broadcasts(session_id: str) -> None: injected = 0 for msg in pending: skip_reason = _should_skip(msg, sess_state, now_ts) + if not skip_reason and _body_is_empty(msg): + skip_reason = "empty" if not skip_reason and injected >= BROADCAST_MAX_PER_PROMPT: skip_reason = "cap" diff --git a/tests/context/test_broadcast.py b/tests/context/test_broadcast.py index 280bd3d..c1742f5 100644 --- a/tests/context/test_broadcast.py +++ b/tests/context/test_broadcast.py @@ -510,3 +510,116 @@ def test_invalid_severity_defaults_to_alert(self, broadcast_file): create_broadcast("msg", severity="nonexistent") msgs = _load_broadcasts() assert msgs[0]["severity"] == "alert" + + +# --------------------------------------------------------------------------- +# Injection robustness — size budget, empty suppression, dedup, display label +# (P0.1, P0.2, A.1, A.3) +# --------------------------------------------------------------------------- + + +class TestInjectionRobustness: + def test_body_is_empty_for_no_signals(self): + from hooks.context.broadcast import _body_is_empty + + assert _body_is_empty({"message": "No active signals."}) + assert _body_is_empty({"message": "[Active Signals]\nNo active signals."}) + assert _body_is_empty({"message": " no data. "}) + assert _body_is_empty({"message": ""}) + assert _body_is_empty({}) + + def test_body_is_empty_false_for_real_content(self): + from hooks.context.broadcast import _body_is_empty + + assert not _body_is_empty({"message": "deploy freeze until 3am"}) + assert not _body_is_empty({"message": "[Active Signals]\nlitellm-0 down"}) + + def test_truncate_body_under_limit_passthrough(self): + from hooks.context.broadcast import _truncate_body + + assert _truncate_body("hello", 100) == "hello" + + def test_truncate_body_over_limit_marks(self): + from hooks.context.broadcast import _truncate_body + + body = "x" * 1000 + truncated = _truncate_body(body, 200) + assert len(truncated.encode("utf-8")) <= 200 + assert truncated.endswith("[...truncated]") + + def test_display_label_info_not_alert(self): + from hooks.context.broadcast import _display_label + + assert _display_label("info") == "INFO" + assert _display_label("alert") == "ALERT" + assert _display_label("critical") == "CRITICAL" + assert _display_label("warning") == "WARNING" + + def test_format_critical_context_skips_empty(self): + from hooks.context.broadcast import format_critical_context + + msgs = [ + {"id": "a", "severity": "alert", "message": "No active signals."}, + {"id": "b", "severity": "alert", "message": ""}, + ] + assert format_critical_context(msgs) == "" + + def test_format_critical_context_dedups_by_content_hash(self): + from hooks.context.broadcast import format_critical_context + + msgs = [ + { + "id": "a", + "severity": "alert", + "message": "litellm down", + "channel": "ops", + "content_hash": "h1", + "created_at": "2026-05-05T10:00:00Z", + }, + { + "id": "b", + "severity": "alert", + "message": "litellm down", + "channel": "ops", + "content_hash": "h1", + "created_at": "2026-05-05T10:01:00Z", + }, + ] + out = format_critical_context(msgs) + # Only the newer (id:b) should remain. + assert "id:b" in out + assert "id:a" not in out + + def test_format_critical_context_enforces_byte_cap(self): + from hooks.context.broadcast import format_critical_context + + # 50 messages with 1KB body each — total far exceeds 4KB cap. + msgs = [ + { + "id": f"m{i}", + "severity": "alert", + "message": "x" * 1024, + "content_hash": f"h{i}", + "created_at": f"2026-05-05T10:{i:02d}:00Z", + } + for i in range(50) + ] + out = format_critical_context(msgs) + assert len(out.encode("utf-8")) <= 4096 + + def test_format_broadcast_banner_truncates_long_body(self): + from hooks.context.broadcast import format_broadcast_banner + + msg = {"severity": "info", "source": "test", "message": "x" * 20000, "id": "abc"} + out = format_broadcast_banner(msg) + assert len(out.encode("utf-8")) <= 8192 + 200 # body cap + envelope + assert "[...truncated]" in out + + def test_format_broadcast_banner_uses_display_label(self): + from hooks.context.broadcast import format_broadcast_banner + + msg = {"severity": "info", "source": "test", "message": "ok"} + out = format_broadcast_banner(msg) + assert "[INFO]" in out + assert "[ALERT]" not in out + From f9e9e466f75be6413e72ef7fea493a955d8b9819 Mon Sep 17 00:00:00 2001 From: Nestor Colt Date: Tue, 5 May 2026 15:00:30 +0200 Subject: [PATCH 02/10] feat(broadcast): default-off PreToolUse alert injection (P1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BROADCAST_CRITICAL_ON_PRETOOL: true → false BROADCAST_PRETOOL_MIN_SEVERITY: alert → critical Routine alert-tier broadcasts no longer fire on every tool call. Only true emergencies (critical) reach PreToolUse. Routine alerts still land at UserPromptSubmit once per turn. Eliminates the per-tool-call [ALERT]-shaped injections that issue #34713 documents as eroding model confidence in hook-dense setups. Operators on active incident response can opt back in via ~/.agentihooks/.env (existing user values still win via os.environ.setdefault precedence at config.py:38). .env.example gains a BROADCAST SYSTEM section documenting the new defaults and the size-cap knobs from the previous commit. Co-Authored-By: Claude Opus 4.7 --- .env.example | 22 ++++++++++++++++++++++ hooks/config.py | 11 +++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 83081e6..2ab3ded 100644 --- a/.env.example +++ b/.env.example @@ -166,3 +166,25 @@ # GITFOAM_BINARY=~/.cargo/bin/gitfoam # Local dev path to gitfoam repo (used if the binary is missing and you want to cargo-install from source) # GITFOAM_LOCAL_SOURCE=/home/iamroot/dev/tcc-ecosystem/gitfoam + +# ============================================================================= +# BROADCAST SYSTEM — fleet messaging +# ============================================================================= +# Severity tiers: +# info → once per session (deduped via delivered_to) +# alert → every UserPromptSubmit (persistent, ack-only) +# critical → every UserPromptSubmit + every PreToolUse (when enabled below) +# +# Defaults (changed 2026-05-05): +# BROADCAST_CRITICAL_ON_PRETOOL=false (was true) — gate alerts off PreToolUse +# BROADCAST_PRETOOL_MIN_SEVERITY=critical (was alert) — only emergencies fire on tool calls +# These defaults reduce hook-error contamination per Claude Code issue #34713. +# Operators on incident response can opt back in: +# BROADCAST_CRITICAL_ON_PRETOOL=true +# BROADCAST_PRETOOL_MIN_SEVERITY=alert +# +# Per-injection byte caps. Claude Code's additionalContext has a 10,000-char +# hard limit; exceeding it makes the harness silently substitute a filepath +# for the body. Raise carefully. +# BROADCAST_MAX_BYTES_PRETOOL=4096 +# BROADCAST_MAX_BYTES_PROMPT=8192 diff --git a/hooks/config.py b/hooks/config.py index 484b0b3..0dc5c72 100644 --- a/hooks/config.py +++ b/hooks/config.py @@ -352,8 +352,15 @@ def _env_bool(key: str, default: str = "false") -> bool: BROADCAST_ENABLED = _env_bool("BROADCAST_ENABLED", "true") BROADCAST_FILE: str = os.getenv("BROADCAST_FILE", str(Path.home() / ".agentihooks" / "broadcast.json")) BROADCAST_MAX_MESSAGES: int = int(os.getenv("BROADCAST_MAX_MESSAGES", "50")) -BROADCAST_CRITICAL_ON_PRETOOL = _env_bool("BROADCAST_CRITICAL_ON_PRETOOL", "true") -BROADCAST_PRETOOL_MIN_SEVERITY: str = os.getenv("BROADCAST_PRETOOL_MIN_SEVERITY", "alert") +# Default OFF — alert-tier broadcasts on every PreToolUse is a #34713 +# contamination vector ("Hook Error"-shaped injections erode model +# confidence at scale). Operators on incident response can opt back in via +# ~/.agentihooks/.env. +BROADCAST_CRITICAL_ON_PRETOOL = _env_bool("BROADCAST_CRITICAL_ON_PRETOOL", "false") +# PreToolUse threshold raised to "critical" so only true-emergency +# broadcasts fire on every tool call. Routine alerts still land via +# UserPromptSubmit. +BROADCAST_PRETOOL_MIN_SEVERITY: str = os.getenv("BROADCAST_PRETOOL_MIN_SEVERITY", "critical") # Cadence controls — skip re-injecting identical or too-frequent broadcasts per session. BROADCAST_DEDUP_BY_HASH = _env_bool("BROADCAST_DEDUP_BY_HASH", "true") From b7ad1027e5faa3c903bb649828bbac11a72993bd Mon Sep 17 00:00:00 2001 From: Nestor Colt Date: Tue, 5 May 2026 15:01:50 +0200 Subject: [PATCH 03/10] feat(broadcast,brain): per-message content_hash dedup with stable UUIDs (P1.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a stable `content_hash` field (sha256(channel|severity|message)[:16]) to every broadcast entry written by create_broadcast. Decouples dedup from the random UUID minted on every call. brain_adapter._publish_entries now does a diff-based clear instead of clear-and-republish: entries on BRAIN_CHANNEL whose content_hash is also in the new batch survive untouched. Only genuinely new content gets a fresh UUID. Why: brain adapter republishes the same hot-arcs / operator-intent / tick-diff content every refresh tick. Old code did `clear_broadcasts(channel) -> create_broadcast` which minted new UUIDs unconditionally — so the per-(session, message_id) delivery state in broadcast_delivery_state.json never matched, and dedup never fired even for unchanged content. Sessions saw the same banner repeatedly under different ids. New helper `find_broadcast_by_content_hash(hash, channel=None)` returns the most recent broadcast matching a content hash, scoped to a channel. Tests: 4 new in TestContentHashField. All 48 existing broadcast tests pass. Brain HTTP tests untouched (14 pass). Co-Authored-By: Claude Opus 4.7 --- hooks/context/brain_adapter.py | 42 +++++++++++++++++++++--- hooks/context/broadcast.py | 21 ++++++++++++ tests/context/test_broadcast.py | 57 +++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 5 deletions(-) diff --git a/hooks/context/brain_adapter.py b/hooks/context/brain_adapter.py index fb1ed98..43708e6 100644 --- a/hooks/context/brain_adapter.py +++ b/hooks/context/brain_adapter.py @@ -296,7 +296,12 @@ def _publish_entries(entries: list[BrainEntry]) -> int: except ImportError: return 0 - from hooks.context.broadcast import clear_broadcasts, create_broadcast + from hooks.context.broadcast import ( + _load_broadcasts, + _msg_hash, + _save_broadcasts, + create_broadcast, + ) from hooks.telemetry import span_ctx entries = [_shrink_entry(e, BRAIN_HOT_ARCS_TOP_N, BRAIN_PAYLOAD_MAX_BYTES) for e in entries] @@ -309,11 +314,38 @@ def _publish_entries(entries: list[BrainEntry]) -> int: "total_bytes": total_bytes, }, ) as span: - # Clear existing brain messages on this channel - clear_broadcasts(channel=BRAIN_CHANNEL) + # Diff-based clear: keep entries on this channel whose content_hash + # also exists in the new batch (stable UUIDs across ticks → delivery + # state finally matches; resolves "same content, fresh id" feedback + # loop where dedup never hit because every republish minted a new + # uuid). + new_hashes: dict[str, BrainEntry] = {} + for entry in entries: + probe = { + "channel": BRAIN_CHANNEL, + "severity": entry.severity, + "message": f"[{entry.title}]\n{entry.content}".strip(), + } + new_hashes[_msg_hash(probe)] = entry + + existing = _load_broadcasts() + kept_hashes: set[str] = set() + rest: list[dict] = [] + for m in existing: + if m.get("channel") == BRAIN_CHANNEL: + h = m.get("content_hash") + if h and h in new_hashes: + rest.append(m) + kept_hashes.add(h) + # else: drop — content no longer in batch + else: + rest.append(m) + _save_broadcasts(rest) count = 0 - for entry in entries: + for h, entry in new_hashes.items(): + if h in kept_hashes: + continue # content already live with a stable id, do not re-mint msg_id = create_broadcast( message=f"[{entry.title}]\n{entry.content}", severity=entry.severity, @@ -325,7 +357,7 @@ def _publish_entries(entries: list[BrainEntry]) -> int: if msg_id: count += 1 - span.set_attrs({"published_count": count}) + span.set_attrs({"published_count": count, "kept_count": len(kept_hashes)}) return count diff --git a/hooks/context/broadcast.py b/hooks/context/broadcast.py index 7f12645..449266f 100644 --- a/hooks/context/broadcast.py +++ b/hooks/context/broadcast.py @@ -342,6 +342,10 @@ def create_broadcast( } if channel: entry["channel"] = channel + # Stable content hash decouples dedup from the random uuid above so the + # brain adapter (which republishes the same content with fresh ids each + # tick) does not generate false-novelty injections. + entry["content_hash"] = _msg_hash(entry) msgs = _load_broadcasts() msgs.append(entry) @@ -354,6 +358,23 @@ def create_broadcast( return msg_id +def find_broadcast_by_content_hash(content_hash: str, channel: str | None = None) -> dict | None: + """Return the most recent broadcast matching content_hash + channel, else None.""" + if not content_hash: + return None + msgs = _load_broadcasts() + matches = [ + m + for m in msgs + if m.get("content_hash") == content_hash + and (channel is None or m.get("channel") == channel) + ] + if not matches: + return None + matches.sort(key=lambda m: m.get("created_at", "")) + return matches[-1] + + def list_broadcasts() -> list[dict]: return _load_broadcasts() diff --git a/tests/context/test_broadcast.py b/tests/context/test_broadcast.py index c1742f5..65e7739 100644 --- a/tests/context/test_broadcast.py +++ b/tests/context/test_broadcast.py @@ -623,3 +623,60 @@ def test_format_broadcast_banner_uses_display_label(self): assert "[INFO]" in out assert "[ALERT]" not in out + +# --------------------------------------------------------------------------- +# P1.2 — content_hash field on every broadcast entry +# --------------------------------------------------------------------------- + + +class TestContentHashField: + def test_create_broadcast_sets_content_hash(self, broadcast_file): + from hooks.context.broadcast import _load_broadcasts, create_broadcast + + with patch("hooks.context.broadcast._broadcast_path", return_value=broadcast_file): + create_broadcast("hello world", severity="alert", channel="ops") + msgs = _load_broadcasts() + assert msgs[0]["content_hash"] + assert isinstance(msgs[0]["content_hash"], str) + assert len(msgs[0]["content_hash"]) == 16 + + def test_content_hash_stable_for_same_content(self, broadcast_file): + from hooks.context.broadcast import _load_broadcasts, create_broadcast + + with patch("hooks.context.broadcast._broadcast_path", return_value=broadcast_file): + create_broadcast("same body", severity="info", channel="brain") + create_broadcast("same body", severity="info", channel="brain") + msgs = _load_broadcasts() + assert msgs[0]["content_hash"] == msgs[1]["content_hash"] + # but ids are different (UUIDs) + assert msgs[0]["id"] != msgs[1]["id"] + + def test_find_broadcast_by_content_hash(self, broadcast_file): + from hooks.context.broadcast import ( + _load_broadcasts, + create_broadcast, + find_broadcast_by_content_hash, + ) + + with patch("hooks.context.broadcast._broadcast_path", return_value=broadcast_file): + create_broadcast("body A", severity="info", channel="brain") + msgs = _load_broadcasts() + h = msgs[0]["content_hash"] + found = find_broadcast_by_content_hash(h, channel="brain") + assert found is not None + assert found["message"] == "body A" + + def test_find_broadcast_by_content_hash_channel_mismatch(self, broadcast_file): + from hooks.context.broadcast import ( + _load_broadcasts, + create_broadcast, + find_broadcast_by_content_hash, + ) + + with patch("hooks.context.broadcast._broadcast_path", return_value=broadcast_file): + create_broadcast("body A", severity="info", channel="brain") + msgs = _load_broadcasts() + h = msgs[0]["content_hash"] + found = find_broadcast_by_content_hash(h, channel="ops") + assert found is None + From 3fb4e7c12b9432faaf294d84172e0f6b6aae57e6 Mon Sep 17 00:00:00 2001 From: Nestor Colt Date: Tue, 5 May 2026 15:03:41 +0200 Subject: [PATCH 04/10] feat(brain): halt-phrase scrub, framing prefix, empty-signal severity normalization (P1.3, P1.4, A.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1.3 — Brain entries pass through _scrub_halt_phrases() before publish. Substring rewrites (case-insensitive): "do you understand" → "is this clear" "elaborate to me" → "expand on" "stop all tool calls" → "pause tool usage" "wait for confirmation" → "awaiting input" operator-behavior.md treats these phrases as STOP signals. Brain content is system-channel state, never an operator directive — but a literal substring match in the model's halt rule cannot distinguish source. P1.4 — _normalize_severity_for_empty() forces severity=info when an "Active Signals" / "Amygdala" entry has an empty body ("No active signals.", "No data.", "None.", ""). The #34713 contamination shape is [ALERT]-tier framing of empty payloads; downgrading at the source prevents it regardless of what upstream emits. Real signals keep their declared severity. A.2 — _wrap_with_framing() prefixes long natural-language broadcasts ("Operator Intent", "Active Hot Arcs", "last-tick-diff", "Tick Diff") with "STATUS BROADCAST (informational, no action required):" so the model parser is less likely to treat the system-reminder as a redirect from the operator. broadcast._VALID_SEVERITIES expanded to {nuclear, critical, alert, warning, info, resolved} — the full _SEVERITY_RANK vocabulary. Old code silently clobbered any non-{critical,alert,info} severity to "alert", which is how brain_keeper's `severity: warning` empty-signals entries ended up rendered as [ALERT]. _DEFAULT_TTL and _DEFAULT_PERSISTENT extended to match. Tests: 8 new in TestBrainAdapterHelpers. All 254 context tests pass. Co-Authored-By: Claude Opus 4.7 --- hooks/context/brain_adapter.py | 69 ++++++++++++++++++++++++++++++++ hooks/context/broadcast.py | 8 ++-- tests/context/test_brain_http.py | 67 +++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/hooks/context/brain_adapter.py b/hooks/context/brain_adapter.py index 43708e6..487180a 100644 --- a/hooks/context/brain_adapter.py +++ b/hooks/context/brain_adapter.py @@ -50,6 +50,70 @@ _HASH_CACHE_FILE = AGENTIHOOKS_HOME / "brain_adapter_hash.json" +_HALT_PHRASE_REWRITES: tuple[tuple[str, str], ...] = ( + ("do you understand", "is this clear"), + ("elaborate to me", "expand on"), + ("stop all tool calls", "pause tool usage"), + ("wait for confirmation", "awaiting input"), +) + + +def _scrub_halt_phrases(text: str) -> str: + """Rewrite halt-trigger substrings that the operator profile (operator-behavior.md) + treats as STOP signals. Brain content is system-channel state, never a directive + — but a literal substring match in the model's halt rule cannot tell them apart. + + Case-insensitive; preserves surrounding text. + """ + if not text: + return text + out = text + for needle, replacement in _HALT_PHRASE_REWRITES: + # Case-insensitive replace preserving original casing of the rest. + i = 0 + while True: + lower = out.lower() + j = lower.find(needle, i) + if j < 0: + break + out = out[:j] + replacement + out[j + len(needle):] + i = j + len(replacement) + return out + + +_FRAMED_TITLES = frozenset( + {"Operator Intent", "Active Hot Arcs", "last-tick-diff", "Tick Diff"} +) + + +def _wrap_with_framing(title: str, body: str) -> str: + """For long natural-language broadcasts, prefix with explicit informational + framing so the model does not pivot as if the operator gave a new directive. + """ + if title in _FRAMED_TITLES: + return ( + "STATUS BROADCAST (informational, no action required):\n\n" + + body + ) + return body + + +_EMPTY_SIGNAL_BODIES = frozenset( + {"no active signals.", "no inject blocks.", "no data.", "none.", ""} +) + + +def _normalize_severity_for_empty(title: str, body: str, severity: str) -> str: + """Empty signal payloads should not ride the alert severity bus — that is + the #34713 contamination shape. Downgrade to info so the source side is + correct, regardless of what upstream emits. + """ + stripped = (body or "").strip().lower() + if title.lower() in {"active signals", "amygdala", "signals"} and stripped in _EMPTY_SIGNAL_BODIES: + return "info" + return severity + + def _load_persisted_hash() -> str: """Read last-published content hash from disk. Empty on miss.""" try: @@ -321,6 +385,11 @@ def _publish_entries(entries: list[BrainEntry]) -> int: # uuid). new_hashes: dict[str, BrainEntry] = {} for entry in entries: + entry.content = _scrub_halt_phrases(entry.content) + entry.content = _wrap_with_framing(entry.title, entry.content) + entry.severity = _normalize_severity_for_empty( + entry.title, entry.content, entry.severity + ) probe = { "channel": BRAIN_CHANNEL, "severity": entry.severity, diff --git a/hooks/context/broadcast.py b/hooks/context/broadcast.py index 449266f..4730b7d 100644 --- a/hooks/context/broadcast.py +++ b/hooks/context/broadcast.py @@ -156,9 +156,11 @@ def _record_delivery(sid: str, msg: dict, now_ts: float) -> None: # Default TTL per severity (seconds). 0 = use default. -_DEFAULT_TTL = {"critical": 1800, "alert": 3600, "info": 14400} -_DEFAULT_PERSISTENT = {"critical": True, "alert": True, "info": False} -_VALID_SEVERITIES = frozenset({"critical", "alert", "info"}) +_DEFAULT_TTL = {"nuclear": 1800, "critical": 1800, "alert": 3600, "warning": 3600, "info": 14400, "resolved": 1800} +_DEFAULT_PERSISTENT = {"nuclear": True, "critical": True, "alert": True, "warning": True, "info": False, "resolved": False} +_VALID_SEVERITIES = frozenset( + {"nuclear", "critical", "alert", "warning", "info", "resolved"} +) # Channels every session is subscribed to unconditionally. # The operator's rule: brain + amygdala are fleet-wide, no repo can drop them. diff --git a/tests/context/test_brain_http.py b/tests/context/test_brain_http.py index e597b9b..2c7e3d0 100644 --- a/tests/context/test_brain_http.py +++ b/tests/context/test_brain_http.py @@ -330,3 +330,70 @@ def test_brain_writer_http_noop_when_url_unset(monkeypatch): count, failed = bw._publish_to_http(markers, session_id="s") assert count == 0 assert failed == markers + + +# --------------------------------------------------------------------------- +# P1.3 — phrase scrub +# P1.4 — empty-signal severity normalization +# A.2 — framing prefix for natural-language broadcasts +# --------------------------------------------------------------------------- + + +class TestBrainAdapterHelpers: + def test_scrub_replaces_do_you_understand(self): + from hooks.context.brain_adapter import _scrub_halt_phrases + + assert _scrub_halt_phrases("Do you understand the situation?") == ( + "is this clear the situation?" + ) + + def test_scrub_replaces_elaborate_case_insensitive(self): + from hooks.context.brain_adapter import _scrub_halt_phrases + + assert _scrub_halt_phrases("Please ELABORATE TO ME on this.") == ( + "Please expand on on this." + ) + + def test_scrub_no_op_on_clean_text(self): + from hooks.context.brain_adapter import _scrub_halt_phrases + + assert _scrub_halt_phrases("nothing to scrub here") == "nothing to scrub here" + + def test_framing_prefix_applied_to_operator_intent(self): + from hooks.context.brain_adapter import _wrap_with_framing + + out = _wrap_with_framing("Operator Intent", "operator is doing X") + assert out.startswith("STATUS BROADCAST (informational, no action required):") + + def test_framing_prefix_skipped_for_unframed_titles(self): + from hooks.context.brain_adapter import _wrap_with_framing + + assert _wrap_with_framing("Active Signals", "real signal") == "real signal" + + def test_empty_signal_severity_downgraded_to_info(self): + from hooks.context.brain_adapter import _normalize_severity_for_empty + + assert ( + _normalize_severity_for_empty("Active Signals", "No active signals.", "alert") + == "info" + ) + assert ( + _normalize_severity_for_empty("Active Signals", "", "warning") == "info" + ) + + def test_real_signal_severity_preserved(self): + from hooks.context.brain_adapter import _normalize_severity_for_empty + + assert ( + _normalize_severity_for_empty("Active Signals", "litellm-0 down", "alert") + == "alert" + ) + + def test_severity_unchanged_for_non_signal_titles(self): + from hooks.context.brain_adapter import _normalize_severity_for_empty + + assert ( + _normalize_severity_for_empty("Operator Intent", "No active signals.", "info") + == "info" + ) + From ef393aaa328259e8dffc96349cc97b1fcfea268e Mon Sep 17 00:00:00 2001 From: Nestor Colt Date: Tue, 5 May 2026 15:04:18 +0200 Subject: [PATCH 05/10] test: stdout discipline check across all hook modules (P0.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Importing any hooks/* module must NOT write to stdout. Claude Code parses hook-process stdout as JSON; any module-level print or import-time warning silently corrupts the parse and drops additionalContext — broadcast injection silently disappears. New tests/test_import_hygiene.py spawns a subprocess per module and asserts captured stdout is empty after import. 75 modules covered. _ALLOWED_NOISY quarantine set is empty by design and documented as such — every future entry is a bug, not a feature. Co-Authored-By: Claude Opus 4.7 --- tests/test_import_hygiene.py | 87 ++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/test_import_hygiene.py diff --git a/tests/test_import_hygiene.py b/tests/test_import_hygiene.py new file mode 100644 index 0000000..90a9cad --- /dev/null +++ b/tests/test_import_hygiene.py @@ -0,0 +1,87 @@ +"""Stdout discipline test (P0.3). + +Importing any hooks/* module must NOT write to stdout. Claude Code parses +hook-process stdout as JSON; any module-level print/import-time warning +silently corrupts the parse and drops `additionalContext`. + +Per upstream protocol (https://code.claude.com/docs/en/hooks.md): +- exit 0 + valid JSON → fields applied +- exit 0 + invalid JSON → harness logs error, fields NOT applied +- exit 1/non-2 → non-blocking, stderr in transcript + +A noisy import means our broadcast injection silently disappears. This +test catches it at CI time before it ships. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.unit + +REPO_ROOT = Path(__file__).resolve().parent.parent +HOOKS_DIR = REPO_ROOT / "hooks" + +# Modules that legitimately print on import are quarantined here. Empty by +# design — every entry is a future bug. Add ONLY with a justifying comment. +_ALLOWED_NOISY: frozenset[str] = frozenset() + +# Pre-imports that must run before the target so package-init side effects +# (logger setup, env loading) execute under the captured stdout. +_PROBE_TEMPLATE = """ +import io, sys, importlib +sys.stdout = io.StringIO() +sys.stderr = io.StringIO() +mod = importlib.import_module({module!r}) +out = sys.stdout.getvalue() +err = sys.stderr.getvalue() +sys.__stdout__.write(out) +sys.__stderr__.write(err) +""" + + +def _all_hook_modules() -> list[str]: + """Return dotted module names for every .py under hooks/, excluding tests + and __pycache__. Skips files with leading underscore at the package root + so __main__ does not execute the CLI. + """ + modules: list[str] = [] + for path in HOOKS_DIR.rglob("*.py"): + if "__pycache__" in path.parts or path.name == "__main__.py": + continue + rel = path.relative_to(REPO_ROOT) + dotted = ".".join(rel.with_suffix("").parts) + modules.append(dotted) + return sorted(modules) + + +@pytest.mark.parametrize("module", _all_hook_modules()) +def test_module_import_does_not_write_to_stdout(module: str) -> None: + if module in _ALLOWED_NOISY: + pytest.skip(f"{module} is in _ALLOWED_NOISY quarantine") + + probe = _PROBE_TEMPLATE.format(module=module) + env = {**os.environ, "CLAUDE_HOOK_LOG_ENABLED": "false"} + proc = subprocess.run( + [sys.executable, "-c", probe], + cwd=REPO_ROOT, + capture_output=True, + text=True, + env=env, + timeout=30, + ) + if proc.returncode != 0: + pytest.fail( + f"{module}: import failed (exit {proc.returncode})\n" + f"stderr: {proc.stderr}\nstdout: {proc.stdout}" + ) + if proc.stdout: + pytest.fail( + f"{module}: writes to stdout on import — would corrupt hook JSON.\n" + f"captured stdout:\n{proc.stdout}" + ) From 49bef7b16d71c7f5a74cc9f77b4207f6783aa70c Mon Sep 17 00:00:00 2001 From: Nestor Colt Date: Tue, 5 May 2026 15:07:24 +0200 Subject: [PATCH 06/10] =?UTF-8?q?feat(cli):=20agentihooks=20doctor=20?= =?UTF-8?q?=E2=80=94=20hook=20injection=20probe=20(P0.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New subcommand: agentihooks doctor [--debug-hook] [--json]. Without --debug-hook: alias for `agentihooks status` (same checks). With --debug-hook: spawns a fresh `python -m hooks` process per event (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop), feeds a synthetic payload on stdin, and asserts the documented Claude Code protocol invariants: 1. Exit code is 0 or 2. Exit 1 (or any unexpected code) flagged. 2. SessionStart / UserPromptSubmit accept plain-text stdout, but it must stay under 10,000 chars. ≥10K triggers Claude Code's tempfile-substitution path — the model receives a filepath instead of body content (silent semantic loss). 3. PreToolUse / PostToolUse / Stop must emit valid hookSpecificOutput JSON or empty stdout; plain-text on those events is undocumented and may be ignored by some CC versions. 4. additionalContext, when present, is a string under 10,000 chars. This is the missing visibility layer for upstream issue #34713 + #19432 class bugs: silent additionalContext drops on malformed JSON, oversized payloads, exit code drift. Run it after any hook-stack change. Adds check_hook_injection() + format_hook_injection() to status_checker so the same logic is reusable from skills / MCP tooling. Tests: 5 new in TestHookInjectionProbe. Full suite 1047 passed. Co-Authored-By: Claude Opus 4.7 --- scripts/install.py | 35 ++++++++ scripts/status_checker.py | 157 +++++++++++++++++++++++++++++++++++ tests/test_status_checker.py | 87 +++++++++++++++++++ 3 files changed, 279 insertions(+) diff --git a/scripts/install.py b/scripts/install.py index 2f23b12..4698a77 100755 --- a/scripts/install.py +++ b/scripts/install.py @@ -4962,6 +4962,21 @@ def main() -> None: sub.add_parser("status", help="Show installation health, cost guardrails, and system state") + doctor_p = sub.add_parser( + "doctor", + help="Diagnose hook health: simulate every event, validate stdout JSON, surface broken hooks", + ) + doctor_p.add_argument( + "--debug-hook", + action="store_true", + help="Run each hook event with synthetic payload and assert protocol invariants", + ) + doctor_p.add_argument( + "--json", + action="store_true", + help="Emit machine-readable JSON instead of CLI report", + ) + prune_p = sub.add_parser("prune", help="Remove stale MCP entries from all config files") prune_p.add_argument("--verbose", "-v", action="store_true", help="Show details of each pruned entry") @@ -5268,6 +5283,26 @@ def main() -> None: from scripts.status_checker import format_cli, run_all_checks print(format_cli(run_all_checks())) + elif args.command == "doctor": + sys.path.insert(0, str(AGENTIHOOKS_ROOT)) + from scripts.status_checker import ( + check_hook_injection, + format_cli, + format_hook_injection, + run_all_checks, + ) + + if args.debug_hook: + result = check_hook_injection() + if args.json: + import json as _json + + print(_json.dumps(result, indent=2)) + else: + print(format_hook_injection(result)) + sys.exit(0 if result.get("ok") else 1) + else: + print(format_cli(run_all_checks())) elif args.command == "prune": sys.path.insert(0, str(AGENTIHOOKS_ROOT)) from scripts.sync_daemon import _get_valid_mcp_names, _prune_stale_mcp_servers diff --git a/scripts/status_checker.py b/scripts/status_checker.py index 91ca5ec..714381b 100644 --- a/scripts/status_checker.py +++ b/scripts/status_checker.py @@ -747,3 +747,160 @@ def main() -> None: if __name__ == "__main__": main() + + +# --------------------------------------------------------------------------- +# Hook injection probe (P0.4 — agentihooks doctor --debug-hook) +# --------------------------------------------------------------------------- + + +def _synthetic_payload(event: str) -> dict: + """Minimal valid payload per Claude Code hook protocol.""" + base = { + "session_id": "doctor-probe", + "transcript_path": "", + "cwd": str(Path.cwd()), + "hook_event_name": event, + } + if event in ("PreToolUse", "PostToolUse"): + base["tool_name"] = "Bash" + base["tool_input"] = {"command": "true"} + base["tool_response"] = {} + if event == "UserPromptSubmit": + base["prompt"] = "doctor probe" + return base + + +def check_hook_injection() -> dict: + """Run each hook event with a synthetic payload via `python -m hooks` and + assert the upstream Claude Code protocol invariants: + + 1. exit code is 0 or 2 (never an unexpected non-zero like 1) + 2. stdout is parseable JSON OR empty + 3. if hookSpecificOutput.additionalContext is present, it is a string + under 10,000 chars (the documented hard cap) + + Returns a dict {ok: bool, events: [...], warnings: [...]}. + """ + import json as _json + import os + import subprocess + import sys + + repo_root = Path(__file__).resolve().parent.parent + events = [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "Stop", + ] + results: list[dict] = [] + warnings: list[str] = [] + + for event in events: + payload = _synthetic_payload(event) + env = {**os.environ, "CLAUDE_HOOK_LOG_ENABLED": "false", "BROADCAST_ENABLED": "false"} + try: + proc = subprocess.run( + [sys.executable, "-m", "hooks"], + cwd=repo_root, + input=_json.dumps(payload), + capture_output=True, + text=True, + env=env, + timeout=30, + ) + except subprocess.TimeoutExpired: + results.append({"event": event, "ok": False, "reason": "timeout >30s"}) + warnings.append(f"{event}: hook timed out (>30s)") + continue + + out = (proc.stdout or "").strip() + rc = proc.returncode + ok = True + reason = None + ctx_len = 0 + + # Per Claude Code hooks protocol: + # - SessionStart / UserPromptSubmit: plain-text stdout is valid context. + # - PreToolUse / PostToolUse / Stop: prefer hookSpecificOutput JSON; + # plain-text stdout is undocumented and may be ignored by some versions. + accepts_plain_text = event in ("SessionStart", "UserPromptSubmit") + + if rc not in (0, 2): + ok = False + reason = f"exit code {rc} (expected 0 or 2)" + elif out: + parsed = None + try: + parsed = _json.loads(out) + except _json.JSONDecodeError as e: + if not accepts_plain_text: + ok = False + reason = ( + f"plain-text stdout on {event} is undocumented; emit " + f"hookSpecificOutput JSON instead ({e.msg})" + ) + else: + ctx_len = len(out) + if ctx_len >= 10000: + ok = False + reason = ( + f"plain-text stdout is {ctx_len} chars; ≥10000 triggers " + "Claude Code's tempfile-substitution path" + ) + if parsed is not None: + hso = parsed.get("hookSpecificOutput") or {} + ac = hso.get("additionalContext") + if ac is not None: + if not isinstance(ac, str): + ok = False + reason = "additionalContext must be a string" + else: + ctx_len = len(ac) + if ctx_len >= 10000: + ok = False + reason = ( + f"additionalContext is {ctx_len} chars; ≥10000 triggers " + "Claude Code's tempfile-substitution path (model receives a path, not body)" + ) + + results.append( + { + "event": event, + "ok": ok, + "exit_code": rc, + "stdout_bytes": len(out), + "additional_context_chars": ctx_len, + "reason": reason, + "stderr_first_line": (proc.stderr or "").splitlines()[0] if proc.stderr else "", + } + ) + if not ok and reason: + warnings.append(f"{event}: {reason}") + + return {"ok": all(r["ok"] for r in results), "events": results, "warnings": warnings} + + +def format_hook_injection(result: dict) -> str: + lines = ["AgentiHooks doctor — hook injection probe", "=" * 50] + for ev in result.get("events", []): + status = "✓" if ev["ok"] else "✗" + lines.append( + f" {status} {ev['event']:<20} exit={ev['exit_code']} " + f"out_bytes={ev['stdout_bytes']} ctx_chars={ev['additional_context_chars']}" + ) + if ev.get("reason"): + lines.append(f" reason: {ev['reason']}") + if ev.get("stderr_first_line"): + lines.append(f" stderr: {ev['stderr_first_line']}") + if result.get("warnings"): + lines.append("") + lines.append("Warnings:") + for w in result["warnings"]: + lines.append(f" - {w}") + lines.append("") + lines.append("Overall: " + ("OK ✓" if result.get("ok") else "FAILED ✗")) + return "\n".join(lines) + diff --git a/tests/test_status_checker.py b/tests/test_status_checker.py index 0489964..e2bd7e0 100644 --- a/tests/test_status_checker.py +++ b/tests/test_status_checker.py @@ -278,3 +278,90 @@ def test_with_session(self): with patch("hooks._redis.get_redis", return_value=None): results = run_all_checks(session_id="test-sess") assert "session" in results + + +# --------------------------------------------------------------------------- +# P0.4 — agentihooks doctor --debug-hook +# --------------------------------------------------------------------------- + + +class TestHookInjectionProbe: + def test_synthetic_payload_minimal_fields(self): + from scripts.status_checker import _synthetic_payload + + for ev in ("SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"): + p = _synthetic_payload(ev) + assert p["session_id"] == "doctor-probe" + assert p["hook_event_name"] == ev + + def test_synthetic_payload_pretool_includes_tool_fields(self): + from scripts.status_checker import _synthetic_payload + + p = _synthetic_payload("PreToolUse") + assert p["tool_name"] == "Bash" + assert p["tool_input"] == {"command": "true"} + + def test_format_hook_injection_renders_passing_run(self): + from scripts.status_checker import format_hook_injection + + result = { + "ok": True, + "events": [ + { + "event": "SessionStart", + "ok": True, + "exit_code": 0, + "stdout_bytes": 0, + "additional_context_chars": 0, + "reason": None, + "stderr_first_line": "", + } + ], + "warnings": [], + } + out = format_hook_injection(result) + assert "Overall: OK" in out + assert "✓" in out + + def test_format_hook_injection_renders_failure(self): + from scripts.status_checker import format_hook_injection + + result = { + "ok": False, + "events": [ + { + "event": "PreToolUse", + "ok": False, + "exit_code": 1, + "stdout_bytes": 12, + "additional_context_chars": 0, + "reason": "exit code 1 (expected 0 or 2)", + "stderr_first_line": "boom", + } + ], + "warnings": ["PreToolUse: exit code 1 (expected 0 or 2)"], + } + out = format_hook_injection(result) + assert "Overall: FAILED" in out + assert "exit code 1" in out + assert "stderr: boom" in out + + def test_check_hook_injection_real_run(self): + """Smoke test against the real hook process. Asserts the probe runs + end-to-end without raising; does NOT assert ok=True (fleet may have + legitimate findings that the operator routes separately).""" + from scripts.status_checker import check_hook_injection + + result = check_hook_injection() + assert "ok" in result + assert "events" in result + assert len(result["events"]) == 5 + for ev in result["events"]: + assert ev["event"] in { + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "Stop", + } + From 51aaa8277aab675a9c6cea48b1981f95802d1abb Mon Sep 17 00:00:00 2001 From: Nestor Colt Date: Tue, 5 May 2026 15:12:04 +0200 Subject: [PATCH 07/10] fix(ci-manifesto): cap SessionStart injection at 7500 bytes (P0.4 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Anton CI manifesto is ~36 KB. Claude Code injects hook stdout AND additionalContext into context up to a 10,000-char hard cap; beyond that the harness writes to a temp file and the model receives a filepath instead of body content. Until this fix, every SessionStart silently lost the entire manifesto — the model thought it loaded but actually saw a path reference. Bundle CLAUDE.md's "If the manifesto is not visible, alert operator" rule never fired because the model believed the inject succeeded. `agentihooks doctor --debug-hook` flagged this on its first run. Fix: _build_injection() now caps at 7500 bytes (header + body + footer) to leave headroom for other on_session_start contributors (TOKEN CONTROL, thinking guidance, brain inject). When the manifesto exceeds budget, the body is truncated and a footer points the agent at the canonical file path: [TRUNCATED — the full doctrine is at: /path/to/MANIFESTO.md] Read that path with the Read tool when you need section details. Doctor probe now shows SessionStart at 8526 bytes — under cap, all green. Tests: 3 new in TestInjectionBudget. Full suite 1050 passed. Co-Authored-By: Claude Opus 4.7 --- hooks/context/ci_manifesto.py | 42 ++++++++++++++++++++++++----- tests/test_ci_manifesto.py | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 tests/test_ci_manifesto.py diff --git a/hooks/context/ci_manifesto.py b/hooks/context/ci_manifesto.py index 14ba138..a7047c7 100644 --- a/hooks/context/ci_manifesto.py +++ b/hooks/context/ci_manifesto.py @@ -253,17 +253,47 @@ def contains_pr_signal(text: str) -> bool: return _signal_match(text, get_pr_signals()) +# Claude Code injects hook stdout / additionalContext into context up to a +# 10,000-char cap; beyond that the harness writes to a temp file and the +# model receives a filepath instead of body content. The full Anton CI +# manifesto is ~36KB, so we MUST inject under-cap or it gets silently +# substituted (and the bundle CLAUDE.md rule "If the manifesto is not +# visible, alert operator" never fires because the model thinks it loaded). +# +# Strategy: emit the manifesto verbatim up to a safe byte budget, then a +# trailer pointing the agent at the canonical file path so the model can +# Read on demand for any section it needs in full. +_INJECTION_BUDGET_BYTES = 7500 +_INJECTION_TRAILER_TEMPLATE = ( + "\n[TRUNCATED — the full doctrine is at: {path}]\n" + "Read that path with the Read tool when you need section details.\n" + "=== END CI MANIFESTO ===\n" +) +_INJECTION_HEADER_TEMPLATE = ( + "=== CI MANIFESTO (auto-injected doctrine — source of truth) ===\n" + "Source: {path}\n\n" +) + + def _build_injection() -> str: data = _load() content = data["content"] if not content: return "" - return ( - "=== CI MANIFESTO (auto-injected doctrine — source of truth) ===\n" - f"Source: {data['path']}\n\n" - f"{content}\n" - "=== END CI MANIFESTO ===\n" - ) + path_str = str(data["path"]) + header = _INJECTION_HEADER_TEMPLATE.format(path=path_str) + full_trailer = "\n=== END CI MANIFESTO ===\n" + full_payload = header + content + full_trailer + if len(full_payload.encode("utf-8")) <= _INJECTION_BUDGET_BYTES: + return full_payload + # Need to truncate. Compute room left for content after header + trailer. + trailer = _INJECTION_TRAILER_TEMPLATE.format(path=path_str) + overhead = len((header + trailer).encode("utf-8")) + body_budget = max(0, _INJECTION_BUDGET_BYTES - overhead) + body_bytes = content.encode("utf-8")[:body_budget] + # Avoid splitting a multi-byte char. + body = body_bytes.decode("utf-8", errors="ignore") + return header + body + trailer def inject_on_session_start() -> None: diff --git a/tests/test_ci_manifesto.py b/tests/test_ci_manifesto.py new file mode 100644 index 0000000..9be8ed5 --- /dev/null +++ b/tests/test_ci_manifesto.py @@ -0,0 +1,50 @@ +"""Tests for hooks.context.ci_manifesto.""" + +import pytest + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Size-aware injection (budget guard for Claude Code 10K cap) +# --------------------------------------------------------------------------- + + +class TestInjectionBudget: + def test_oversize_manifesto_truncates_with_path_footer(self, tmp_path, monkeypatch): + from unittest.mock import patch + + from hooks.context import ci_manifesto + + manifest = tmp_path / "huge.md" + manifest.write_text("# Doctrine\n" + ("padding " * 5000)) # ~40 KB + with patch.object(ci_manifesto, "_load") as mock_load: + mock_load.return_value = {"path": str(manifest), "content": manifest.read_text()} + payload = ci_manifesto._build_injection() + assert len(payload.encode("utf-8")) <= ci_manifesto._INJECTION_BUDGET_BYTES + assert "[TRUNCATED" in payload + assert str(manifest) in payload + + def test_under_budget_manifesto_emitted_full(self, tmp_path): + from unittest.mock import patch + + from hooks.context import ci_manifesto + + small = tmp_path / "small.md" + small.write_text("# Doctrine\n\nbe nice.") + with patch.object(ci_manifesto, "_load") as mock_load: + mock_load.return_value = {"path": str(small), "content": small.read_text()} + payload = ci_manifesto._build_injection() + assert "be nice." in payload + assert "[TRUNCATED" not in payload + assert "=== END CI MANIFESTO ===" in payload + + def test_empty_content_returns_empty(self, tmp_path): + from unittest.mock import patch + + from hooks.context import ci_manifesto + + with patch.object(ci_manifesto, "_load") as mock_load: + mock_load.return_value = {"path": "/nope", "content": ""} + assert ci_manifesto._build_injection() == "" + From 023a54f45e88ebea1711af6b0758f76f41ee8271 Mon Sep 17 00:00:00 2001 From: Nestor Colt Date: Tue, 5 May 2026 17:44:29 +0200 Subject: [PATCH 08/10] feat(caps): make injection size limits env-gated, default open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator preference: keep injections wide-open by default so agents get the full enhanced brain / manifesto / broadcast context. Truncation stays available as opt-in via env var when an operator decides they need envelope headroom under Claude Code's 10K hook output limit. Defaults flipped to "no cap": BROADCAST_MAX_BYTES_PRETOOL: 4096 → 0 BROADCAST_MAX_BYTES_PROMPT: 8192 → 0 New env var (default 0 = no cap): CI_MANIFESTO_MAX_BYTES — caps the CI manifesto inject when set; when truncated, body is followed by a "Read full doctrine at " footer Behavior: - 0 (default): full content reaches the model. The doctor probe will warn when total stdout exceeds 10,000 chars (Claude Code's documented hard cap → silent filepath substitution upstream). - >0: truncate to the configured byte budget, preserve trailing marker so agents can self-discover the truncation. Doctor probe still works the same — it reports current event sizes and flags >10K as failures so operators can decide whether to dial in a cap or let the upstream truncation happen. Documented in README under "Hook output and the 10K cap" section, and in .env.example with recommended values when opting in. Tests updated to patch caps explicitly when verifying truncation. Full suite: 1053 passed. Co-Authored-By: Claude Opus 4.7 --- .env.example | 22 ++++++++++++---- README.md | 26 ++++++++++++++++++- hooks/config.py | 9 +++++-- hooks/context/broadcast.py | 37 ++++++++++++++++---------- hooks/context/ci_manifesto.py | 36 ++++++++++++++++---------- tests/context/test_broadcast.py | 46 +++++++++++++++++++++++++++++---- tests/test_ci_manifesto.py | 31 ++++++++++++++++++---- 7 files changed, 163 insertions(+), 44 deletions(-) diff --git a/.env.example b/.env.example index 2ab3ded..49b5ed3 100644 --- a/.env.example +++ b/.env.example @@ -183,8 +183,20 @@ # BROADCAST_CRITICAL_ON_PRETOOL=true # BROADCAST_PRETOOL_MIN_SEVERITY=alert # -# Per-injection byte caps. Claude Code's additionalContext has a 10,000-char -# hard limit; exceeding it makes the harness silently substitute a filepath -# for the body. Raise carefully. -# BROADCAST_MAX_BYTES_PRETOOL=4096 -# BROADCAST_MAX_BYTES_PROMPT=8192 +# Per-injection byte caps. 0 (default) = no cap — full content ships through +# whatever stdout the hook emits. Set >0 to opt in to truncation when you +# need envelope headroom under Claude Code's 10,000-char hook output limit +# (over → harness silently writes to a temp file and the model receives a +# filepath instead of the body). +# +# Recommended values when you opt in: +# BROADCAST_MAX_BYTES_PRETOOL=4096 — keeps PreToolUse alerts under 4 KB +# BROADCAST_MAX_BYTES_PROMPT=8192 — keeps banner injects under 8 KB +# CI_MANIFESTO_MAX_BYTES=7500 — caps CI manifesto inject + adds a +# "Read full doctrine at " footer +# +# To see what each event currently emits and whether anything is over the +# 10K Claude Code cap: agentihooks doctor --debug-hook +# BROADCAST_MAX_BYTES_PRETOOL=0 +# BROADCAST_MAX_BYTES_PROMPT=0 +# CI_MANIFESTO_MAX_BYTES=0 diff --git a/README.md b/README.md index a174255..a9a30f3 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,11 @@ All configuration in `.env` files in `~/.agentihooks/`. Key variables: | `CONTEXT_COMPRESSION_SCOPE` | `refresh` | Scope: `refresh` or `all` | | `CONTEXT_REFRESH_INTERVAL` | `20` | Re-inject rules every N turns | | `BROADCAST_ENABLED` | `true` | Fleet messaging master switch | -| `BROADCAST_CRITICAL_ON_PRETOOL` | `true` | Critical alerts on every tool call | +| `BROADCAST_CRITICAL_ON_PRETOOL` | `false` | Re-inject critical broadcasts on every PreToolUse (default off — alerts still land on UserPromptSubmit) | +| `BROADCAST_PRETOOL_MIN_SEVERITY` | `critical` | Minimum severity for PreToolUse re-injection. `alert` widens it. | +| `BROADCAST_MAX_BYTES_PRETOOL` | `0` | PreToolUse banner byte cap. `0` = no cap. Set >0 to opt in to truncation under Claude Code's 10K hook output limit. | +| `BROADCAST_MAX_BYTES_PROMPT` | `0` | UserPromptSubmit banner byte cap. Same semantics. | +| `CI_MANIFESTO_MAX_BYTES` | `0` | CI manifesto inject byte cap. `0` = full doctrine ships through. Set >0 (e.g. `7500`) to truncate with a "Read full file at \" footer. | | `TOKEN_CONTROL_ENABLED` | `true` | Token control layer master switch | | `BASH_FILTER_ENABLED` | `true` | Truncate verbose bash output | | `FILE_READ_CACHE_ENABLED` | `true` | Block redundant file re-reads | @@ -337,6 +341,26 @@ All configuration in `.env` files in `~/.agentihooks/`. Key variables: Complete table: [Configuration Reference](https://the-cloud-clockwork.github.io/agentihooks/docs/reference/configuration/) +#### Hook output and the 10K cap + +Claude Code injects hook stdout (and any `hookSpecificOutput.additionalContext`) +into the model's context up to a documented **10,000-character hard cap**. +Beyond the cap, the harness silently writes the body to a temp file and the +model receives a filepath instead of the content. If your SessionStart or +UserPromptSubmit injection accumulates above 10K, it is **silently lost**. + +AgentiHooks does not enforce a default cap — full content ships through so +agents have the most context possible. To audit what each event actually +emits and whether anything is over the limit, run: + +```bash +agentihooks doctor --debug-hook +``` + +If you stack many injections (CI manifesto + brain feed + amygdala + custom +overlays + tool memory), use the `*_MAX_BYTES` knobs above to cap the +biggest contributors and leave headroom for the rest. + ### Remote brain quickstart To wire a Claude Code session into a brain stack you already deployed diff --git a/hooks/config.py b/hooks/config.py index 0dc5c72..c774988 100644 --- a/hooks/config.py +++ b/hooks/config.py @@ -376,8 +376,8 @@ def _env_bool(key: str, default: str = "false") -> bool: # hard limit; exceeding it makes the harness write to a temp file and the # model receives a filepath instead of the body. Cap below the limit with # envelope headroom. -BROADCAST_MAX_BYTES_PRETOOL: int = int(os.getenv("BROADCAST_MAX_BYTES_PRETOOL", "4096")) -BROADCAST_MAX_BYTES_PROMPT: int = int(os.getenv("BROADCAST_MAX_BYTES_PROMPT", "8192")) +BROADCAST_MAX_BYTES_PRETOOL: int = int(os.getenv("BROADCAST_MAX_BYTES_PRETOOL", "0")) +BROADCAST_MAX_BYTES_PROMPT: int = int(os.getenv("BROADCAST_MAX_BYTES_PROMPT", "0")) BROADCAST_PERSISTENT_THROTTLE = _env_bool("BROADCAST_PERSISTENT_THROTTLE", "true") BROADCAST_DELIVERY_STATE_FILE: str = os.getenv( "BROADCAST_DELIVERY_STATE_FILE", @@ -446,6 +446,11 @@ def _resolve_manifesto_path() -> str: CI_MANIFESTO_PATH: str = _resolve_manifesto_path() +# Optional cap on the bytes the manifesto inject contributes to SessionStart / +# UserPromptSubmit. 0 (default) = no cap; full doctrine ships through. Set +# >0 to opt in to truncation when stacking multiple injections under +# Claude Code's documented 10,000-char hook output limit. +CI_MANIFESTO_MAX_BYTES: int = int(os.getenv("CI_MANIFESTO_MAX_BYTES", "0")) CI_MANIFESTO_REFRESH_EVERY: int = int(os.getenv("CI_MANIFESTO_REFRESH_EVERY", "8")) # Auto dev-switch — at SessionStart, if cwd is on main/master, switch to dev. diff --git a/hooks/context/broadcast.py b/hooks/context/broadcast.py index 4730b7d..a8a3cdb 100644 --- a/hooks/context/broadcast.py +++ b/hooks/context/broadcast.py @@ -68,9 +68,14 @@ def _body_is_empty(msg: dict) -> bool: def _truncate_body(body: str, max_bytes: int) -> str: - """Truncate utf-8 body to max_bytes with a marker, preserving char boundaries.""" + """Truncate utf-8 body to max_bytes with a marker, preserving char boundaries. + + max_bytes <= 0 → no cap; return body unchanged. Lets operators run with + truncation off by default and opt in via env var when they need + envelope headroom under Claude Code's 10K hook output limit. + """ if max_bytes <= 0: - return "" + return body encoded = body.encode("utf-8") if len(encoded) <= max_bytes: return body @@ -756,18 +761,24 @@ def format_critical_context(msgs: list[dict]) -> str: deduped.sort(key=lambda m: _SEVERITY_RANK.get(m.get("severity", "info"), 9)) header = "BROADCAST ALERTS (PreToolUse):" - budget = max(0, BROADCAST_MAX_BYTES_PRETOOL - len(header.encode("utf-8")) - 1) lines = [header] - for m in deduped: - msg_id = m.get("id", "") - sev = _display_label(m.get("severity", "alert")) - body = _truncate_body(m.get("message", ""), max(0, budget // max(1, len(deduped)))) - line = f" - [{sev}] (id:{msg_id}) {body}" - line_bytes = len(line.encode("utf-8")) + 1 - if line_bytes > budget: - break - lines.append(line) - budget -= line_bytes + if BROADCAST_MAX_BYTES_PRETOOL <= 0: + for m in deduped: + msg_id = m.get("id", "") + sev = _display_label(m.get("severity", "alert")) + lines.append(f" - [{sev}] (id:{msg_id}) {m.get('message', '')}") + else: + budget = max(0, BROADCAST_MAX_BYTES_PRETOOL - len(header.encode("utf-8")) - 1) + for m in deduped: + msg_id = m.get("id", "") + sev = _display_label(m.get("severity", "alert")) + body = _truncate_body(m.get("message", ""), max(0, budget // max(1, len(deduped)))) + line = f" - [{sev}] (id:{msg_id}) {body}" + line_bytes = len(line.encode("utf-8")) + 1 + if line_bytes > budget: + break + lines.append(line) + budget -= line_bytes if len(lines) == 1: return "" return "\n".join(lines) diff --git a/hooks/context/ci_manifesto.py b/hooks/context/ci_manifesto.py index a7047c7..b6e27ef 100644 --- a/hooks/context/ci_manifesto.py +++ b/hooks/context/ci_manifesto.py @@ -253,17 +253,18 @@ def contains_pr_signal(text: str) -> bool: return _signal_match(text, get_pr_signals()) -# Claude Code injects hook stdout / additionalContext into context up to a -# 10,000-char cap; beyond that the harness writes to a temp file and the -# model receives a filepath instead of body content. The full Anton CI -# manifesto is ~36KB, so we MUST inject under-cap or it gets silently -# substituted (and the bundle CLAUDE.md rule "If the manifesto is not -# visible, alert operator" never fires because the model thinks it loaded). +# Claude Code injects hook stdout and additionalContext into the model's +# context with a documented 10,000-char hard cap (over → harness silently +# writes to a temp file and the model gets a filepath, not the body). # -# Strategy: emit the manifesto verbatim up to a safe byte budget, then a -# trailer pointing the agent at the canonical file path so the model can -# Read on demand for any section it needs in full. -_INJECTION_BUDGET_BYTES = 7500 +# We do NOT enforce a default cap on the manifesto inject — operators who +# want full doctrine in context should keep the env var unset and accept +# the upstream tradeoff. Set CI_MANIFESTO_MAX_BYTES=N to opt in to +# truncation when you have multiple things competing for SessionStart +# budget. +# +# CI_MANIFESTO_MAX_BYTES=0 (default) → unbounded; emit full manifesto. +# CI_MANIFESTO_MAX_BYTES=7500 → cap to ~7.5 KB with trailer. _INJECTION_TRAILER_TEMPLATE = ( "\n[TRUNCATED — the full doctrine is at: {path}]\n" "Read that path with the Read tool when you need section details.\n" @@ -275,6 +276,14 @@ def contains_pr_signal(text: str) -> bool: ) +def _injection_budget_bytes() -> int: + """Read budget from env each call so operators can dial via .env edits + without a session restart. 0 = no cap.""" + from hooks.config import CI_MANIFESTO_MAX_BYTES + + return CI_MANIFESTO_MAX_BYTES + + def _build_injection() -> str: data = _load() content = data["content"] @@ -284,14 +293,15 @@ def _build_injection() -> str: header = _INJECTION_HEADER_TEMPLATE.format(path=path_str) full_trailer = "\n=== END CI MANIFESTO ===\n" full_payload = header + content + full_trailer - if len(full_payload.encode("utf-8")) <= _INJECTION_BUDGET_BYTES: + + budget = _injection_budget_bytes() + if budget <= 0 or len(full_payload.encode("utf-8")) <= budget: return full_payload # Need to truncate. Compute room left for content after header + trailer. trailer = _INJECTION_TRAILER_TEMPLATE.format(path=path_str) overhead = len((header + trailer).encode("utf-8")) - body_budget = max(0, _INJECTION_BUDGET_BYTES - overhead) + body_budget = max(0, budget - overhead) body_bytes = content.encode("utf-8")[:body_budget] - # Avoid splitting a multi-byte char. body = body_bytes.decode("utf-8", errors="ignore") return header + body + trailer diff --git a/tests/context/test_broadcast.py b/tests/context/test_broadcast.py index 65e7739..7ecc415 100644 --- a/tests/context/test_broadcast.py +++ b/tests/context/test_broadcast.py @@ -590,10 +590,12 @@ def test_format_critical_context_dedups_by_content_hash(self): assert "id:b" in out assert "id:a" not in out - def test_format_critical_context_enforces_byte_cap(self): + def test_format_critical_context_enforces_byte_cap_when_set(self): + """Cap only kicks in when env opt-in (BROADCAST_MAX_BYTES_PRETOOL > 0).""" + from unittest.mock import patch + from hooks.context.broadcast import format_critical_context - # 50 messages with 1KB body each — total far exceeds 4KB cap. msgs = [ { "id": f"m{i}", @@ -604,17 +606,51 @@ def test_format_critical_context_enforces_byte_cap(self): } for i in range(50) ] - out = format_critical_context(msgs) + with patch("hooks.context.broadcast.BROADCAST_MAX_BYTES_PRETOOL", 4096): + out = format_critical_context(msgs) assert len(out.encode("utf-8")) <= 4096 + def test_format_critical_context_no_cap_emits_all(self): + """Default 0 → all deduped messages reach the model in full.""" + from hooks.context.broadcast import format_critical_context + + msgs = [ + { + "id": f"m{i}", + "severity": "alert", + "message": "x" * 1024, + "content_hash": f"h{i}", + "created_at": f"2026-05-05T10:{i:02d}:00Z", + } + for i in range(50) + ] + out = format_critical_context(msgs) + # No cap → 50 lines emitted (each at 1KB+ → 50+ KB total) + assert out.count("- [ALERT]") == 50 + def test_format_broadcast_banner_truncates_long_body(self): + from unittest.mock import patch + from hooks.context.broadcast import format_broadcast_banner msg = {"severity": "info", "source": "test", "message": "x" * 20000, "id": "abc"} - out = format_broadcast_banner(msg) - assert len(out.encode("utf-8")) <= 8192 + 200 # body cap + envelope + with patch("hooks.context.broadcast.BROADCAST_MAX_BYTES_PROMPT", 8192): + out = format_broadcast_banner(msg) + assert len(out.encode("utf-8")) <= 8192 + 200 assert "[...truncated]" in out + def test_format_broadcast_banner_no_cap_emits_full(self): + """Default BROADCAST_MAX_BYTES_PROMPT=0 → full body reaches the model.""" + from unittest.mock import patch + + from hooks.context.broadcast import format_broadcast_banner + + msg = {"severity": "info", "source": "test", "message": "x" * 5000, "id": "abc"} + with patch("hooks.context.broadcast.BROADCAST_MAX_BYTES_PROMPT", 0): + out = format_broadcast_banner(msg) + assert "[...truncated]" not in out + assert msg["message"] in out + def test_format_broadcast_banner_uses_display_label(self): from hooks.context.broadcast import format_broadcast_banner diff --git a/tests/test_ci_manifesto.py b/tests/test_ci_manifesto.py index 9be8ed5..1fd2ff1 100644 --- a/tests/test_ci_manifesto.py +++ b/tests/test_ci_manifesto.py @@ -11,28 +11,49 @@ class TestInjectionBudget: - def test_oversize_manifesto_truncates_with_path_footer(self, tmp_path, monkeypatch): + def test_default_no_cap_emits_full_doctrine(self, tmp_path): + """Default CI_MANIFESTO_MAX_BYTES=0 → no truncation, full content reaches model.""" from unittest.mock import patch from hooks.context import ci_manifesto manifest = tmp_path / "huge.md" manifest.write_text("# Doctrine\n" + ("padding " * 5000)) # ~40 KB - with patch.object(ci_manifesto, "_load") as mock_load: + with patch.object(ci_manifesto, "_load") as mock_load, patch( + "hooks.config.CI_MANIFESTO_MAX_BYTES", 0 + ): + mock_load.return_value = {"path": str(manifest), "content": manifest.read_text()} + payload = ci_manifesto._build_injection() + assert "[TRUNCATED" not in payload + assert "padding " * 100 in payload # body fully present + + def test_opt_in_cap_truncates_with_path_footer(self, tmp_path): + """When CI_MANIFESTO_MAX_BYTES is set, oversized content is truncated.""" + from unittest.mock import patch + + from hooks.context import ci_manifesto + + manifest = tmp_path / "huge.md" + manifest.write_text("# Doctrine\n" + ("padding " * 5000)) # ~40 KB + with patch.object(ci_manifesto, "_load") as mock_load, patch( + "hooks.config.CI_MANIFESTO_MAX_BYTES", 7500 + ): mock_load.return_value = {"path": str(manifest), "content": manifest.read_text()} payload = ci_manifesto._build_injection() - assert len(payload.encode("utf-8")) <= ci_manifesto._INJECTION_BUDGET_BYTES + assert len(payload.encode("utf-8")) <= 7500 assert "[TRUNCATED" in payload assert str(manifest) in payload - def test_under_budget_manifesto_emitted_full(self, tmp_path): + def test_under_budget_no_truncation(self, tmp_path): from unittest.mock import patch from hooks.context import ci_manifesto small = tmp_path / "small.md" small.write_text("# Doctrine\n\nbe nice.") - with patch.object(ci_manifesto, "_load") as mock_load: + with patch.object(ci_manifesto, "_load") as mock_load, patch( + "hooks.config.CI_MANIFESTO_MAX_BYTES", 7500 + ): mock_load.return_value = {"path": str(small), "content": small.read_text()} payload = ci_manifesto._build_injection() assert "be nice." in payload From 348fdfcfd600c91476431159fef5f285b2d24515 Mon Sep 17 00:00:00 2001 From: Nestor Colt Date: Tue, 5 May 2026 21:29:28 +0200 Subject: [PATCH 09/10] feat(ci-manifesto): move from SessionStart hook stdout to CLAUDE.md memory channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI manifesto (~36 KB) was being emitted on every SessionStart via hookSpecificOutput.additionalContext stdout. Claude Code's hook output is capped at ~2 KB before the harness substitutes the body with a filepath preview — so the model never saw the doctrine. Bundle's "if manifesto not visible, alert operator" rule never fired because the substitution is invisible to the agent. Fix: agentihooks init now appends the manifesto to ~/.claude/CLAUDE.md between BEGIN/END CI MANIFESTO markers. CLAUDE.md is loaded through the memory channel which has no 2 KB hook cap — the full doctrine permanently in context for every session, zero per-session API cost. Changes: * scripts/install.py — new _append_ci_manifesto_to_claude_md() helper, idempotent block replacement via marker comments, called after the profile-chain CLAUDE.md write. * hooks/context/ci_manifesto.py — inject_on_session_start() and maybe_refresh() now gated on CI_MANIFESTO_RUNTIME_INJECT (default false). Manifesto in CLAUDE.md is the new default; runtime inject is the legacy escape hatch. * hooks/config.py — new CI_MANIFESTO_RUNTIME_INJECT env var, default false. Doctor probe before: SessionStart 38,950 bytes → silently truncated. Doctor probe after: SessionStart 671 bytes → all green. Tests: 117 passed (config, broadcast, ci_manifesto, install). Co-Authored-By: Claude Opus 4.7 --- hooks/config.py | 7 ++++ hooks/context/ci_manifesto.py | 29 +++++++++++++---- scripts/install.py | 60 +++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/hooks/config.py b/hooks/config.py index c774988..31f731e 100644 --- a/hooks/config.py +++ b/hooks/config.py @@ -451,6 +451,13 @@ def _resolve_manifesto_path() -> str: # >0 to opt in to truncation when stacking multiple injections under # Claude Code's documented 10,000-char hook output limit. CI_MANIFESTO_MAX_BYTES: int = int(os.getenv("CI_MANIFESTO_MAX_BYTES", "0")) +# When True, ci_manifesto.inject_on_session_start fires on every session +# start (legacy behavior — emits 36 KB+ on stdout, hits Claude Code's 2 KB +# persisted-output preview cap, manifesto silently truncated to filepath). +# Default False — manifesto is now appended to ~/.claude/CLAUDE.md by +# `agentihooks init` and loaded through the memory channel (no hook cap, +# no per-session cost). Flip to True to restore the old runtime injection. +CI_MANIFESTO_RUNTIME_INJECT: bool = _env_bool("CI_MANIFESTO_RUNTIME_INJECT", "false") CI_MANIFESTO_REFRESH_EVERY: int = int(os.getenv("CI_MANIFESTO_REFRESH_EVERY", "8")) # Auto dev-switch — at SessionStart, if cwd is on main/master, switch to dev. diff --git a/hooks/context/ci_manifesto.py b/hooks/context/ci_manifesto.py index b6e27ef..919d6dc 100644 --- a/hooks/context/ci_manifesto.py +++ b/hooks/context/ci_manifesto.py @@ -307,11 +307,17 @@ def _build_injection() -> str: def inject_on_session_start() -> None: - """Emit manifesto as additionalContext at SessionStart.""" + """Emit manifesto as additionalContext at SessionStart. + + Default OFF — the manifesto is now appended to ~/.claude/CLAUDE.md by + agentihooks init (memory channel, no 2KB hook cap, zero per-session + cost). Set CI_MANIFESTO_RUNTIME_INJECT=true to restore legacy runtime + injection. + """ try: - from hooks.config import CI_MANIFESTO_ENABLED + from hooks.config import CI_MANIFESTO_ENABLED, CI_MANIFESTO_RUNTIME_INJECT - if not CI_MANIFESTO_ENABLED: + if not CI_MANIFESTO_ENABLED or not CI_MANIFESTO_RUNTIME_INJECT: return payload = _build_injection() if not payload: @@ -323,11 +329,22 @@ def inject_on_session_start() -> None: def maybe_refresh(session_id: str) -> None: - """Counter-gated re-injection on UserPromptSubmit.""" + """Counter-gated re-injection on UserPromptSubmit. + + Like inject_on_session_start, this is gated by CI_MANIFESTO_RUNTIME_INJECT. + With manifesto in CLAUDE.md, periodic re-injection is unnecessary — + CLAUDE.md content stays in context for the whole session. + """ try: - from hooks.config import CI_MANIFESTO_ENABLED, CI_MANIFESTO_REFRESH_EVERY + from hooks.config import ( + CI_MANIFESTO_ENABLED, + CI_MANIFESTO_REFRESH_EVERY, + CI_MANIFESTO_RUNTIME_INJECT, + ) - if not CI_MANIFESTO_ENABLED or CI_MANIFESTO_REFRESH_EVERY <= 0: + if not CI_MANIFESTO_ENABLED or not CI_MANIFESTO_RUNTIME_INJECT: + return + if CI_MANIFESTO_REFRESH_EVERY <= 0: return _session_counter[session_id] = _session_counter.get(session_id, 0) + 1 if _session_counter[session_id] % CI_MANIFESTO_REFRESH_EVERY != 0: diff --git a/scripts/install.py b/scripts/install.py index 4698a77..31a0365 100755 --- a/scripts/install.py +++ b/scripts/install.py @@ -2561,6 +2561,9 @@ def _install_global_inner(args: argparse.Namespace) -> None: # No CLAUDE.md in any profile — try last profile as fallback _install_system_prompt(profile_dirs[-1][1], profile_dirs[-1][0]) + # --- 5b. Append CI manifesto to ~/.claude/CLAUDE.md (memory channel) --- + _append_ci_manifesto_to_claude_md() + # --- 6. Install MCP servers to user scope (~/.claude.json) --- # Layer 1: hooks-utils from agentihooks _install_user_mcp(last_profile) @@ -3344,6 +3347,63 @@ def _symlink_dir_contents( _link_item(item, dst_dir / item.name, label) + + +def _append_ci_manifesto_to_claude_md() -> None: + """Append the CI manifesto to ~/.claude/CLAUDE.md as a fenced block. + + The manifesto used to be injected at SessionStart via stdout, but Claude + Code's hook output is capped at ~2KB before the harness substitutes the + body with a filepath preview. The manifesto is ~36KB, so the model never + saw the doctrine. CLAUDE.md is loaded through the memory channel, which + has no such cap — appending the manifesto here puts it permanently in + the model's context for every session, at zero per-session cost. + + Idempotent: if the manifesto block is already present and unchanged, + no write happens. + """ + try: + from hooks import config as _cfg + except Exception: + return + if not getattr(_cfg, "CI_MANIFESTO_ENABLED", True): + return + manifesto_path = Path(getattr(_cfg, "CI_MANIFESTO_PATH", "")).expanduser() + if not manifesto_path.exists(): + _cprint( + f" [--] CI manifesto not found at {manifesto_path} — skipping CLAUDE.md append." + ) + return + dst = CLAUDE_HOME / _CLAUDE_MD_NAME + if not dst.exists(): + # Nothing to append to — install_system_prompt handles its own write + return + body = manifesto_path.read_text().rstrip() + block = ( + "\n\n\n" + f"\n\n" + f"{body}\n\n" + "\n" + ) + current = dst.read_text() + begin_marker = "" + if begin_marker in current and end_marker in current: + # Replace existing block + before = current.split(begin_marker, 1)[0].rstrip() + after_split = current.split(end_marker, 1) + after = after_split[1] if len(after_split) > 1 else "" + new_content = before + block + after.lstrip("\n") + if new_content == current: + _cprint(" [--] CI manifesto block already up to date in CLAUDE.md") + return + else: + new_content = current.rstrip() + block + dst.write_text(new_content) + _cprint( + f" [OK] Appended CI manifesto to {dst} ({len(body):,} bytes)" + ) + def _install_system_prompt(profile_dir: Path, profile_name: str) -> None: """Copy profile's CLAUDE.md to ~/.claude/CLAUDE.md. From b14d452c683ef3cfa9b8bb47f5f2bf4855d5d3d5 Mon Sep 17 00:00:00 2001 From: Nestor Colt Date: Wed, 6 May 2026 00:06:40 +0200 Subject: [PATCH 10/10] =?UTF-8?q?feat(pages):=20consolidate=20sidebar=20IA?= =?UTF-8?q?=20=E2=80=94=205=20sections=20+=20Home?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the agentibrain-kernel / agentibridge / agenticore pattern: every page is owned by a top-level section, no floating standalones. - Create docs/pillars/index.md as the 'The Four Pillars' parent (was orphan — six pillar pages had parent: 'The Four Pillars' but no parent page existed). - Reset section nav_orders for clean user-journey ordering: Home(1) → Getting Started(2) → The Four Pillars(3) → Hook System(4) → MCP Tools(5) → Reference(6). - Route four standalone docs under sections: - bundles.md → Getting Started - connectors.md, extending.md → Hook System - cost-management.md → Reference - Backfill missing parent: on 12 leaf docs that were implicitly relying on directory structure (just-the-docs needs explicit parent). --- docs/bundles.md | 5 +++++ docs/connectors.md | 5 +++++ docs/cost-management.md | 4 ++-- docs/extending.md | 3 ++- docs/getting-started/index.md | 1 - docs/getting-started/installation.md | 1 + docs/getting-started/profiles.md | 1 + docs/hooks/events.md | 1 + docs/hooks/index.md | 1 - docs/mcp-tools/aws.md | 1 + docs/mcp-tools/compute.md | 1 + docs/mcp-tools/database.md | 1 + docs/mcp-tools/email.md | 1 + docs/mcp-tools/index.md | 1 - docs/mcp-tools/observability.md | 1 + docs/mcp-tools/storage.md | 1 + docs/mcp-tools/utilities.md | 1 + docs/pillars/index.md | 9 +++++++++ docs/reference/cli-commands.md | 1 + docs/reference/configuration.md | 1 + docs/reference/index.md | 3 +-- 21 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 docs/pillars/index.md diff --git a/docs/bundles.md b/docs/bundles.md index be6211f..655981f 100644 --- a/docs/bundles.md +++ b/docs/bundles.md @@ -1,3 +1,8 @@ +--- +title: Bundles +parent: Getting Started +nav_order: 9 +--- # Bundles A bundle is a single external directory containing all your personal agentihooks customizations -- custom profiles, MCP configs, skills, agents, commands, and rules. Agentihooks is the engine; the bundle is your data. diff --git a/docs/connectors.md b/docs/connectors.md index a6bedfa..d3c583d 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -1,3 +1,8 @@ +--- +title: Connectors +parent: Hook System +nav_order: 9 +--- # Connectors {: .warning } diff --git a/docs/cost-management.md b/docs/cost-management.md index f22f9d8..b6aa6f9 100644 --- a/docs/cost-management.md +++ b/docs/cost-management.md @@ -1,7 +1,7 @@ --- title: Cost Management -nav_order: 3 -permalink: /docs/cost-management/ +parent: Reference +nav_order: 9 --- # Cost Management diff --git a/docs/extending.md b/docs/extending.md index b2d5532..9b53298 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -1,5 +1,6 @@ --- -title: Extending AgentiHooks +title: Extending +parent: Hook System nav_order: 8 --- diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 533a209..bbc3a8c 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -2,7 +2,6 @@ title: Getting Started nav_order: 2 has_children: true -permalink: /docs/getting-started/ --- # Getting Started diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 653af37..0a64f3e 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,6 +1,7 @@ --- title: Installation nav_order: 2 +parent: Getting Started --- # Installation diff --git a/docs/getting-started/profiles.md b/docs/getting-started/profiles.md index 34eaa3e..b93b429 100644 --- a/docs/getting-started/profiles.md +++ b/docs/getting-started/profiles.md @@ -1,6 +1,7 @@ --- title: Profiles nav_order: 3 +parent: Getting Started --- # Profiles diff --git a/docs/hooks/events.md b/docs/hooks/events.md index 743b5e6..6bcce9d 100644 --- a/docs/hooks/events.md +++ b/docs/hooks/events.md @@ -1,6 +1,7 @@ --- title: Events nav_order: 2 +parent: Hook System --- # Hook Events diff --git a/docs/hooks/index.md b/docs/hooks/index.md index a7fed50..0cf63d9 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -2,7 +2,6 @@ title: Hook System nav_order: 4 has_children: true -permalink: /docs/hooks/ --- # Hook System diff --git a/docs/mcp-tools/aws.md b/docs/mcp-tools/aws.md index 503fb64..1bf17fb 100644 --- a/docs/mcp-tools/aws.md +++ b/docs/mcp-tools/aws.md @@ -1,6 +1,7 @@ --- title: AWS nav_order: 4 +parent: MCP Tools --- # AWS Tools diff --git a/docs/mcp-tools/compute.md b/docs/mcp-tools/compute.md index 7b6b42f..7201002 100644 --- a/docs/mcp-tools/compute.md +++ b/docs/mcp-tools/compute.md @@ -1,6 +1,7 @@ --- title: Compute nav_order: 9 +parent: MCP Tools --- # Compute Tools diff --git a/docs/mcp-tools/database.md b/docs/mcp-tools/database.md index c38a924..a7d042d 100644 --- a/docs/mcp-tools/database.md +++ b/docs/mcp-tools/database.md @@ -1,6 +1,7 @@ --- title: Database nav_order: 8 +parent: MCP Tools --- # Database Tools diff --git a/docs/mcp-tools/email.md b/docs/mcp-tools/email.md index 12bac35..78f32fe 100644 --- a/docs/mcp-tools/email.md +++ b/docs/mcp-tools/email.md @@ -1,6 +1,7 @@ --- title: Email nav_order: 5 +parent: MCP Tools --- # Email Tools diff --git a/docs/mcp-tools/index.md b/docs/mcp-tools/index.md index 633a908..d475fef 100644 --- a/docs/mcp-tools/index.md +++ b/docs/mcp-tools/index.md @@ -2,7 +2,6 @@ title: MCP Tools nav_order: 5 has_children: true -permalink: /docs/mcp-tools/ --- # MCP Tools diff --git a/docs/mcp-tools/observability.md b/docs/mcp-tools/observability.md index 2ffc2b1..e7de0ef 100644 --- a/docs/mcp-tools/observability.md +++ b/docs/mcp-tools/observability.md @@ -1,6 +1,7 @@ --- title: Observability nav_order: 10 +parent: MCP Tools --- # Observability Tools diff --git a/docs/mcp-tools/storage.md b/docs/mcp-tools/storage.md index b0d28a3..d8d25f2 100644 --- a/docs/mcp-tools/storage.md +++ b/docs/mcp-tools/storage.md @@ -1,6 +1,7 @@ --- title: Storage nav_order: 7 +parent: MCP Tools --- # Storage Tools diff --git a/docs/mcp-tools/utilities.md b/docs/mcp-tools/utilities.md index b5e0022..ccf75f1 100644 --- a/docs/mcp-tools/utilities.md +++ b/docs/mcp-tools/utilities.md @@ -1,6 +1,7 @@ --- title: Utilities nav_order: 13 +parent: MCP Tools --- # Utilities Tools diff --git a/docs/pillars/index.md b/docs/pillars/index.md new file mode 100644 index 0000000..b412138 --- /dev/null +++ b/docs/pillars/index.md @@ -0,0 +1,9 @@ +--- +title: The Four Pillars +nav_order: 3 +has_children: true +--- + +# The Four Pillars + +AgentiHooks is organised around four pillars: Identity, Guardrails, Context Intelligence, Fleet Command. Each pillar covers one axis of running Claude Code agents at scale. diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index 3af9700..74006b3 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -1,6 +1,7 @@ --- title: CLI Commands nav_order: 3 +parent: Reference --- # CLI Commands diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index c83c352..5de49c6 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1,6 +1,7 @@ --- title: Configuration nav_order: 2 +parent: Reference --- # Configuration Reference diff --git a/docs/reference/index.md b/docs/reference/index.md index 1b10442..892e47e 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -1,8 +1,7 @@ --- title: Reference -nav_order: 7 +nav_order: 6 has_children: true -permalink: /docs/reference/ --- # Reference