diff --git a/.env.example b/.env.example index 83081e6..49b5ed3 100644 --- a/.env.example +++ b/.env.example @@ -166,3 +166,37 @@ # 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. 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/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 diff --git a/hooks/config.py b/hooks/config.py index 2e1ae83..31f731e 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") @@ -365,6 +372,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", "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", @@ -433,6 +446,18 @@ 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")) +# 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/brain_adapter.py b/hooks/context/brain_adapter.py index fb1ed98..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: @@ -296,7 +360,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 +378,43 @@ 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: + 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, + "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 +426,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 207302f..a8a3cdb 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,66 @@ _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. + + 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 body + 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() @@ -99,9 +161,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. @@ -285,6 +349,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) @@ -297,6 +365,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() @@ -640,9 +725,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 +744,43 @@ 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: - msg_id = m.get("id", "") - sev = m.get("severity", "critical").upper() - lines.append(f" - [{sev}] (id:{msg_id}) {m['message']}") + 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):" + lines = [header] + 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) @@ -689,6 +807,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/hooks/context/ci_manifesto.py b/hooks/context/ci_manifesto.py index 14ba138..919d6dc 100644 --- a/hooks/context/ci_manifesto.py +++ b/hooks/context/ci_manifesto.py @@ -253,25 +253,71 @@ def contains_pr_signal(text: str) -> bool: return _signal_match(text, get_pr_signals()) +# 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). +# +# 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" + "=== END CI MANIFESTO ===\n" +) +_INJECTION_HEADER_TEMPLATE = ( + "=== CI MANIFESTO (auto-injected doctrine — source of truth) ===\n" + "Source: {path}\n\n" +) + + +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"] 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 + + 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, budget - overhead) + body_bytes = content.encode("utf-8")[:body_budget] + body = body_bytes.decode("utf-8", errors="ignore") + return header + body + trailer 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: @@ -283,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 2f23b12..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. @@ -4962,6 +5022,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 +5343,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/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" + ) + diff --git a/tests/context/test_broadcast.py b/tests/context/test_broadcast.py index 280bd3d..7ecc415 100644 --- a/tests/context/test_broadcast.py +++ b/tests/context/test_broadcast.py @@ -510,3 +510,209 @@ 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_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 + + 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) + ] + 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"} + 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 + + msg = {"severity": "info", "source": "test", "message": "ok"} + out = format_broadcast_banner(msg) + 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 + diff --git a/tests/test_ci_manifesto.py b/tests/test_ci_manifesto.py new file mode 100644 index 0000000..1fd2ff1 --- /dev/null +++ b/tests/test_ci_manifesto.py @@ -0,0 +1,71 @@ +"""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_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, 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")) <= 7500 + assert "[TRUNCATED" in payload + assert str(manifest) in payload + + 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, 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 + 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() == "" + 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}" + ) 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", + } +