ralph-wiggum stop hook: jq error handler is unreachable under set -e, leaving a stale ralph-loop state file

Status Open
Maintainer reply None cached
Activity 1 comment · opened Sep 17, 2026

What's Wrong?

In plugins/ralph-wiggum/hooks/stop-hook.sh, the jq parse-failure handler is unreachable.

The script runs under set -euo pipefail (line 7). Line 90 assigns from a jq command substitution, and lines 97–105 are meant to handle a parse failure:

LAST_OUTPUT=$(echo "$LAST_LINE" | jq -r '...' 2>&1)

# Check if jq succeeded
if [[ $? -ne 0 ]]; then
  echo "⚠️  Ralph loop: Failed to parse assistant message JSON" >&2
  ...
  rm "$RALPH_STATE_FILE"
  exit 0
fi

Under set -e, a failing command substitution in an assignment aborts the script at line 90, so the if block never executes.

The consequence that bites is the leftover state file. When a transcript line matches "role":"assistant" but is not valid JSON (for example, a JSONL line truncated mid-write):

| | intended | actual |
|---|---|---|
| exit code | 0 | 5 (jq's status) |
| user guidance | printed | never printed |
| .claude/ralph-loop.local.md | removed | left on disk |

A stale state file makes the next session's Stop hook see an active loop and resume it with the old prompt — exactly what the handler was written to prevent.

What Should Happen?

On a jq parse failure the hook should do what the handler says: print the guidance, remove .claude/ralph-loop.local.md, and exit 0 so the loop stops cleanly.

Error Messages/Logs

Nothing is printed. The hook exits with jq's status instead:

exit=5
state file still present? YES

Expected (and what happens once the handler is reachable):

⚠️  Ralph loop: Failed to parse assistant message JSON
   Error: jq: error (at <stdin>:1): ...
   This may indicate a transcript format issue
   Ralph loop is stopping.
exit=0
state file still present? no

Steps to Reproduce

Runs against the real script, no plugin install needed:

#!/bin/bash
# usage: repro.sh <path to plugins/ralph-wiggum/hooks/stop-hook.sh>
HOOK="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
SANDBOX="$(mktemp -d)"; cd "$SANDBOX" || exit 1; mkdir -p .claude

cat > .claude/ralph-loop.local.md <<'STATE'
---
iteration: 1
max_iterations: 10
completion_promise: "DONE"
---
Keep improving the test suite.
STATE

# a line that matches '"role":"assistant"' but is not valid JSON
printf '%s\n' '{"role":"assistant","message":{"content":[{"type":"text","text":"hi"' > broken.jsonl

printf '{"transcript_path": "%s", "stop_hook_active": false}' "$SANDBOX/broken.jsonl" \
  | bash "$HOOK"; echo "exit=$?"
echo "state file still present? $([ -f .claude/ralph-loop.local.md ] && echo YES || echo no)"

Observed on main (commit of 2026-09-17): exit=5, no guidance printed, state file still present? YES.

Suggested fix

Wrap the assignment in set +e / set -e and capture the status explicitly. This is the idiom the repo already uses in plugins/plugin-dev/skills/hook-development/scripts/test-hook.sh#L189-L192:

 # Parse JSON with error handling
+set +e
 LAST_OUTPUT=$(echo "$LAST_LINE" | jq -r '
   .message.content |
   map(select(.type == "text")) |
   map(.text) |
   join("\n")
 ' 2>&1)
+JQ_STATUS=$?
+set -e

 # Check if jq succeeded
-if [[ $? -ne 0 ]]; then
+if [[ $JQ_STATUS -ne 0 ]]; then

One file, 8 insertions, 1 deletion, bash -n clean.

I verified the patch with the corrupted transcript above plus two healthy-path controls, so a green result cannot come from the hook simply doing nothing:

| case | before | after |
|---|---|---|
| corrupted transcript | exit 5, no message, state file remains | exit 0, message shown, state file removed |
| control — healthy transcript | decision=block, iteration 1→2 | unchanged |
| control — completion promise | loop ends, state file removed | unchanged |

Both controls pass before and after, so the change is confined to the error path.

Branch with the commit, in case it is useful: https://github.com/2001056/claude-code/tree/fix/ralph-stop-hook-jq-error-handler

I tried to open this as a PR first, but CreatePullRequest is refused for outside forks, so filing it here instead. Happy to send a PR if that channel is reopened.

Is this a regression?

Not sure / haven't tested previous versions

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