Skip to content

Instantly share code, notes, and snippets.

@gomesalexandre
Last active April 24, 2026 17:51
Show Gist options
  • Select an option

  • Save gomesalexandre/59afae573b488ded572d5b02f2a4994e to your computer and use it in GitHub Desktop.

Select an option

Save gomesalexandre/59afae573b488ded572d5b02f2a4994e to your computer and use it in GitHub Desktop.
Claude Code Stop hook → block exit while P0/P1 bd (beads) issues remain open. Minimal autonomous-loop primitive.

Claude Code Stop hook → block exit while bd beads are in-flight

A minimal Claude Code Stop hook that keeps an autonomous agent session going until your beads queue is reconciled — i.e. no beads are still marked in_progress. Inspired by the Reeds autonomous-loop pattern + the official Ralph Wiggum "don't stop until complete" example. No framework — just a shell script and two lines of settings.local.json.

What it does

When Claude Code tries to stop a turn, this hook fires. It:

  1. Reads stop_hook_active from stdin. If Claude has already been blocked this turn, let it stop — avoids infinite loops (this is the invariant the official hooks docs warn about).
  2. Runs bd list --status=in_progress — counts beads the agent has actively claimed this session.
  3. If > 0, emits {"decision":"block","reason":"..."} back to Claude, listing the first three remaining beads as a hint. Claude keeps going.
  4. If 0, exits 0 silently — Claude stops normally.
  5. If bd isn't installed on the host, skips gracefully — doesn't block stops in environments that don't use beads.

Net: in an autonomous session, the agent can't "declare done" with work-in-flight that it claimed. It has to either finish (via bd close <id>) or explicitly reopen (via bd update <id> --status=open) before exit.

Why "in_progress" and not P0/P1?

Earlier versions gated on open P0/P1 beads. Two problems:

  • Priority is project-specific. "P0" in your repo might mean "fund-safety blocker"; "P0" somewhere else might mean "nice-to-have for next sprint." The hook shouldn't hardcode either.
  • Open ≠ mine. In a shared-repo beads store, open beads can span many authors + ages. Blocking on all of them wedges your exit on someone else's backlog.

in_progress is the one signal that means "the agent running right now claimed this and hasn't released it." That's exactly the "this session" scope.

An opt-in BD_STOP_BLOCK_ON_OPEN=1 env var additionally blocks on any open bead, for solo-repo workflows where you want the stricter gate.

Why not Reeds / Ralph Wiggum?

Both are great. Reeds is a full loop runner; Ralph Wiggum is the official Anthropic plugin for the same pattern. This 80-line hook is the "I just want the Stop-block primitive, nothing else" answer. Use Reeds if you also want the structured loop (bd readybd show → implement → bd close → repeat); use this if you're already driving your own loop and just need the "don't exit yet" gate.

Install

# 1. drop the script in your repo
mkdir -p .claude/hooks
cp check-beads-before-stop.sh .claude/hooks/
chmod +x .claude/hooks/check-beads-before-stop.sh

# 2. wire into .claude/settings.local.json
#    (.claude/ is typically gitignored, so this is per-contributor)

Add to .claude/settings.local.json:

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-beads-before-stop.sh"
          }
        ]
      }
    ]
  }
}

Configure

Env vars the script reads (all optional):

  • BD_ISSUE_PREFIX — scope bd queries to a specific issue prefix (e.g. VA). Required when the .beads/issues.jsonl store has mixed prefixes (shared/imported repos); bd itself errors out with "mixed prefixes" in that case so the hook can't count without it.
  • BD_STOP_BLOCK_ON_OPEN=1 — additionally block on any open bead, not just in_progress. Strict mode; off by default.

Set them in your shell rc or in the .claude/settings.local.json hooks env field (Claude Code settings schema supports this per docs).

Trade-offs

  • Infinite-loop safety: the stop_hook_active check is load-bearing. Without it, a broken bead query (say bd returns non-zero but grep matches something unexpected) would wedge the agent forever. Keep the check.
  • Per-contributor: .claude/settings.local.json isn't committed in most setups. If you want the hook shared across a team, commit a template to .claude/settings.json (non-local) and document the install.
  • "Stale" in_progress beads: if you mark bd update X --status=in_progress from a prior session and forget, the hook will block you at the start of the next session. Run bd list --status=in_progress on session start to see if anything's carrying over; bd update <id> --status=open to release.
  • Bullet character portability: beads renders status bullets with unicode glyphs (○ ◐ ◯ ●). The script greps for all four so it works across statuses. If your beads version uses different glyphs, extend the regex.

Smoke test

# Should exit 0 (queue clean)
echo '{"stop_hook_active": false}' | .claude/hooks/check-beads-before-stop.sh

# Create an in_progress bead, hook should now emit a "block" JSON
BD_ISSUE_PREFIX=VA bd create --title "smoke" --type=task --priority=4 --description "test"
BD_ISSUE_PREFIX=VA bd update VA-N --status=in_progress
BD_ISSUE_PREFIX=VA bash -c 'echo "{\"stop_hook_active\": false}" | .claude/hooks/check-beads-before-stop.sh'
# => {"decision": "block", "reason": "Don't stop yet: 1 in_progress bead(s)..."}

