feat: adopt Ralph pattern — pipe to claude (no --print), working Stop hook
This commit is contained in:
51
lib/hooks.sh
51
lib/hooks.sh
@@ -1,19 +1,50 @@
|
||||
#!/bin/bash
|
||||
# Stop hook management for Claude Code loop continuation.
|
||||
# Stop hook management for the agent loop.
|
||||
#
|
||||
# NOTE: Hooks are currently no-ops. The loop uses `claude --print` (non-interactive),
|
||||
# which runs to completion and exits naturally — no Stop hook is needed to signal
|
||||
# iteration boundaries. The install/remove interface is preserved so that a future
|
||||
# interactive mode can be added without changing loop.sh's call sites.
|
||||
# The Stop hook sends SIGINT to the loop's parent process when a claude session
|
||||
# finishes. This is how claude signals "I'm done" back to the bash loop —
|
||||
# the Ralph pattern (ghuntley.com/ralph).
|
||||
#
|
||||
# If interactive mode is added, the hook mechanism will need redesign: `kill -INT $PPID`
|
||||
# targets the hook runner's parent (Claude Code), not loop.sh. A sentinel-file or
|
||||
# named-pipe approach would be more reliable.
|
||||
# Without this hook, claude would exit to an interactive prompt instead of
|
||||
# returning control to the loop script.
|
||||
|
||||
SETTINGS_FILE="${PROJECT_ROOT}/.claude/settings.local.json"
|
||||
|
||||
install_hooks() {
|
||||
: # no-op — see note above
|
||||
if [ ! -f "$SETTINGS_FILE" ]; then
|
||||
mkdir -p "$(dirname "$SETTINGS_FILE")"
|
||||
echo '{}' > "$SETTINGS_FILE"
|
||||
fi
|
||||
|
||||
if command -v jq &>/dev/null; then
|
||||
jq '.hooks.Stop = [{"matcher": "", "hooks": [{"type": "command", "command": "kill -INT $PPID || true"}]}]' \
|
||||
"$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
|
||||
else
|
||||
python3 -c "
|
||||
import json, os
|
||||
p = '$SETTINGS_FILE'
|
||||
s = json.load(open(p)) if os.path.exists(p) else {}
|
||||
s.setdefault('hooks', {})['Stop'] = [{'matcher': '', 'hooks': [{'type': 'command', 'command': 'kill -INT \$PPID || true'}]}]
|
||||
json.dump(s, open(p, 'w'), indent=2)
|
||||
"
|
||||
fi
|
||||
log "Stop hook installed"
|
||||
}
|
||||
|
||||
remove_hooks() {
|
||||
: # no-op — see note above
|
||||
if [ -f "$SETTINGS_FILE" ]; then
|
||||
if command -v jq &>/dev/null; then
|
||||
jq 'del(.hooks.Stop)' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
|
||||
jq 'if .hooks == {} then del(.hooks) else . end' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
|
||||
else
|
||||
python3 -c "
|
||||
import json
|
||||
s = json.load(open('$SETTINGS_FILE'))
|
||||
s.get('hooks', {}).pop('Stop', None)
|
||||
if not s.get('hooks'): s.pop('hooks', None)
|
||||
json.dump(s, open('$SETTINGS_FILE', 'w'), indent=2)
|
||||
"
|
||||
fi
|
||||
log "Stop hook removed"
|
||||
fi
|
||||
}
|
||||
|
||||
41
loop.sh
41
loop.sh
@@ -188,40 +188,33 @@ fi
|
||||
|
||||
# --- Agent runner ---
|
||||
# Runs a prompt through the selected AI tool.
|
||||
# Two modes:
|
||||
# Interactive (default when TTY available): runs claude in full interactive mode.
|
||||
# The user sees the complete CC session (tool calls, file edits, etc.) in the terminal.
|
||||
# No output capture — state is tracked via prd.json and .verdict file.
|
||||
# Headless (no TTY or LOOP_HEADLESS=true): uses claude --print for fully autonomous operation.
|
||||
# Output is captured to a temp file for verdict parsing.
|
||||
#
|
||||
# The function prints captured output to stdout (headless) or nothing (interactive).
|
||||
# Interactive (default): Pipes prompt to claude WITHOUT --print.
|
||||
# This gives the full interactive CC UI — tool calls, file edits, etc.
|
||||
# A Stop hook (installed at startup) sends SIGINT to the loop when claude
|
||||
# finishes, which returns control to the while loop for the next iteration.
|
||||
# State is tracked via files (prd.json, .verdict), not stdout.
|
||||
#
|
||||
# Headless (LOOP_HEADLESS=true): Uses claude --print for CI/background.
|
||||
# Output captured to file for verdict parsing.
|
||||
run_agent() {
|
||||
local prompt="$1"
|
||||
local role="${2:-}" # "generator" or "evaluator" — used for verdict file
|
||||
local role="${2:-}"
|
||||
|
||||
# Clean up any previous verdict file
|
||||
rm -f "$LOOP_DIR/.verdict"
|
||||
|
||||
# Determine whether we can run interactively
|
||||
local has_tty=false
|
||||
if [ "${LOOP_HEADLESS:-false}" != "true" ] && { true > /dev/tty; } 2>/dev/null; then
|
||||
has_tty=true
|
||||
fi
|
||||
|
||||
# Run in subshell so a non-zero exit from the AI tool doesn't kill the loop.
|
||||
local agent_exit=0
|
||||
if [ "$has_tty" = true ]; then
|
||||
# --- Interactive mode ---
|
||||
# Run claude directly in the terminal — full interactive UI visible.
|
||||
# No output capture. State tracked via files (prd.json, .verdict).
|
||||
if [ "${LOOP_HEADLESS:-false}" != "true" ]; then
|
||||
# --- Interactive mode (Ralph pattern) ---
|
||||
# Pipe prompt to claude without --print — full interactive UI.
|
||||
# The Stop hook handles session exit.
|
||||
(
|
||||
case "$TOOL" in
|
||||
claude)
|
||||
claude --dangerously-skip-permissions "$prompt"
|
||||
printf '%s\n' "$prompt" | claude --dangerously-skip-permissions
|
||||
;;
|
||||
amp)
|
||||
amp --dangerously-allow-all "$prompt"
|
||||
printf '%s\n' "$prompt" | amp --dangerously-allow-all
|
||||
;;
|
||||
*)
|
||||
log "ERROR: Unknown tool '$TOOL'"
|
||||
@@ -230,7 +223,9 @@ run_agent() {
|
||||
esac
|
||||
) || agent_exit=$?
|
||||
|
||||
# In interactive mode, read verdict from file if evaluator wrote one
|
||||
sleep 2 # Brief pause between sessions
|
||||
|
||||
# Read verdict from file if evaluator wrote one
|
||||
if [ "$role" = "evaluator" ] && [ -f "$LOOP_DIR/.verdict" ]; then
|
||||
cat "$LOOP_DIR/.verdict"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user