Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion home/.claude/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) │
Expand Down Expand Up @@ -106,6 +109,8 @@ Session End / Agent Teams

**Output:** New events in `<recent-events>` 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
Expand Down Expand Up @@ -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:<id>`, or `help_needed` on the session's `repo:<name>` 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:<name>` classification.
4. **Peek** new events (non-consuming, JSON) and classify DIRECTED = `channel == session:<id>` OR (`event_type == help_needed` AND `channel == repo:<name>`).
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": "...<recent-events>..."}` 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 `<recent-events>` 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`

Expand All @@ -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 `<recent-events>`.
- **`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
Expand Down Expand Up @@ -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" }] }],
Expand Down
116 changes: 116 additions & 0 deletions home/.claude/hooks/drain-directed-events.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/bin/bash

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Important] PR body claims tests/test-hooks.sh gained 7 behavioral tests (syntax; missing cli/session_id then exit 0; loop guard; directed then block JSON; ambient-only then silent; empty then silent), but tests/test-hooks.sh is not in this PR diff and has zero references to drain-directed-events.sh or eventbus-collect.sh. Every new code path here (loop guard, dep guard, repo derivation, peek/classify branch, directed/no-directed split, consume-on-block) is untested. CLAUDE.md is explicit about new code needing tests. Please add the tests the description references (or update the description to match what shipped).

# 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Important] PR body claims home/.claude/hooks/README.md was updated with hook-table rows + a new Event-drain architecture section, but the README is not in the diff. The Stop-hook lifecycle ASCII diagram (README.md:38-42) still lists only zj-status + enforce-insight-publish and the Hook Details section has no entry for drain-directed-events.sh. CLAUDE.md is explicit: when adding or modifying hooks, update the README. Per this hook own comment on line 32 (See hooks/README.md for details), the README section it points at does not exist. Please add the README updates the description references.

#
# 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:<name> 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This is the first of two peek calls per directed Stop (the second is L103 for text rendering). On every Stop where events exist, the hook makes 2x the bus traffic of a single fetch. Rendering the human text from the JSON payloads here (the payload field is already what the CLI text mode displays) would halve traffic and tighten the race window mentioned at L103.

[[ -z "$PEEKED" ]] && exit 0

# Count DIRECTED events:
# DIRECTED = channel == "session:<this session_id>"
# OR (event_type == "help_needed" AND channel == "repo:<this 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Race window between this peek (text, for the surfaced summary) and the consume on line 112: events that arrive in the millisecond gap get consumed by L112 but were never in SUMMARY, so they are lost from Claude view. The window is small but eliminable - render the text yourself from the JSON PEEKED already in hand (line 76), and you reduce both the calls and the gap. Two birds: this race + the next comment about doubling bus traffic.

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<recent-events>\n%s\n</recent-events>' "$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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Important] Multi-hook block race can lose directed events. This hook is registered AFTER enforce-insight-publish.sh in settings.json Stop and both can emit {"decision":"block"} on the same turn (insight-rule violation + directed event arriving are independent and can co-occur).

The PR body mitigation ("a turn where enforce-insight wins leaves the directed events intact to be drained on the next Stop") only holds if Claude Code stops running subsequent Stop hooks once one returns block, OR if it surfaces both reasons. Neither is asserted in Claude Code hook contract. If both hooks run to completion and only one block decision is honored, this line consumes (cursor advances past) events whose surfaced reason was dropped - silent data loss.

Safer options:

  1. Verify Claude Code resolution and document it inline (and ideally enforce with a test).
  2. Do not consume here - let the next prompt-events.sh invocation drain. Costs duplicate surfacing in the rare both-block turn, but no data loss.
  3. Have drain replicate enforce-insight check and skip surfacing if enforce would block (couples the two hooks, but tighter).

The choice deserves an explicit comment; "that is fine" in the file header is not justified by the code as written.


# Block once, surfacing all peeked events so the agent can act.
jq -n --arg reason "$REASON" '{decision: "block", reason: $reason}'
exit 0
60 changes: 60 additions & 0 deletions home/.claude/hooks/lib/eventbus-collect.sh
Original file line number Diff line number Diff line change
@@ -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 <recent-events>
# - 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
}
44 changes: 12 additions & 32 deletions home/.claude/hooks/prompt-events.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions home/.claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
{
"type": "command",
"command": "~/.claude/hooks/enforce-insight-publish.sh"
},
{
"type": "command",
"command": "~/.claude/hooks/drain-directed-events.sh"
}
]
}
Expand Down
Loading
Loading