# stop_hook_active=true should always exit 0 (no infinite loops)
echo '{"stop_hook_active": true}' | .claude/hooks/check-beads-before-stop.sh

# Cleanup
BD_ISSUE_PREFIX=VA bd close VA-N --reason "smoke"

References

License

Public domain / CC0. Rip it, rename it, change the logic — whatever helps your loop.

#!/usr/bin/env bash
# Stop hook: block Claude Code exit while any beads are still
# `in_progress` — the "this session" proxy for work-in-flight the agent
# claimed and hasn't closed or re-opened.
#
# Respects `stop_hook_active` to avoid infinite loops — if Claude has
# already been blocked once this turn, we let it stop.
#
# Opt-ins via env:
# BD_ISSUE_PREFIX=VA scope `bd` queries to a specific prefix
# BD_STOP_BLOCK_ON_OPEN=1 additionally block on any `open` bead
# (broader; off by default so other people's
# backlog doesn't wedge your exit)
#
# Wiring: referenced from .claude/settings.local.json under
# `.hooks.Stop[0].hooks[0].command`.
set -eu
# Read stdin JSON — Claude Code passes {session_id, transcript_path,
# stop_hook_active}. If jq is not available, fall back to grep (avoids
# a hard dep on jq).
INPUT="$(cat)"
if command -v jq >/dev/null 2>&1; then
STOP_HOOK_ACTIVE="$(printf '%s' "$INPUT" | jq -r '.stop_hook_active // false')"
else
# Cheap grep fallback — returns "true" if the field is set truthy.
if printf '%s' "$INPUT" | grep -q '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then
STOP_HOOK_ACTIVE="true"
else
STOP_HOOK_ACTIVE="false"
fi
fi
# Already blocked this turn — let the stop go through.
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
exit 0
fi
# Skip if bd isn't available — don't block stops in environments where
# the beads tool isn't installed.
if ! command -v bd >/dev/null 2>&1; then
exit 0
fi
# ---------------------------------------------------------------------------
# Primary gate: any bead in `in_progress` status is WIP the agent claimed
# and owes either a close or a reopen. Priority-agnostic — "this session"
# is defined by what was actively being worked on, not by how urgent the
# open-queue items happen to be.
#
# Secondary (opt-in): set BD_STOP_BLOCK_ON_OPEN=1 to also block on any
# open bead. Broader / stricter; off by default so you don't get
# wedged by other people's backlog in a shared repo.
# ---------------------------------------------------------------------------
if [ -n "${BD_ISSUE_PREFIX:-}" ]; then
export BD_ISSUE_PREFIX
fi
WIP=$(bd list --status=in_progress 2>/dev/null | grep -cE '^(○|◐|◯|●)' || true)
EXTRA_OPEN=0
if [ "${BD_STOP_BLOCK_ON_OPEN:-0}" = "1" ]; then
EXTRA_OPEN=$(bd list --status=open 2>/dev/null | grep -cE '^(○|◐|◯|●)' || true)
fi
TOTAL=$((WIP + EXTRA_OPEN))
if [ "$TOTAL" -eq 0 ]; then
# Clean — allow stop.
exit 0
fi
# Build a compact "remaining" hint for Claude.
FIRST_WIP=$(bd list --status=in_progress 2>/dev/null | grep -E '^(○|◐|◯|●)' | head -3 | sed -E 's/^(○|◐|◯|●) /- /' || true)
FIRST_OPEN=""
if [ "${BD_STOP_BLOCK_ON_OPEN:-0}" = "1" ] && [ "$EXTRA_OPEN" -gt 0 ]; then
FIRST_OPEN=$(bd list --status=open 2>/dev/null | grep -E '^(○|◐|◯|●)' | head -3 | sed -E 's/^(○|◐|◯|●) /- /' || true)
fi
build_reason() {
printf "Don't stop yet: %d in_progress bead(s)" "$WIP"
if [ "$EXTRA_OPEN" -gt 0 ]; then
printf ' + %d other open bead(s)' "$EXTRA_OPEN"
fi
printf '.\n\nin_progress:\n%s' "$FIRST_WIP"
if [ -n "$FIRST_OPEN" ]; then
printf '\n\nopen:\n%s' "$FIRST_OPEN"
fi
printf '\n\nFinish via `bd close <id>`, re-open via `bd update <id> --status=open`, or unset BD_STOP_BLOCK_ON_OPEN to exit anyway.'
}
REASON="$(build_reason)"
if command -v jq >/dev/null 2>&1; then
printf '%s' "$REASON" | jq -Rs '{decision: "block", reason: .}'
else
# Crude JSON-escape fallback: escape backslashes + double-quotes,
# then convert literal newlines to \n escape sequences.
ESC="$(printf '%s' "$REASON" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk 'BEGIN{ORS="\\n"} {print}')"
ESC="${ESC%\\n}"
printf '{"decision":"block","reason":"%s"}\n' "$ESC"
fi
exit 0
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-beads-before-stop.sh"
}
]
}
]
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment