From c5ee819e4dc1dd13bf4241ca3807bc014ab6583e Mon Sep 17 00:00:00 2001 From: Evan Senter Date: Sun, 31 May 2026 13:30:01 +0100 Subject: [PATCH 1/2] feat(hooks): add Stop-hook event-drain for directed events prompt-events.sh (UserPromptSubmit) only fires when the human types, so directed events (DMs to session:, help_needed on the session's repo) sent to an idle/just-finished session were missed until the next prompt. Add a Stop hook, drain-directed-events.sh, that PEEKs the bus at end of turn and, only when a directed event is waiting, surfaces all peeked events via {"decision":"block"} and then consumes to advance the shared per-session cursor. With no directed event it neither surfaces nor consumes, leaving ambient events for the next prompt-events.sh pull. Extract a shared library, lib/eventbus-collect.sh, as the single source of truth for ~/.extra/URL handling, the EXCLUDE denylist, the events fetch wrapper, and the dep guards; refactor prompt-events.sh to use it so the two hooks cannot diverge. Behavior of prompt-events.sh is unchanged. Register the new hook in settings.json Stop array (after enforce-insight-publish), document the shared-lib architecture, the verified event-bus JSON schema, peek/cursor semantics, and the multi-block ordering caveat in hooks/README.md, and add behavioral tests covering graceful degradation, the loop guard, directed->block, and ambient-only->silent. make check: shellcheck clean, hook tests 90 passed / 0 failed, bootstrap tests 35 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- home/.claude/hooks/drain-directed-events.sh | 116 ++++++++++++++++++++ home/.claude/hooks/lib/eventbus-collect.sh | 60 ++++++++++ home/.claude/hooks/prompt-events.sh | 44 ++------ home/.claude/settings.json | 4 + 4 files changed, 192 insertions(+), 32 deletions(-) create mode 100755 home/.claude/hooks/drain-directed-events.sh create mode 100755 home/.claude/hooks/lib/eventbus-collect.sh diff --git a/home/.claude/hooks/drain-directed-events.sh b/home/.claude/hooks/drain-directed-events.sh new file mode 100755 index 0000000..d2f0f8c --- /dev/null +++ b/home/.claude/hooks/drain-directed-events.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# Stop hook: drain DIRECTED agent-event-bus events that arrived while the +# session was working, so the agent sees DMs / help requests at end-of-turn +# instead of only on the next human prompt. +# +# Input (via stdin): JSON with session_id, stop_hook_active, cwd, transcript_path +# Output: JSON {"decision":"block","reason":"..."} when directed events wait; +# silent (exit 0) otherwise. +# +# Why this exists: +# prompt-events.sh (UserPromptSubmit) only fires when the human types. A +# directed event sent to an idle/just-finished session would otherwise sit +# unseen until the next prompt. This hook closes that gap by PEEKing the bus +# at Stop and, if anything directed is waiting, blocking once to surface it. +# +# Cursor semantics (important): +# The bus keeps ONE high-water cursor per session_id, shared by BOTH hooks. +# - PEEK (non-consuming) lets us look without moving the cursor. +# - We only CONSUME (advance the cursor) when we actually surface events. +# - When we surface, we surface ALL peeked events (directed + ambient), then +# consume, because the single cursor can't selectively skip ambient ones. +# Surfacing ambient alongside directed is lossless and correct. +# - When there are NO directed events we neither surface nor consume, leaving +# everything for prompt-events.sh to pull on the next UserPromptSubmit. +# +# Stop-hook ordering / multi-block interaction: +# Registered in settings.json AFTER enforce-insight-publish.sh. Both can emit +# {"decision":"block"}. Claude Code honors a single block decision per Stop, +# so the two can race on the same turn; that is fine - this hook only blocks +# when directed events exist, and (because it peeks, not consumes, unless it +# blocks) a turn where enforce-insight wins leaves the directed events intact +# to be drained on the next Stop. See hooks/README.md for details. +# +# NEVER block the session on error: every failure path exits 0. + +set -euo pipefail + +# Read stdin (always consume to avoid broken pipe). +INPUT=$(cat) + +# Loop guard: do not re-fire on our own block (mirrors enforce-insight-publish.sh). +# Done before sourcing the lib / requiring the cli so the guard is robust. +if command -v jq >/dev/null 2>&1; then + STOP_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false') + [[ "$STOP_ACTIVE" == "true" ]] && exit 0 +fi + +# Source shared collection library (denylist + fetch path shared w/ prompt-events.sh). +# shellcheck source=lib/eventbus-collect.sh +source "$(dirname "$0")/lib/eventbus-collect.sh" + +# Graceful degradation: need cli + jq. +eb_have_deps || exit 0 + +SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""') +[[ -z "$SESSION_ID" ]] && exit 0 + +# Determine this session's repo (for repo: help_needed classification), +# from cwd/git like session-start.sh does. Best-effort; empty REPO is fine. +CWD=$(echo "$INPUT" | jq -r '.cwd // ""') +[[ -z "$CWD" ]] && CWD="$PWD" +REPO="" +if command -v git >/dev/null 2>&1 && git -C "$CWD" rev-parse --git-dir >/dev/null 2>&1; then + COMMON_DIR=$(git -C "$CWD" rev-parse --git-common-dir 2>/dev/null) + if [[ "$COMMON_DIR" == /* ]]; then + REPO=$(basename "$(dirname "$COMMON_DIR")") + else + REPO=$(basename "$(git -C "$CWD" rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || basename "$CWD") + fi +else + REPO=$(basename "$CWD" 2>/dev/null || echo "") +fi + +# PEEK new events (non-consuming), JSON for classification, ascending order. +# JSON shape (verified live): {"events":[{event_id,event_type,payload,channel},...]}. +PEEKED=$(eb_fetch_events "$SESSION_ID" peek json asc) +[[ -z "$PEEKED" ]] && exit 0 + +# Count DIRECTED events: +# DIRECTED = channel == "session:" +# OR (event_type == "help_needed" AND channel == "repo:") +SESSION_CHANNEL="session:$SESSION_ID" +REPO_CHANNEL="" +[[ -n "$REPO" ]] && REPO_CHANNEL="repo:$REPO" + +DIRECTED=$(echo "$PEEKED" | jq -r \ + --arg sc "$SESSION_CHANNEL" --arg rc "$REPO_CHANNEL" ' + (.events // []) + | [ .[] | select( + (.channel == $sc) + or (.event_type == "help_needed" and $rc != "" and .channel == $rc) + ) + ] | length' 2>/dev/null || echo 0) + +# No directed events (or parse failure): leave everything for prompt-events.sh. +# Do NOT consume. +if [[ -z "$DIRECTED" ]] || ! [[ "$DIRECTED" =~ ^[0-9]+$ ]] || [[ "$DIRECTED" -eq 0 ]]; then + exit 0 +fi + +# Render ALL peeked events in the CLI's own text format (same as prompt-events +# surfaces) by re-peeking as text. Still non-consuming. +SUMMARY=$(eb_fetch_events "$SESSION_ID" peek text asc) +if [[ -z "$SUMMARY" || "$SUMMARY" == "No events" || "$SUMMARY" == "No new events" ]]; then + exit 0 +fi + +REASON=$(printf 'Directed event(s) arrived while you were working. Address them before going idle.\n\n\n%s\n' "$SUMMARY") + +# Consume to advance the shared cursor past what we just surfaced, so the next +# prompt-events.sh pull does not re-show these. Discard the output. +eb_fetch_events "$SESSION_ID" consume text asc >/dev/null 2>&1 || true + +# Block once, surfacing all peeked events so the agent can act. +jq -n --arg reason "$REASON" '{decision: "block", reason: $reason}' +exit 0 diff --git a/home/.claude/hooks/lib/eventbus-collect.sh b/home/.claude/hooks/lib/eventbus-collect.sh new file mode 100755 index 0000000..42a722b --- /dev/null +++ b/home/.claude/hooks/lib/eventbus-collect.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# Shared agent-event-bus collection library. +# +# Single source of truth for the two event-bus hooks so they CANNOT diverge: +# - prompt-events.sh (UserPromptSubmit): consumes events, injects +# - drain-directed-events.sh (Stop): peeks for directed events, surfaces+consumes +# +# This file is meant to be SOURCED, not executed. It provides: +# EB_URL_ARGS array of --url args (empty if AGENT_EVENT_BUS_URL unset) +# EB_EXCLUDE canonical denylist of noisy event types +# eb_have_deps func: returns 0 iff agent-event-bus-cli AND jq are present +# eb_fetch_events func: thin wrapper over `agent-event-bus-cli events` +# +# Symlink note: bootstrap symlinks the whole hooks/ dir, so lib/ rides along. +# Hooks source this via: "$(dirname "$0")/lib/eventbus-collect.sh". +# +# Callers run under `set -euo pipefail`; this lib must be safe under it. + +# Source user's environment for AGENT_EVENT_BUS_URL, defensively (PR #314 pattern). +# ~/.extra is user-edited and untracked; a stray unset var or non-zero line must +# not abort the hook under set -euo pipefail. +if [[ -f ~/.extra ]]; then + set +eu + # shellcheck source=/dev/null + source ~/.extra + set -eu +fi + +# Build --url args from AGENT_EVENT_BUS_URL if set (e.g. remote Tailscale endpoint). +EB_URL_ARGS=() +[[ -n "${AGENT_EVENT_BUS_URL:-}" ]] && EB_URL_ARGS=(--url "$AGENT_EVENT_BUS_URL") + +# Canonical denylist of noisy event types. Both hooks MUST use this one list. +EB_EXCLUDE="session_registered,session_unregistered,ci_watching,task_started,ci_rerun,parallel_work_started" + +# Graceful-degradation guard: caller should `eb_have_deps || exit 0`. +# Returns 0 iff both the CLI and jq are available. +eb_have_deps() { + command -v agent-event-bus-cli >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 +} + +# Fetch events for a session via the CLI. +# +# Usage: eb_fetch_events SESSION_ID PEEK FORMAT ORDER +# SESSION_ID required (used for cursor tracking; client-id == session-id) +# PEEK "peek" -> non-consuming (--peek); anything else -> consuming +# FORMAT "json" -> --json; anything else -> default text rendering +# ORDER "asc" | "desc" (default asc) +# +# Always uses --resume so the server tracks the per-session high-water cursor. +# JSON output is a top-level object: {"events":[{event_id,event_type,payload, +# channel}, ...], "next_cursor": ...}. Text output is the CLI's human format. +# Emits the CLI's stdout on success; empty string on any failure (never errors). +eb_fetch_events() { + local session_id="$1" peek="${2:-consume}" format="${3:-text}" order="${4:-asc}" + local flags=(--resume --session-id "$session_id" --order "$order" --exclude "$EB_EXCLUDE" --timeout 200 --limit 20) + [[ "$peek" == "peek" ]] && flags+=(--peek) + [[ "$format" == "json" ]] && flags+=(--json) + agent-event-bus-cli ${EB_URL_ARGS[@]+"${EB_URL_ARGS[@]}"} events "${flags[@]}" 2>/dev/null || true +} diff --git a/home/.claude/hooks/prompt-events.sh b/home/.claude/hooks/prompt-events.sh index f7500d3..a2eae6d 100755 --- a/home/.claude/hooks/prompt-events.sh +++ b/home/.claude/hooks/prompt-events.sh @@ -6,54 +6,34 @@ # # Uses --resume for incremental polling: only shows events since last prompt. # The server tracks cursor position per session, so each prompt only sees NEW events. +# +# Shares its denylist + fetch path with drain-directed-events.sh via the +# eventbus-collect.sh lib so the two hooks cannot diverge. See hooks/README.md +# "Event-drain architecture". set -euo pipefail -# Source user's environment for AGENT_EVENT_BUS_URL -if [[ -f ~/.extra ]]; then - # ~/.extra is user-edited and untracked; a stray unset var or non-zero - # line must not abort the hook under set -euo pipefail. - set +eu - # shellcheck source=/dev/null - source ~/.extra - set -eu -fi +# Source shared collection library (handles ~/.extra, URL args, denylist, fetch). +# shellcheck source=lib/eventbus-collect.sh +source "$(dirname "$0")/lib/eventbus-collect.sh" # Read session info (always consume stdin to avoid broken pipe) INPUT=$(cat) -# Check for agent-event-bus-cli -if ! command -v agent-event-bus-cli &>/dev/null; then - # Graceful degradation: skip if CLI not installed - exit 0 -fi - -# Build URL args if AGENT_EVENT_BUS_URL is set (e.g., remote Tailscale endpoint) -URL_ARGS=() -[[ -n "${AGENT_EVENT_BUS_URL:-}" ]] && URL_ARGS=(--url "$AGENT_EVENT_BUS_URL") +# Graceful degradation: need cli + jq. +eb_have_deps || exit 0 # Parse session-id - required for cursor tracking -SESSION_ID="" -if command -v jq &>/dev/null; then - SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""') -fi +SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""') # Without session_id, we can't do incremental polling if [[ -z "$SESSION_ID" ]]; then exit 0 fi -# Fetch only NEW events since last prompt using --resume -# --resume: incremental polling - server tracks cursor, only returns new events +# Fetch only NEW events since last prompt (consuming read, ascending, text). # --order asc: chronological order (oldest first, new events at end) -EVENTS=$(agent-event-bus-cli ${URL_ARGS[@]+"${URL_ARGS[@]}"} events \ - --resume \ - --session-id "$SESSION_ID" \ - --order asc \ - --exclude session_registered,session_unregistered,ci_watching,task_started,ci_rerun,parallel_work_started \ - --timeout 200 \ - --limit 20 \ - 2>/dev/null) || true +EVENTS=$(eb_fetch_events "$SESSION_ID" consume text asc) # Output events in XML tags (interpretation guidance is in CLAUDE.md) if [[ -n "$EVENTS" && "$EVENTS" != "No events" && "$EVENTS" != "No new events" ]]; then diff --git a/home/.claude/settings.json b/home/.claude/settings.json index d460c7b..1c45a3f 100644 --- a/home/.claude/settings.json +++ b/home/.claude/settings.json @@ -36,6 +36,10 @@ { "type": "command", "command": "~/.claude/hooks/enforce-insight-publish.sh" + }, + { + "type": "command", + "command": "~/.claude/hooks/drain-directed-events.sh" } ] } From 6292eaf295d0db645457108c9a38a8ccd45e405f Mon Sep 17 00:00:00 2001 From: Evan Senter Date: Fri, 5 Jun 2026 10:20:48 +0100 Subject: [PATCH 2/2] test(hooks): add drain-directed-events tests + README docs Add 9 behavioral tests for drain-directed-events.sh to tests/test-hooks.sh (suite: 90 -> 99 passing) covering: syntax; graceful degradation (no jq / no cli / no session_id -> exit 0 silent); stop_hook_active loop guard (silent, no consume); DM on session: -> block JSON with surfaced events + consume; help_needed on repo: -> block; ambient-only and empty bus -> silent with no consume. Tests use a channel-aware mock agent-event-bus-cli (peek/consume via marker file) and real jq for the classification filter; mutation-verified. Document the hook in hooks/README.md: new detail section, lifecycle-diagram and settings.json config rows, and an "Event-drain architecture" section covering the shared eventbus-collect.sh lib, verified JSON schema, peek/cursor semantics, and the multi-Stop-hook ordering caveat. Co-Authored-By: Claude Opus 4.8 (1M context) --- home/.claude/hooks/README.md | 66 ++++++++++- tests/test-hooks.sh | 224 +++++++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+), 1 deletion(-) diff --git a/home/.claude/hooks/README.md b/home/.claude/hooks/README.md index 3577d23..7b4612b 100644 --- a/home/.claude/hooks/README.md +++ b/home/.claude/hooks/README.md @@ -40,6 +40,9 @@ Session Start │ │ 2. enforce-insight-publish │ │ │ (blocks if ★ Insight │ │ │ without publish_event) │ +│ │ 3. drain-directed-events │ +│ │ (blocks to surface DMs/ │ +│ │ help_needed at idle) │ │ │ │ │ ▼ │ │ (repeat) │ @@ -106,6 +109,8 @@ Session End / Agent Teams **Output:** New events in `` tags (only if new events exist) +**Shared lib:** Uses `lib/eventbus-collect.sh` for `~/.extra`/`AGENT_EVENT_BUS_URL` handling, the canonical `EB_EXCLUDE` denylist, and the `eb_fetch_events` wrapper — the same code path as `drain-directed-events.sh`, so the two hooks cannot diverge. See [Event-drain architecture](#event-drain-architecture). + --- ### zj-status.sh @@ -216,6 +221,29 @@ Counting is lenient: one `publish_event` covers all insights in the turn (matche --- +### drain-directed-events.sh +**Trigger:** `Stop` + +**Purpose:** Surface **directed** event-bus events (DMs to `session:`, or `help_needed` on the session's `repo:` channel) that arrived while the session was working, so the agent acts on them at end-of-turn instead of waiting until the next human prompt. `prompt-events.sh` (UserPromptSubmit) only fires when the human types; a directed event sent to an idle/just-finished session would otherwise sit unseen. + +**Actions:** +1. Exit 0 if `stop_hook_active` is true (loop guard, mirrors `enforce-insight-publish.sh`). +2. Exit 0 (silent) if jq, `agent-event-bus-cli`, or `session_id` is missing — never block on degradation. +3. Derive `repo` from `cwd` (git common-dir / toplevel, falling back to `cwd` basename) for `repo:` classification. +4. **Peek** new events (non-consuming, JSON) and classify DIRECTED = `channel == session:` OR (`event_type == help_needed` AND `channel == repo:`). +5. If no directed events: exit 0 without consuming — leave everything for the next `prompt-events.sh` pull. +6. If ≥1 directed: re-peek as text, emit `{"decision": "block", "reason": "......"}` surfacing **all** peeked events, then do a consuming read to advance the cursor. + +**Input JSON fields:** `session_id`, `transcript_path`, `stop_hook_active`, `cwd` + +**Output:** `{"decision": "block", "reason": "..."}` (with surfaced events in `` tags) when directed events wait. Silent otherwise. + +**Exit code:** Always 0. Blocking is signaled via the JSON `decision` field. + +See [Event-drain architecture](#event-drain-architecture) for the shared-lib design, cursor/peek semantics, and the multi-Stop-hook ordering caveat. + +--- + ### post-tool-failure.sh **Trigger:** `PostToolUseFailure` @@ -234,6 +262,41 @@ Counting is lenient: one `publish_event` covers all insights in the turn (matche **Exit code:** Always 0. Uses `additionalContext` for feedback, not exit codes. +## Event-drain architecture + +Two hooks read the agent-event-bus and surface events to the agent: + +- **`prompt-events.sh`** (`UserPromptSubmit`) — fires when the human types. Consumes new events and injects them as ``. +- **`drain-directed-events.sh`** (`Stop`) — fires at end-of-turn. Peeks for *directed* events and blocks once to surface them if any are waiting, closing the gap where a session goes idle without seeing a DM / help request. + +### Shared library (`lib/eventbus-collect.sh`) + +Both hooks `source "$(dirname "$0")/lib/eventbus-collect.sh"` so they **cannot diverge**. It is the single source of truth for: + +- `~/.extra` sourcing + `AGENT_EVENT_BUS_URL` → `EB_URL_ARGS` (one fetch path). +- `EB_EXCLUDE` — one canonical denylist of noisy event types. +- `eb_have_deps` — graceful-degradation guard (`agent-event-bus-cli` AND `jq`). +- `eb_fetch_events SESSION_ID PEEK FORMAT ORDER` — thin wrapper over `agent-event-bus-cli events`, always `--resume`. `peek`→`--peek` (non-consuming); `json`→`--json`. + +Bootstrap symlinks the whole `hooks/` dir, so `lib/` rides along automatically. + +### Event-bus JSON schema (verified live) + +`events --json` returns a **top-level object**: `{"events":[{event_id, event_type, payload, channel}, …], "next_cursor": …}`. The human-readable field is `payload` (not `message`); classification keys on `channel`. + +### Peek / cursor semantics + +The bus keeps **one high-water cursor per `session_id`**, shared by both hooks. + +- **Peek** is non-consuming: it looks without advancing the cursor. +- The drain only **consumes** (advances the cursor) when it actually surfaces events — i.e. only when it blocks. +- When it blocks, it surfaces **all** peeked events (directed *and* ambient), because the single shared cursor can't selectively skip the ambient ones. Surfacing ambient alongside directed is lossless and correct. +- When there are **no directed** events, it neither surfaces nor consumes — everything is left in place for the next `prompt-events.sh` pull on `UserPromptSubmit`. (No directed → no consume → ambient flows normally.) + +### Multi-Stop-hook ordering caveat + +`drain-directed-events.sh` is registered in `settings.json` **after** `enforce-insight-publish.sh`. Both can emit `{"decision":"block"}`, and Claude Code honors **one** block decision per Stop. This is safe by design: the drain *peeks* and only *consumes when it wins*. If `enforce-insight-publish.sh` wins a given Stop, the directed events are left intact (unconsumed) and drain on the next Stop. No directed event is lost to the race. + ## Writing Hooks ### Requirements @@ -295,7 +358,8 @@ In `settings.json`: ] }], "Stop": [{ "hooks": [ { "type": "command", "command": "~/.claude/hooks/zj-status.sh waiting" }, - { "type": "command", "command": "~/.claude/hooks/enforce-insight-publish.sh" } + { "type": "command", "command": "~/.claude/hooks/enforce-insight-publish.sh" }, + { "type": "command", "command": "~/.claude/hooks/drain-directed-events.sh" } ] }], "PreCompact": [{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/pre-compact.sh" }] }], "TeammateIdle": [{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/teammate-idle.sh" }] }], diff --git a/tests/test-hooks.sh b/tests/test-hooks.sh index 0a93606..59c9630 100755 --- a/tests/test-hooks.sh +++ b/tests/test-hooks.sh @@ -1612,6 +1612,218 @@ EOF [[ $exit_code -eq 0 ]] && [[ "$output" == *"\"decision\": \"block\""* ]] } +# ============================================================================ +# drain-directed-events.sh tests (Stop hook) +# ============================================================================ +# +# This hook peeks the bus (non-consuming), classifies DIRECTED events +# (channel == session:, or help_needed on repo:), and blocks once +# surfacing all peeked events when any directed event waits. It uses the +# eventbus-collect.sh lib (shared with prompt-events.sh). +# +# Its jq classification filter (select/length over .events[]) needs REAL jq, +# not the setup_test_env mock. Tests that exercise the block path therefore +# prepend $(_real_jq_path) like the enforce-insight-publish tests do. +# +# A purpose-built mock agent-event-bus-cli is installed per-test. It honors +# --peek (non-consuming) vs consuming reads via a marker file, and emits JSON +# (--json) or text. Fixtures are supplied through env vars so each test can +# control what the bus "returns" without touching the network. + +# Install a channel-aware mock agent-event-bus-cli for the drain hook. +# Reads fixtures from env: +# MOCK_EVENTS_JSON JSON object string for `events --json --peek` +# MOCK_EVENTS_TEXT text rendering for `events --peek` (no --json) +# MOCK_CONSUME_MARK file path; a consuming read (no --peek) touches it so a +# test can assert the cursor advanced. +setup_mock_drain_cli() { + cat > "$TEST_TMP/bin/agent-event-bus-cli" << 'MOCK_CLI' +#!/bin/bash +# Mock agent-event-bus-cli for drain-directed-events.sh tests. +# Skip global flags (e.g. --url VALUE) before the subcommand. +while [[ "${1:-}" == --* ]]; do + case "$1" in + --url) shift 2 ;; + *) shift ;; + esac +done + +case "${1:-}" in + events) + peek=0 + json=0 + shift + while [[ $# -gt 0 ]]; do + case "$1" in + --peek) peek=1; shift ;; + --json) json=1; shift ;; + --session-id|--order|--exclude|--timeout|--limit|--url) shift 2 ;; + --resume) shift ;; + *) shift ;; + esac + done + # Consuming read (not peek): record that the cursor advanced. + if [[ $peek -eq 0 && -n "${MOCK_CONSUME_MARK:-}" ]]; then + : > "$MOCK_CONSUME_MARK" + fi + if [[ $json -eq 1 ]]; then + printf '%s\n' "${MOCK_EVENTS_JSON:-{\"events\":[]\}}" + else + printf '%s\n' "${MOCK_EVENTS_TEXT:-}" + fi + ;; + *) + echo "Unknown command: ${1:-}" >&2 + exit 1 + ;; +esac +MOCK_CLI + chmod +x "$TEST_TMP/bin/agent-event-bus-cli" +} + +test_drain_directed_syntax() { + bash -n "$HOOKS_DIR/drain-directed-events.sh" +} + +test_drain_directed_graceful_no_jq() { + # No jq -> the loop guard can't read stop_hook_active and eb_have_deps fails; + # must exit 0 with no output (never block). Curate a bin without jq but with + # the hook's other deps so we don't accidentally pick up /usr/bin/jq. + local no_jq_dir="$TEST_TMP/no-jq" + mkdir -p "$no_jq_dir" + ln -sf "$(type -P cat)" "$no_jq_dir/cat" + ln -sf "$(type -P bash)" "$no_jq_dir/bash" + ln -sf "$(type -P dirname)" "$no_jq_dir/dirname" + [[ -n "$(type -P git)" ]] && ln -sf "$(type -P git)" "$no_jq_dir/git" + [[ -n "$(type -P basename)" ]] && ln -sf "$(type -P basename)" "$no_jq_dir/basename" + + local output exit_code=0 + output=$(echo '{"session_id":"sess-1","cwd":"/tmp"}' | \ + env -i PATH="$no_jq_dir" HOME="$HOME" \ + bash "$HOOKS_DIR/drain-directed-events.sh" 2>&1) || exit_code=$? + + [[ $exit_code -eq 0 ]] && [[ -z "$output" ]] +} + +test_drain_directed_graceful_no_cli() { + # jq present but agent-event-bus-cli absent -> eb_have_deps fails -> exit 0, + # silent. Build a PATH with jq's dir but NOT the real CLI's dir. + local output exit_code=0 + output=$(echo '{"session_id":"sess-1","cwd":"/tmp"}' | \ + env -i PATH="$(_real_jq_path):/usr/bin:/bin" HOME="$HOME" \ + bash "$HOOKS_DIR/drain-directed-events.sh" 2>&1) || exit_code=$? + + [[ $exit_code -eq 0 ]] && [[ -z "$output" ]] +} + +test_drain_directed_graceful_no_session_id() { + setup_mock_drain_cli + local output exit_code=0 + output=$(echo '{"cwd":"/tmp"}' | \ + PATH="$(_real_jq_path):$PATH" \ + bash "$HOOKS_DIR/drain-directed-events.sh" 2>&1) || exit_code=$? + + # No session_id -> can't track cursor -> exit 0 silent. + [[ $exit_code -eq 0 ]] && [[ -z "$output" ]] +} + +test_drain_directed_respects_stop_hook_active() { + # Loop guard: stop_hook_active=true must short-circuit BEFORE any bus call. + setup_mock_drain_cli + local mark="$TEST_TMP/consume-loopguard" + rm -f "$mark" + + local output exit_code=0 + output=$(MOCK_EVENTS_JSON='{"events":[{"event_id":1,"event_type":"help_needed","payload":"x","channel":"session:sess-1"}]}' \ + MOCK_CONSUME_MARK="$mark" \ + bash -c 'echo "{\"session_id\":\"sess-1\",\"cwd\":\"/tmp\",\"stop_hook_active\":true}" | PATH="'"$(_real_jq_path)"':$PATH" bash "'"$HOOKS_DIR"'/drain-directed-events.sh"' 2>&1) || exit_code=$? + + # Silent exit 0, and no consume happened (cursor untouched). + [[ $exit_code -eq 0 ]] && [[ -z "$output" ]] && [[ ! -e "$mark" ]] +} + +test_drain_directed_blocks_on_dm() { + # A DM on session: is DIRECTED -> hook must emit a block decision JSON + # surfacing the event, and must consume (advance cursor) when it blocks. + setup_mock_drain_cli + local mark="$TEST_TMP/consume-dm" + rm -f "$mark" + + local dm_json='{"events":[{"event_id":7,"event_type":"help_needed","payload":"please review PR #99","channel":"session:sess-1"}]}' + local dm_text='[7] help_needed (session:sess-1) + please review PR #99' + + local output exit_code=0 + output=$(MOCK_EVENTS_JSON="$dm_json" \ + MOCK_EVENTS_TEXT="$dm_text" \ + MOCK_CONSUME_MARK="$mark" \ + bash -c 'echo "{\"session_id\":\"sess-1\",\"cwd\":\"/tmp\"}" | PATH="'"$(_real_jq_path)"':$PATH" bash "'"$HOOKS_DIR"'/drain-directed-events.sh"' 2>&1) || exit_code=$? + + # Block JSON emitted, event surfaced, and a consuming read happened. + [[ $exit_code -eq 0 ]] && \ + [[ "$output" == *"\"decision\": \"block\""* ]] && \ + [[ "$output" == *"help_needed"* ]] && \ + [[ "$output" == *""* ]] && \ + [[ -e "$mark" ]] +} + +test_drain_directed_silent_on_ambient_only() { + # An ambient event (gotcha_discovered on "all") is NOT directed -> hook must + # exit 0 silently and must NOT consume (leave it for prompt-events.sh). + setup_mock_drain_cli + local mark="$TEST_TMP/consume-ambient" + rm -f "$mark" + + local amb_json='{"events":[{"event_id":3,"event_type":"gotcha_discovered","payload":"watch out","channel":"all"}]}' + local amb_text='[3] gotcha_discovered (all) + watch out' + + local output exit_code=0 + output=$(MOCK_EVENTS_JSON="$amb_json" \ + MOCK_EVENTS_TEXT="$amb_text" \ + MOCK_CONSUME_MARK="$mark" \ + bash -c 'echo "{\"session_id\":\"sess-1\",\"cwd\":\"/tmp\"}" | PATH="'"$(_real_jq_path)"':$PATH" bash "'"$HOOKS_DIR"'/drain-directed-events.sh"' 2>&1) || exit_code=$? + + # Silent exit 0, no block, no consume. + [[ $exit_code -eq 0 ]] && [[ -z "$output" ]] && [[ ! -e "$mark" ]] +} + +test_drain_directed_silent_on_no_events() { + # Empty bus -> peek returns empty events array -> exit 0 silent, no consume. + setup_mock_drain_cli + local mark="$TEST_TMP/consume-empty" + rm -f "$mark" + + local output exit_code=0 + output=$(MOCK_EVENTS_JSON='{"events":[]}' \ + MOCK_EVENTS_TEXT='' \ + MOCK_CONSUME_MARK="$mark" \ + bash -c 'echo "{\"session_id\":\"sess-1\",\"cwd\":\"/tmp\"}" | PATH="'"$(_real_jq_path)"':$PATH" bash "'"$HOOKS_DIR"'/drain-directed-events.sh"' 2>&1) || exit_code=$? + + [[ $exit_code -eq 0 ]] && [[ -z "$output" ]] && [[ ! -e "$mark" ]] +} + +test_drain_directed_blocks_on_help_needed_repo() { + # help_needed on this session's repo: channel is DIRECTED. Repo is + # derived from cwd basename; use a cwd whose basename is "myrepo". + setup_mock_drain_cli + local repo_dir="$TEST_TMP/myrepo" + mkdir -p "$repo_dir" + + local hn_json='{"events":[{"event_id":9,"event_type":"help_needed","payload":"need a hand","channel":"repo:myrepo"}]}' + local hn_text='[9] help_needed (repo:myrepo) + need a hand' + + local output exit_code=0 + output=$(MOCK_EVENTS_JSON="$hn_json" \ + MOCK_EVENTS_TEXT="$hn_text" \ + bash -c 'echo "{\"session_id\":\"sess-1\",\"cwd\":\"'"$repo_dir"'\"}" | PATH="'"$(_real_jq_path)"':$PATH" bash "'"$HOOKS_DIR"'/drain-directed-events.sh"' 2>&1) || exit_code=$? + + [[ $exit_code -eq 0 ]] && \ + [[ "$output" == *"\"decision\": \"block\""* ]] && \ + [[ "$output" == *"help_needed"* ]] +} + # ============================================================================ # Run all tests # ============================================================================ @@ -1749,6 +1961,18 @@ main() { run_test "integration: outputs additionalContext at threshold" "test_post_tool_failure_outputs_context_at_threshold" echo "" + echo "=== drain-directed-events.sh ===" + run_test "syntax check" "test_drain_directed_syntax" + run_test "graceful degradation (no jq)" "test_drain_directed_graceful_no_jq" + run_test "graceful degradation (no agent-event-bus-cli)" "test_drain_directed_graceful_no_cli" + run_test "graceful degradation (no session_id)" "test_drain_directed_graceful_no_session_id" + run_test "respects stop_hook_active (loop guard, no consume)" "test_drain_directed_respects_stop_hook_active" + run_test "blocks on DM (session channel), consumes" "test_drain_directed_blocks_on_dm" + run_test "blocks on help_needed (repo channel)" "test_drain_directed_blocks_on_help_needed_repo" + run_test "silent on ambient-only (no consume)" "test_drain_directed_silent_on_ambient_only" + run_test "silent on no events (no consume)" "test_drain_directed_silent_on_no_events" + echo "" + # Summary echo "========================================" echo "Tests: $TESTS_RUN | Passed: $TESTS_PASSED | Failed: $TESTS_FAILED"