# stuck-detector: an agent hook by Scalably

Canonical: https://scalably.io/hooks/stuck-detector
Source: https://github.com/scalably-io/agent-hooks/blob/v1.0.0/hooks/stuck-detector.sh
Runs on: PostToolUseFailure
Release: v1.0.0 at commit 53dcece
Integrity: sha256 of the script served at https://scalably.io/hooks/stuck-detector.sh is 7586c7b5ac72050972d91d5df0cee569de8fe816e7f30e0dc7c1c8b796cc4abe. Verify: curl -s https://scalably.io/hooks/stuck-detector.sh | shasum -a 256
This is the machine-readable representation of the page at the canonical URL. Same facts, denser format.

## Summary

PostToolUseFailure hook: catch an agent retrying the same failing call.

## Install

```
/plugin marketplace add scalably-io/agent-skills
/plugin install agent-hooks@scalably-agent-skills
```

## What it does

A loop here means the SAME tool, the SAME input, and the SAME error, repeated.
A different error means something moved, so it never fires. Semantic
stuckness, the kind where the approach is wrong but each attempt fails
differently, is deliberately out of scope: it cannot be detected from failure
text, and pretending otherwise would produce false alarms that teach people to
ignore the hook.

```text
  2nd identical failure  warn, do not block
  3rd and beyond         block and ask for a change of approach
```

The fingerprint is the whole trick. Volatile tokens (clock times, pids, hex
addresses, durations, temp paths, dates) are stripped before hashing, so two
runs of one error are one fingerprint rather than two, and unrelated errors do
not collide. Counters are per session, under the temp directory.

Exit: always 0. The block is expressed in the JSON decision, not the code.

## The whole script

```bash
#!/usr/bin/env bash
# PostToolUseFailure hook: catch an agent retrying the same failing call.
#
# A loop here means the SAME tool, the SAME input, and the SAME error, repeated.
# A different error means something moved, so it never fires. Semantic
# stuckness, the kind where the approach is wrong but each attempt fails
# differently, is deliberately out of scope: it cannot be detected from failure
# text, and pretending otherwise would produce false alarms that teach people to
# ignore the hook.
#
#   2nd identical failure  warn, do not block
#   3rd and beyond         block and ask for a change of approach
#
# The fingerprint is the whole trick. Volatile tokens (clock times, pids, hex
# addresses, durations, temp paths, dates) are stripped before hashing, so two
# runs of one error are one fingerprint rather than two, and unrelated errors do
# not collide. Counters are per session, under the temp directory.
#
# Exit: always 0. The block is expressed in the JSON decision, not the code.

input=$(cat)
tool=$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null) || exit 0
session=$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null) || session="nosession"
[ -z "$tool" ] && exit 0

# Input signature: the command for Bash, the path for edits, the whole input otherwise.
sig_input=$(printf '%s' "$input" | jq -r '.tool_input.command // .tool_input.file_path // (.tool_input|tostring)' 2>/dev/null | head -c 300)

err=$(printf '%s' "$input" | jq -r '(.tool_output // .error // "") | tostring' 2>/dev/null | \
  sed -E 's/[0-9]{2}:[0-9]{2}(:[0-9]{2})?//g;
          s/0x[0-9a-fA-F]+//g;
          s/[0-9]+(\.[0-9]+)?(ms|s|m) //g;
          s/(pid|PID)[ =:][0-9]+//g;
          s|/(private/)?tmp/[^ "]*||g;
          s/[0-9]{4}-[0-9]{2}-[0-9]{2}//g' | tail -c 400)
[ -z "$err" ] && exit 0

sig=$(printf '%s|%s|%s' "$tool" "$sig_input" "$err" | shasum 2>/dev/null | cut -c1-16)
f="${TMPDIR:-/tmp}/agent-hooks-stuck-${session}-${sig}"
n=$(( $(cat "$f" 2>/dev/null || echo 0) + 1 ))
printf '%s' "$n" > "$f" 2>/dev/null

warn="STUCK-DETECTOR: this exact call already failed with this exact error. Do not retry it unchanged. Change the approach, or say plainly that you are blocked and why."
stop="STUCK-DETECTOR: third identical failure, same call and same error. Stop retrying. A third attempt will fail the same way. Change the approach or escalate."

if [ "$n" -eq 2 ]; then
  jq -n --arg c "$warn" '{hookSpecificOutput:{hookEventName:"PostToolUseFailure", additionalContext:$c}}' 2>/dev/null
elif [ "$n" -ge 3 ]; then
  jq -n --arg r "$stop" '{decision:"block", reason:$r}' 2>/dev/null
fi
exit 0
```
