monitor-events — Event-driven waits in skill prose
What this is for
CTL-210 unified the Catalyst event log: every GitHub webhook, Linear webhook, comms post,
and orchestrator/worker lifecycle event flows through ~/catalyst/events/YYYY-MM.jsonl.
Consumers no longer poll gh pr view, linearis read, or signal files — they subscribe
to the event stream via filter.
This skill documents the canonical patterns. Use it as a reference when writing or migrating skill prose; do not invoke it as a slash command.
Prerequisite — orch-monitor daemon must be running
The two primitives below read from ~/catalyst/events/YYYY-MM.jsonl, which is populated
by the orch-monitor daemon (plugins/dev/scripts/orch-monitor/server.ts). When the
daemon is not running:
catalyst-events tailreturns an empty streamcatalyst-events wait-forblocks until its--timeoutexpires (default 600s) and exits non-zero — callers fall back togh pr viewpolling, which can't see deploys
Liveness check (the same call wired into check-project-setup.sh):
plugins/dev/scripts/catalyst-monitor.sh status # human-readable
plugins/dev/scripts/catalyst-monitor.sh status --json # {"running":true,"pid":...}
Skills that invoke check-project-setup.sh (orchestrate, oneshot, merge-pr) handle the
liveness check automatically — interactive runs prompt to start the daemon, autonomous
runs warn-to-stderr and proceed. If you reuse the primitives outside those skills, run
the status check yourself and either start the daemon (catalyst-monitor.sh start) or
plan for the polling fallback.
Pattern selection & cost tradeoffs
Three patterns are available; pick by cost shape, not just by mechanism. Listed cheapest first.
| Pattern | Mechanism | Cost shape | When to use |
|---|---|---|---|
| Broker interest (preferred) | The catalyst-broker daemon (see [[broker]]) classifies events between Claude turns and emits filter.wake.{id} only on semantic match. Deterministic interest types (pr_lifecycle, ticket_lifecycle, comms_lifecycle) match by typed-field comparison; prose interests go through Groq Llama 3.1 8B. | Lowest. Zero turns while blocked; 1 turn per matched wake. Deterministic routes cost 0 LLM tokens; prose routes ~$0.05–0.10/M tokens at small batch sizes. Pre-filtering happens out of Claude's context entirely. | Whenever the broker daemon is running. Worker-scope (single PR) and orchestrator-scope (many PRs) both supported via the same registration mechanism. |
catalyst-events wait-for with jq | Blocking Bash CLI; one jq predicate; exits on first match or --timeout. Works without the broker. | Zero turns while blocked, 1 turn per match. Same per-match cost as Monitor but workers usually wait for ONE specific event, so total turn count stays low. Filter expansion (CI + reviews + push + merge) widens the per-match probability. | Short-lived claude -p workers when the broker is not running; standalone one-shot waits; CI scripts. |
Monitor over catalyst-events tail | Claude Code's Monitor tool wraps tail --filter; every matching line surfaces as a turn-resuming notification. | Highest. 1 wake per matching line means broad filters can dominate context. | Long-lived orchestrator only. NOT for short-lived claude -p workers — they have no long-lived turn loop to consume notifications. |
Worker contract (matches
oneshotPhase 5, CTL-371): dispatched workers prefer the broker (Pattern 3), fall back towait-for(Pattern 2) when the daemon is down, and never useMonitor/tail(Pattern 1). Seeplugins/dev/skills/oneshot/SKILL.mdPhase 5 and theorchestratedispatch prompt for the canonical invocation.
Note on numbering. The "Pattern N" labels in the recipe sections below (Pattern 1 — worker waits for PR merge, Pattern 2 — long-lived orchestrator wakes, Pattern 3 — reactive PR lifecycle, Pattern 4 — tail by ticket) are recipe IDs, not the cost-tier rank in the table above. The recipes pre-date the broker integration; both the broker-preferred path and the
wait-forfallback inside each recipe map to the table rows above.
Both wait-for and Monitor use catalyst-events under the hood. tail is the streaming
foundation; wait-for is tail | head -n 1 with a timeout. The broker reads the same event log
as tail does but classifies before waking, which is why its per-wake cost is so much lower.
Pattern 1 — Worker waits for its PR to merge
A claude -p worker that just opened PR #342 needs to block until the PR merges, then
do post-merge work.
Preferred (when catalyst-filter is running, CTL-269): register a single semantic
interest covering every concern the worker cares about (CI, comms, reviews, BEHIND,
Linear), then wait on filter.wake.${CATALYST_SESSION_ID}. The Groq-backed daemon
classifies raw events against the natural-language prompt and emits one wake per
match. See [[catalyst-filter]] for the full registration recipe and the daemon-restart
contract. The two-phase pattern below is the fallback for environments where the
daemon is not running.
Use the two-phase pattern from [[wait-for-github]]: a 3-minute Phase 1 with a diagnostic checkpoint before committing to the full 2-hour wait.
# Two-phase pattern — see [[wait-for-github]] for full reference.
REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner')
EVENT=""
_WFG_MATCHED=false
# Phase 1: short wait with diagnostic checkpoint (3 minutes).
EVENT=$(catalyst-events wait-for \
--filter ".attributes.\"event.name\" == \"github.pr.merged\" and .attributes.\"vcs.pr.number\" == ${PR_NUMBER}" \
--timeout 180 2>/dev/null || true)
if [ -n "$EVENT" ]; then
_WFG_MATCHED=true
else
# Phase 1 timed out — run diagnostics before extending to Phase 2.
echo "Phase 1 timed out after 3 min — running diagnostics..."
STALLED=false
FILTER_MISMATCH=false
_LOG_FILE=~/catalyst/events/$(date -u +%Y-%m).jsonl
_LOG_LINES=$(wc -l < "$_LOG_FILE" 2>/dev/null | tr -d ' ')
_SINCE_LINE=$(( ${_LOG_LINES:-0} > 500 ? ${_LOG_LINES:-0} - 500 : 0 ))
HEARTBEATS=$(catalyst-events tail --since-line "$_SINCE_LINE" 2>/dev/null \
| jq -c 'select(.attributes."event.name" == "session.heartbeat")' | wc -l | tr -d ' ')
[ "${HEARTBEATS:-0}" -eq 0 ] && { echo "WARN: No heartbeats — event log may be stalled"; STALLED=true; }
RAW_HIT=$(catalyst-events tail --since-line "$_SINCE_LINE" 2>/dev/null | jq -c \
--argjson pr "$PR_NUMBER" \
'select((.attributes."vcs.pr.number" == $pr) or (.body.payload.prNumbers // [] | contains([$pr])))' | head -1)
if [ -n "$RAW_HIT" ]; then
echo "WARN: Event arrived but filter did not match. Raw event:"; echo "$RAW_HIT" | jq .
FILTER_MISMATCH=true
fi
# The smee→monitor webhook tunnel is the GitHub-event ingestion path and is NOT yet
# retired (Linear smee retires first; GitHub smee is gated on CTC-134). A dead tunnel
# produces zero events while the monitor keeps heartbeating — so without this check a
# worker would treat infra as healthy and enter the 2-hour Phase 2 wait. Tunnel down →
# skip the extension and rely on the authoritative REST confirmation below.
TUNNEL_STATE=$(catalyst-monitor status --json 2>/dev/null | jq -r '.webhookTunnel.connected // false')
[ "$TUNNEL_STATE" != "true" ] && { echo "WARN: Webhook tunnel not running"; STALLED=true; }
if [ "$FILTER_MISMATCH" = "false" ] && [ "$STALLED" = "false" ]; then
# Infrastructure healthy — extend to Phase 2.
EVENT=$(catalyst-events wait-for \
--filter ".attributes.\"event.name\" == \"github.pr.merged\" and .attributes.\"vcs.pr.number\" == ${PR_NUMBER}" \
--timeout 7200 2>/dev/null || true)
[ -n "$EVENT" ] && _WFG_MATCHED=true
fi
fi
# Authoritative REST confirmation — always follows any wait-for path.
MERGED=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.merged' 2>/dev/null || echo "false")
if [ "$MERGED" = "true" ]; then
# Proceed