Repository navigation
Open
Labels
triage-must-haveVerdict: high-value, important work for Spec Kit — do firstVerdict: high-value, important work for Spec Kit — do first
Description
What happens
Spec-kit's runtime events run an extension's script when something happens in the AI tool, for example when a turn ends. Two problems:
- A slow script can block the agent. If the script runs past its timeout, spec-kit's event runner exits with code 2. Claude Code reads exit code 2 from a "turn ended" hook as "don't stop, keep going", so one slow script can keep the agent running when it should stop.
- The script isn't told which event fired. It gets no arguments and no
SPECKIT_*variables, only the AI tool's own data on stdin. A script that handles more than one event has to guess.
How to see it (v1.2.6)
A tiny extension with a "turn ended" (stop) event and a 2-second timeout. When its script takes 1 second, the hook exits 0. When it takes 5 seconds, the hook exits 2 and prints "timed out". The script is below.
Reproduction script
#!/usr/bin/env bash
# u3: when an extension's runtime-event handler runs past its timeout, spec-kit's event runner exits 2.
# For Claude Code, exit 2 from a Stop hook means "block": the agent is told to keep going.
# The handler below stands in for any slow call (a network request, a lock wait) with `sleep`.
# Everything else is stock: a minimal extension declaring a documented `stop` event with a 2 second timeout.
set -uo pipefail
REF="${REF:-v1.2.6}"
WORK="$(mktemp -d)"
cd "$WORK"
SPECIFY=(uvx -q --from "git+https://github.com/github/spec-kit.git@$REF" specify)
"${SPECIFY[@]}" init --here --force --non-interactive --integration claude --script sh --ignore-agent-tools >/dev/null 2>&1 || { echo "init failed"; exit 2; }
echo "spec-kit $REF in $WORK"
mkdir -p ext/commands ext/scripts/bash
cat > ext/extension.yml <<'YML'
schema_version: "1.0"
extension:
id: slowstop
name: "Slow stop handler"
version: "0.1.0"
description: "Records something when a turn ends"
author: repro
repository: https://example.com/slowstop
license: MIT
requires:
speckit_version: ">=1.2.0"
provides:
commands:
- name: speckit.slowstop.record
file: commands/speckit.slowstop.record.md
description: "Record the turn end"
events:
stop:
command: speckit.slowstop.record
timeout: 2
YML
cat > ext/commands/speckit.slowstop.record.md <<'MD'
---
description: "Record the turn end"
scripts:
sh: scripts/bash/record.sh
---
Runs on its own when a turn ends.
MD
printf '#!/usr/bin/env bash\nsleep 5\necho recorded\n' > ext/scripts/bash/record.sh
chmod +x ext/scripts/bash/record.sh
"${SPECIFY[@]}" extension add --dev ./ext </dev/null 2>&1 | tail -1
hook=$(python3 - <<'PY'
import json
s=json.load(open(".claude/settings.json"))
for group in s.get("hooks",{}).get("Stop",[]):
for h in group.get("hooks",[]):
if "slowstop" in h.get("command","") or "events" in h.get("command",""):
print(h["command"]); raise SystemExit
PY
)
echo "Stop hook spec-kit wrote into .claude/settings.json:"
echo " $hook"
[ -n "$hook" ] || { echo "no Stop hook found; not reproduced"; exit 1; }
echo '{"session_id":"repro","hook_event_name":"Stop","stop_hook_active":false}' > payload.json
run_hook() {
start=$(date +%s)
CLAUDE_PROJECT_DIR="$WORK" bash -c "$hook" < payload.json > out.txt 2> err.txt
code=$?
secs=$(( $(date +%s) - start ))
}
echo "A. the handler finishes in time (sleep 1, timeout 2)"
printf '#!/usr/bin/env bash\nsleep 1\necho recorded\n' > .specify/extensions/slowstop/scripts/bash/record.sh
run_hook; echo " ran ${secs}s, exit $code, stderr: $(cat err.txt)"
okcode=$code
echo "B. the handler runs past its timeout (sleep 5, timeout 2)"
printf '#!/usr/bin/env bash\nsleep 5\necho recorded\n' > .specify/extensions/slowstop/scripts/bash/record.sh
run_hook; echo " ran ${secs}s, exit $code, stderr: $(cat err.txt)"
if [ "$okcode" = "0" ] && [ "$code" = "2" ] && [ "$(awk '/timed out/{n++} END{print n+0}' err.txt)" != "0" ]; then
echo "RESULT: spec-kit's runner timed the handler out and exited 2; Claude Code reads exit 2 from a Stop hook as a block"; exit 0
fi
echo "RESULT: not reproduced on $REF (in-time exit $okcode, timeout exit $code)"; exit 1Suggested fix
- When the runner itself times out or fails, exit 0 and print a note, so a failure never blocks the agent.
- Pass the event name to the script, for example as
SPECKIT_EVENT. - Document what arrives on stdin.
AI disclosure: drafted with Claude Code (Claude Opus 5.5, default settings) from runs in a throwaway sandbox; I checked it before filing.
Metadata
Metadata
Assignees
Labels
triage-must-haveVerdict: high-value, important work for Spec Kit — do firstVerdict: high-value, important work for Spec Kit — do first
Activity
fix(events): keep pre_tool_use runner failures blocking, require runn…