agentic-loop 3.18.2 → 3.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.claude/commands/tour.md +11 -7
  2. package/.claude/commands/vibe-help.md +5 -2
  3. package/.claude/commands/vibe-list.md +17 -2
  4. package/.claude/skills/prd/SKILL.md +21 -6
  5. package/.claude/skills/setup-review/SKILL.md +56 -0
  6. package/.claude/skills/tour/SKILL.md +11 -7
  7. package/.claude/skills/vibe-help/SKILL.md +2 -1
  8. package/.claude/skills/vibe-list/SKILL.md +5 -2
  9. package/.pre-commit-hooks.yaml +8 -0
  10. package/README.md +4 -0
  11. package/bin/agentic-loop.sh +7 -0
  12. package/bin/ralph.sh +35 -0
  13. package/dist/checks/check-signs-secrets.d.ts +9 -0
  14. package/dist/checks/check-signs-secrets.d.ts.map +1 -0
  15. package/dist/checks/check-signs-secrets.js +57 -0
  16. package/dist/checks/check-signs-secrets.js.map +1 -0
  17. package/dist/checks/index.d.ts +2 -5
  18. package/dist/checks/index.d.ts.map +1 -1
  19. package/dist/checks/index.js +4 -9
  20. package/dist/checks/index.js.map +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/package.json +2 -1
  26. package/ralph/hooks/common.sh +47 -0
  27. package/ralph/hooks/warn-debug.sh +12 -26
  28. package/ralph/hooks/warn-empty-catch.sh +21 -34
  29. package/ralph/hooks/warn-secrets.sh +39 -52
  30. package/ralph/hooks/warn-urls.sh +25 -45
  31. package/ralph/init.sh +60 -82
  32. package/ralph/loop.sh +533 -53
  33. package/ralph/prd-check.sh +177 -236
  34. package/ralph/prd.sh +5 -2
  35. package/ralph/setup/quick-setup.sh +2 -16
  36. package/ralph/setup.sh +68 -80
  37. package/ralph/signs.sh +8 -0
  38. package/ralph/uat.sh +2015 -0
  39. package/ralph/utils.sh +198 -69
  40. package/ralph/verify/tests.sh +65 -10
  41. package/templates/PROMPT.md +10 -4
  42. package/templates/UAT-PROMPT.md +197 -0
  43. package/templates/config/elixir.json +0 -2
  44. package/templates/config/fastmcp.json +0 -2
  45. package/templates/config/fullstack.json +2 -4
  46. package/templates/config/go.json +0 -2
  47. package/templates/config/minimal.json +0 -2
  48. package/templates/config/node.json +0 -2
  49. package/templates/config/python.json +0 -2
  50. package/templates/config/rust.json +0 -2
  51. package/templates/prd-example.json +6 -8
package/ralph/loop.sh CHANGED
@@ -29,7 +29,7 @@ preflight_checks() {
29
29
 
30
30
  # Check frontend connectivity if configured
31
31
  local test_url
32
- test_url=$(get_config '.testUrlBase' "")
32
+ test_url=$(get_config '.urls.frontend' "")
33
33
  if [[ -n "$test_url" ]]; then
34
34
  printf " Frontend connectivity ($test_url)... "
35
35
  if curl -sf --connect-timeout 5 "$test_url" >/dev/null 2>&1; then
@@ -48,8 +48,8 @@ preflight_checks() {
48
48
  # Check for alembic migrations
49
49
  if [[ -d "$backend_dir/alembic" ]] || [[ -d "$backend_dir/migrations" ]]; then
50
50
  printf " Database migrations... "
51
- # Detect Python runner
52
- local py_runner="python"
51
+ # Detect Python runner (python3 for macOS compatibility)
52
+ local py_runner="python3"
53
53
  if [[ -f "$backend_dir/uv.lock" ]]; then
54
54
  py_runner="uv run"
55
55
  elif [[ -f "$backend_dir/poetry.lock" ]]; then
@@ -158,6 +158,22 @@ _write_preflight_cache() {
158
158
  echo "$(date +%s) $config_hash" > "$RALPH_DIR/.preflight_cache"
159
159
  }
160
160
 
161
+ _prd_structure_hash() {
162
+ # Hash only the fields that PRD validation cares about (story structure, testSteps, etc.)
163
+ # Ignores runtime state (passes, retryCount, skipped) so loop progress doesn't
164
+ # invalidate the validation cache and trigger expensive re-fixes on every restart.
165
+ jq '[.stories[] | del(.passes, .retryCount, .skipped, .skipReason)] | sort_by(.id)' \
166
+ "$RALPH_DIR/prd.json" 2>/dev/null | _file_hash_stdin
167
+ }
168
+
169
+ _file_hash_stdin() {
170
+ if command -v md5sum &>/dev/null; then
171
+ md5sum | cut -d' ' -f1
172
+ else
173
+ md5 -q
174
+ fi
175
+ }
176
+
161
177
  _is_prd_cached() {
162
178
  local cache_file="$RALPH_DIR/.prd_validated"
163
179
  [[ ! -f "$cache_file" ]] && return 1
@@ -170,7 +186,7 @@ _is_prd_cached() {
170
186
  [[ $(( now - cached_time )) -gt $PREFLIGHT_CACHE_TTL_SECONDS ]] && return 1
171
187
 
172
188
  local prd_hash
173
- prd_hash=$(_file_hash "$RALPH_DIR/prd.json")
189
+ prd_hash=$(_prd_structure_hash)
174
190
  [[ "$cached_hash" != "$prd_hash" ]] && return 1
175
191
 
176
192
  return 0
@@ -178,7 +194,7 @@ _is_prd_cached() {
178
194
 
179
195
  _write_prd_cache() {
180
196
  local prd_hash
181
- prd_hash=$(_file_hash "$RALPH_DIR/prd.json")
197
+ prd_hash=$(_prd_structure_hash)
182
198
  echo "$(date +%s) $prd_hash" > "$RALPH_DIR/.prd_validated"
183
199
  }
184
200
 
@@ -317,6 +333,9 @@ $existing_signs
317
333
  ## Rules
318
334
  - Extract a single, actionable pattern that prevents this class of failure
319
335
  - The pattern should be general enough to apply to future stories, not specific to this one
336
+ - NEVER include credentials, passwords, API keys, tokens, emails, or secrets in the pattern
337
+ Instead of: "Login with admin@example.com / Password123"
338
+ Write: "Use Playwright to login with test credentials from environment variables"
320
339
  - If the failure is trivial, unclear, or you can't extract a useful pattern, respond with just: NONE
321
340
  - Category must be one of: backend, frontend, testing, general, database, security
322
341
 
@@ -373,6 +392,12 @@ PATTERN: <pattern>"
373
392
  ;;
374
393
  esac
375
394
 
395
+ # Reject signs that contain credentials or secrets
396
+ if echo "$pattern" | grep -qiE '([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}|password[[:space:]]*[:=]|[[:space:]][A-Za-z0-9_]*_?(pass|pwd|token|secret|key|api.?key)[[:space:]]*[:=]|sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{36})'; then
397
+ log_progress "$story" "SIGN_AUTO" "Skipped - pattern contains credentials"
398
+ return 0
399
+ fi
400
+
376
401
  # Check for duplicates before adding
377
402
  if _sign_is_duplicate "$pattern"; then
378
403
  log_progress "$story" "SIGN_AUTO" "Skipped - duplicate of existing sign"
@@ -390,14 +415,234 @@ PATTERN: <pattern>"
390
415
  return 0
391
416
  }
392
417
 
393
- run_loop() {
394
- # Trap Ctrl+C so it stops the entire loop, not just the current child process.
395
- # Without this, SIGINT only kills the pipeline (claude|tee) and the while loop continues.
396
- trap 'echo ""; print_warning "Ctrl+C received — stopping loop..."; trap - INT; kill -INT $$' INT
418
+ # Generate a starter docker-compose.yml based on detected project type
419
+ _generate_docker_compose() {
420
+ local project_type
421
+ project_type=$(detect_project_type)
422
+
423
+ local compose_content=""
424
+
425
+ case "$project_type" in
426
+ fullstack|django|fastapi|python|node)
427
+ compose_content="services:
428
+ postgres:
429
+ image: postgres:16-alpine
430
+ restart: unless-stopped
431
+ environment:
432
+ POSTGRES_USER: app
433
+ POSTGRES_PASSWORD: app
434
+ POSTGRES_DB: app_dev
435
+ ports:
436
+ - \"5432:5432\"
437
+ volumes:
438
+ - pgdata:/var/lib/postgresql/data
439
+ healthcheck:
440
+ test: [\"CMD-SHELL\", \"pg_isready -U app\"]
441
+ interval: 5s
442
+ timeout: 3s
443
+ retries: 5
444
+
445
+ redis:
446
+ image: redis:7-alpine
447
+ restart: unless-stopped
448
+ ports:
449
+ - \"6379:6379\"
450
+ healthcheck:
451
+ test: [\"CMD\", \"redis-cli\", \"ping\"]
452
+ interval: 5s
453
+ timeout: 3s
454
+ retries: 5
455
+
456
+ volumes:
457
+ pgdata:"
458
+ ;;
459
+ go|rust)
460
+ compose_content="services:
461
+ postgres:
462
+ image: postgres:16-alpine
463
+ restart: unless-stopped
464
+ environment:
465
+ POSTGRES_USER: app
466
+ POSTGRES_PASSWORD: app
467
+ POSTGRES_DB: app_dev
468
+ ports:
469
+ - \"5432:5432\"
470
+ volumes:
471
+ - pgdata:/var/lib/postgresql/data
472
+ healthcheck:
473
+ test: [\"CMD-SHELL\", \"pg_isready -U app\"]
474
+ interval: 5s
475
+ timeout: 3s
476
+ retries: 5
477
+
478
+ volumes:
479
+ pgdata:"
480
+ ;;
481
+ elixir)
482
+ compose_content="services:
483
+ postgres:
484
+ image: postgres:16-alpine
485
+ restart: unless-stopped
486
+ environment:
487
+ POSTGRES_USER: postgres
488
+ POSTGRES_PASSWORD: postgres
489
+ POSTGRES_DB: app_dev
490
+ ports:
491
+ - \"5432:5432\"
492
+ volumes:
493
+ - pgdata:/var/lib/postgresql/data
494
+ healthcheck:
495
+ test: [\"CMD-SHELL\", \"pg_isready -U postgres\"]
496
+ interval: 5s
497
+ timeout: 3s
498
+ retries: 5
499
+
500
+ volumes:
501
+ pgdata:"
502
+ ;;
503
+ fastmcp)
504
+ compose_content="services:
505
+ app:
506
+ build: .
507
+ ports:
508
+ - \"8000:8000\"
509
+ volumes:
510
+ - .:/app
511
+ environment:
512
+ - LOG_LEVEL=debug"
513
+ ;;
514
+ *)
515
+ # minimal or unknown — shouldn't reach here but handle gracefully
516
+ print_warning "No Docker template for project type: $project_type"
517
+ return 1
518
+ ;;
519
+ esac
520
+
521
+ echo "$compose_content" > docker-compose.yml
522
+ print_success "Generated docker-compose.yml for $project_type project"
523
+ echo ""
524
+ echo " Services:"
525
+ grep -E '^[[:space:]]{2}[a-z]' docker-compose.yml | sed 's/:$//' | sed 's/^/ /'
526
+ echo ""
527
+ echo " Next steps:"
528
+ echo " docker compose up -d"
529
+ echo " docker compose ps # verify services are healthy"
530
+ echo ""
531
+ echo " Update your .env to point at the Docker services:"
532
+ case "$project_type" in
533
+ elixir)
534
+ echo " DATABASE_URL=ecto://postgres:postgres@localhost:5432/app_dev"
535
+ ;;
536
+ fastmcp)
537
+ ;;
538
+ *)
539
+ echo " DATABASE_URL=postgresql://app:app@localhost:5432/app_dev"
540
+ echo " REDIS_URL=redis://localhost:6379"
541
+ ;;
542
+ esac
543
+ echo ""
544
+ }
397
545
 
398
- local max_iterations="$DEFAULT_MAX_ITERATIONS"
546
+ # Check for Docker compose files and warn if missing
547
+ # Called from run_loop() before preflight checks
548
+ _docker_safety_warning() {
549
+ # Skip if env var is set
550
+ [[ "${RALPH_SKIP_DOCKER_WARNING:-}" == "1" ]] && return 0
551
+
552
+ # Skip if docker.enabled is true in config
553
+ local docker_enabled
554
+ docker_enabled=$(get_config '.docker.enabled' "false")
555
+ if [[ "$docker_enabled" == "true" ]]; then
556
+ return 0
557
+ fi
558
+
559
+ # Skip if project type doesn't need services
560
+ local project_type
561
+ project_type=$(detect_project_type)
562
+ if [[ "$project_type" == "minimal" || "$project_type" == "hugo" ]]; then
563
+ return 0
564
+ fi
565
+
566
+ # Check for any compose file
567
+ for compose_file in "docker-compose.yml" "docker-compose.yaml" "compose.yml" "compose.yaml"; do
568
+ if [[ -f "$compose_file" ]]; then
569
+ return 0
570
+ fi
571
+ done
572
+
573
+ # No compose file found — show warning
574
+ echo ""
575
+ echo " ╔══════════════════════════════════════════════════════════════════╗"
576
+ echo " ║ ║"
577
+ echo " ║ ⚠️ YOUR PROJECT IS NOT USING DOCKER ║"
578
+ echo " ║ ║"
579
+ echo " ║ Ralph runs autonomously — it writes code, runs commands, ║"
580
+ echo " ║ and executes migrations without asking. ║"
581
+ echo " ║ ║"
582
+ echo " ║ Without Docker, Ralph operates directly on your local ║"
583
+ echo " ║ machine. A bad migration can corrupt real databases. ║"
584
+ echo " ║ A runaway command can affect services outside this repo. ║"
585
+ echo " ║ ║"
586
+ echo " ║ With Docker, your project's services are isolated: ║"
587
+ echo " ║ - Databases and caches run in containers, not locally ║"
588
+ echo " ║ - Nothing outside the project is touched ║"
589
+ echo " ║ - Reset anytime: docker compose down -v && up -d ║"
590
+ echo " ║ ║"
591
+ echo " ║ Suppress: RALPH_SKIP_DOCKER_WARNING=1 npx agentic-loop run ║"
592
+ echo " ║ ║"
593
+ echo " ╚══════════════════════════════════════════════════════════════════╝"
594
+ echo ""
595
+
596
+ local response
597
+ read -r -p " [S]et up Docker now | [C]ontinue without Docker | [Q]uit: " response
598
+
599
+ case "$response" in
600
+ [Ss])
601
+ echo ""
602
+ if ! _generate_docker_compose; then
603
+ print_info "You can create a docker-compose.yml manually, or continue without Docker."
604
+ fi
605
+ ;;
606
+ [Qq])
607
+ echo ""
608
+ echo " Exiting. Set up Docker and try again, or suppress with:"
609
+ echo " RALPH_SKIP_DOCKER_WARNING=1 npx agentic-loop run"
610
+ echo ""
611
+ exit 0
612
+ ;;
613
+ *)
614
+ # Default: continue
615
+ echo ""
616
+ print_info "Continuing without Docker. Suppress future warnings with:"
617
+ echo " RALPH_SKIP_DOCKER_WARNING=1 npx agentic-loop run"
618
+ echo ""
619
+ ;;
620
+ esac
621
+ }
622
+
623
+ run_loop() {
624
+ # PID of the currently running Claude pipeline (used by trap to kill it)
625
+ _CLAUDE_PIPELINE_PID=""
626
+
627
+ # Trap Ctrl+C to kill Claude and stop the loop.
628
+ # When Claude runs as a foreground pipeline, bash defers trap handling until the
629
+ # pipeline exits — so Ctrl+C just gets swallowed. We solve this by running the
630
+ # pipeline in a background subshell and `wait`ing for it, which lets the trap
631
+ # fire immediately. The trap kills the subshell, touches .stop, then exits.
632
+ trap '
633
+ echo ""
634
+ print_warning "Ctrl+C received — stopping loop..."
635
+ [[ -n "$_CLAUDE_PIPELINE_PID" ]] && kill -TERM "$_CLAUDE_PIPELINE_PID" 2>/dev/null
636
+ touch "$RALPH_DIR/.stop"
637
+ kill 0 2>/dev/null
638
+ exit 130
639
+ ' INT
640
+
641
+ local max_iterations="" # No cap by default — per-story circuit breaker is the safety net
399
642
  local specific_story=""
400
643
  local fast_mode=false
644
+ local quiet_mode
645
+ quiet_mode=$(get_config '.quiet' "false")
401
646
 
402
647
  # Parse arguments
403
648
  while [[ $# -gt 0 ]]; do
@@ -414,6 +659,10 @@ run_loop() {
414
659
  fast_mode=true
415
660
  shift
416
661
  ;;
662
+ --quiet)
663
+ quiet_mode=true
664
+ shift
665
+ ;;
417
666
  *)
418
667
  shift
419
668
  ;;
@@ -426,6 +675,9 @@ run_loop() {
426
675
  # Validate prerequisites
427
676
  check_dependencies
428
677
 
678
+ # Warn if no Docker compose file (safety net for autonomous execution)
679
+ _docker_safety_warning
680
+
429
681
  # Pre-loop checks to catch issues before wasting iterations
430
682
  if [[ "$fast_mode" == "true" ]]; then
431
683
  print_info "Fast mode: skipping connectivity checks"
@@ -460,9 +712,21 @@ run_loop() {
460
712
  else
461
713
  print_error "No PRD found."
462
714
  echo ""
463
- echo "Create one with:"
464
- echo " /idea 'your feature description' # thorough (recommended)"
465
- echo " ralph prd 'your feature' # quick"
715
+ echo " You need to create a plan before Ralph can run."
716
+ echo ""
717
+ echo " 1. Open Claude in your terminal:"
718
+ echo ""
719
+ echo " claude --dangerously-skip-permissions"
720
+ echo ""
721
+ echo " 2. Inside Claude, type:"
722
+ echo ""
723
+ echo " /idea \"your feature description\""
724
+ echo ""
725
+ echo " Note: /idea is a Claude skill — it only works inside an active"
726
+ echo " Claude session, not from your regular terminal."
727
+ echo ""
728
+ echo " See Step 4 of the Getting Started guide:"
729
+ echo " docs/GETTING-STARTED.md"
466
730
  echo ""
467
731
  exit 1
468
732
  fi
@@ -499,13 +763,16 @@ run_loop() {
499
763
  # Default to 5 retries - enough for transient issues, stops before wasting cycles
500
764
  # Override with config.json: "maxStoryRetries": 8
501
765
  max_story_retries=$(get_config '.maxStoryRetries' "5")
766
+ # Read timeout once at loop start — not per iteration, so Claude can't extend it mid-run
767
+ local timeout_seconds
768
+ timeout_seconds=$(get_config '.maxSessionSeconds' "$DEFAULT_TIMEOUT_SECONDS")
502
769
  local total_attempts=0
503
770
  local skipped_stories=()
504
771
  local start_time
505
772
  local session_started=false # Track if we've started a Claude session
506
773
  start_time=$(date +%s)
507
774
 
508
- while [[ $iteration -lt $max_iterations ]]; do
775
+ while [[ -z "$max_iterations" || $iteration -lt $max_iterations ]]; do
509
776
  # Check for stop signal
510
777
  if [[ -f "$RALPH_DIR/.stop" ]]; then
511
778
  rm -f "$RALPH_DIR/.stop"
@@ -519,7 +786,11 @@ run_loop() {
519
786
 
520
787
  ((iteration++))
521
788
  echo ""
522
- print_info "=== Iteration $iteration/$max_iterations ==="
789
+ if [[ -n "$max_iterations" ]]; then
790
+ print_info "=== Iteration $iteration/$max_iterations ==="
791
+ else
792
+ print_info "=== Iteration $iteration ==="
793
+ fi
523
794
  echo ""
524
795
 
525
796
  # 1. Get next incomplete story
@@ -683,11 +954,12 @@ run_loop() {
683
954
  echo "└─────────────────────────────────────────────────────────┘"
684
955
  echo ""
685
956
 
686
- local timeout_seconds
687
- timeout_seconds=$(get_config '.maxSessionSeconds' "$DEFAULT_TIMEOUT_SECONDS")
957
+ # Snapshot PRD passes state before Claude runs (detect tampering after)
958
+ local passes_before
959
+ passes_before=$(jq '[.stories[] | {id, passes}]' "$RALPH_DIR/prd.json" 2>/dev/null)
688
960
 
689
961
  # Run Claude - first story gets fresh session, subsequent continue the session
690
- local -a claude_args=(-p --dangerously-skip-permissions --verbose)
962
+ local -a claude_args=(-p --dangerously-skip-permissions --verbose --output-format stream-json)
691
963
  if [[ "$session_started" == "true" ]]; then
692
964
  claude_args=(--continue "${claude_args[@]}")
693
965
  fi
@@ -696,27 +968,146 @@ run_loop() {
696
968
  local claude_output_log claude_exit_code max_crash_retries=5 crash_attempt=0
697
969
  claude_output_log=$(create_temp_file ".log") || { rm -f "$prompt_file"; return 1; }
698
970
 
699
- # Filter to hide ugly CLI crash messages from terminal (still captured in log)
700
- # Strips: "This error originated...", "Error: No messages", stack traces
701
- _filter_cli_noise() {
702
- grep -v -E \
703
- -e "This error originated either by throwing" \
704
- -e "a catch block, or by rejecting a promise" \
705
- -e "The promise rejected with the reason:" \
706
- -e "Error: No messages returned" \
707
- -e "at [A-Za-z0-9_]+ \(/\\\$bunfs/" \
708
- -e "at processTicksAndRejections" \
709
- -e "unhandled.*promise.*rejection" \
710
- || true # Don't fail if no lines pass filter
971
+ # Parse stream-json output from Claude CLI and display a live activity feed.
972
+ # Shows tool usage (Read, Edit, Bash, etc.) as clean one-line summaries.
973
+ # Non-JSON lines (crash messages) are passed through for crash detection.
974
+ # Usage: _parse_stream_activity "true"|"false"
975
+ _parse_stream_activity() {
976
+ local quiet="${1:-false}"
977
+ local dim=$'\033[2m' green=$'\033[0;32m' nc=$'\033[0m'
978
+ local line
979
+ while IFS= read -r line; do
980
+ # Non-JSON lines (crash messages, errors) — always pass through
981
+ if [[ "$line" != "{"* ]]; then
982
+ echo "$line"
983
+ continue
984
+ fi
985
+
986
+ # In quiet mode, skip all JSON parsing (activity suppressed)
987
+ if [[ "$quiet" == "true" ]]; then
988
+ continue
989
+ fi
990
+
991
+ # Fast pre-filter: only parse assistant (tool activity) and result (summary)
992
+ # events. Skip system/user/etc. to avoid unnecessary jq calls.
993
+ if [[ "$line" != *'"assistant"'* && "$line" != *'"result"'* ]]; then
994
+ continue
995
+ fi
996
+
997
+ local msg_type
998
+ msg_type=$(jq -r '.type // empty' <<< "$line" 2>/dev/null) || continue
999
+
1000
+ if [[ "$msg_type" == "assistant" ]]; then
1001
+ # Show Claude's explanation if present (the "what/why" before tool calls)
1002
+ local explanation
1003
+ explanation=$(jq -r '
1004
+ [.message.content[]? | select(.type == "text") | .text] | join(" ")
1005
+ ' <<< "$line" 2>/dev/null)
1006
+ if [[ -n "$explanation" ]]; then
1007
+ # Take first sentence and truncate at word boundary
1008
+ explanation="${explanation%%.*}"
1009
+ if [[ ${#explanation} -gt 72 ]]; then
1010
+ explanation="${explanation:0:72}"
1011
+ explanation="${explanation% *}..."
1012
+ fi
1013
+ printf " ${dim}— %s${nc}\n" "$explanation"
1014
+ fi
1015
+
1016
+ # Extract tool_use content blocks
1017
+ local tool_entries
1018
+ tool_entries=$(jq -r '
1019
+ .message.content[]?
1020
+ | select(.type == "tool_use")
1021
+ | .name + "\t" + (.input | tostring)
1022
+ ' <<< "$line" 2>/dev/null) || continue
1023
+
1024
+ while IFS=$'\t' read -r tool_name tool_input; do
1025
+ [[ -z "$tool_name" ]] && continue
1026
+ local label="" detail=""
1027
+ case "$tool_name" in
1028
+ Read)
1029
+ label="Reading"
1030
+ detail=$(jq -r '.file_path // empty' <<< "$tool_input" 2>/dev/null)
1031
+ detail="${detail#"$PWD/"}"
1032
+ ;;
1033
+ Edit)
1034
+ label="Editing"
1035
+ detail=$(jq -r '.file_path // empty' <<< "$tool_input" 2>/dev/null)
1036
+ detail="${detail#"$PWD/"}"
1037
+ ;;
1038
+ Write)
1039
+ label="Creating"
1040
+ detail=$(jq -r '.file_path // empty' <<< "$tool_input" 2>/dev/null)
1041
+ detail="${detail#"$PWD/"}"
1042
+ ;;
1043
+ Bash)
1044
+ label="Running"
1045
+ # Prefer human-readable description over raw command
1046
+ detail=$(jq -r '.description // empty' <<< "$tool_input" 2>/dev/null)
1047
+ if [[ -z "$detail" ]]; then
1048
+ detail=$(jq -r '.command // empty' <<< "$tool_input" 2>/dev/null)
1049
+ detail="${detail:0:60}"
1050
+ fi
1051
+ ;;
1052
+ Grep)
1053
+ label="Searching"
1054
+ detail=$(jq -r '.pattern // empty' <<< "$tool_input" 2>/dev/null)
1055
+ detail="for \"$detail\""
1056
+ ;;
1057
+ Glob)
1058
+ label="Finding"
1059
+ detail=$(jq -r '.pattern // empty' <<< "$tool_input" 2>/dev/null)
1060
+ detail="files matching $detail"
1061
+ ;;
1062
+ Task)
1063
+ label="Spawning"
1064
+ detail=$(jq -r '.description // empty' <<< "$tool_input" 2>/dev/null)
1065
+ ;;
1066
+ *)
1067
+ label="$tool_name"
1068
+ ;;
1069
+ esac
1070
+ printf " ${dim}⟳${nc} %-10s %s\n" "$label" "$detail"
1071
+ done <<< "$tool_entries"
1072
+
1073
+ elif [[ "$msg_type" == "result" ]]; then
1074
+ local cost duration_ms
1075
+ cost=$(jq -r '.total_cost_usd // empty' <<< "$line" 2>/dev/null)
1076
+ duration_ms=$(jq -r '.duration_ms // empty' <<< "$line" 2>/dev/null)
1077
+ local cost_str="" dur_str=""
1078
+ [[ -n "$cost" ]] && cost_str=$(printf '$%.2f' "$cost")
1079
+ if [[ -n "$duration_ms" ]]; then
1080
+ local total_secs=$(( duration_ms / 1000 ))
1081
+ if [[ $total_secs -ge 60 ]]; then
1082
+ dur_str="$((total_secs / 60))m $((total_secs % 60))s"
1083
+ else
1084
+ dur_str="${total_secs}s"
1085
+ fi
1086
+ fi
1087
+ echo ""
1088
+ if [[ -n "$cost_str" && -n "$dur_str" ]]; then
1089
+ echo -e " ${green}✓ Done${nc} ${dim}(${cost_str}, ${dur_str})${nc}"
1090
+ elif [[ -n "$cost_str" ]]; then
1091
+ echo -e " ${green}✓ Done${nc} ${dim}(${cost_str})${nc}"
1092
+ elif [[ -n "$dur_str" ]]; then
1093
+ echo -e " ${green}✓ Done${nc} ${dim}(${dur_str})${nc}"
1094
+ fi
1095
+ fi
1096
+ done
711
1097
  }
712
1098
 
713
1099
  while [[ $crash_attempt -lt $max_crash_retries ]]; do
714
1100
  claude_exit_code=0
715
- # Use pipefail to capture Claude's exit code, not tee's
716
- set -o pipefail
717
- # Capture full output to log, show filtered output to terminal
718
- cat "$prompt_file" | run_with_timeout "$timeout_seconds" claude "${claude_args[@]}" 2>&1 | tee "$claude_output_log" | _filter_cli_noise || claude_exit_code=$?
719
- set +o pipefail
1101
+ # Run Claude in a background subshell so the INT trap can fire immediately.
1102
+ # Without this, bash defers SIGINT handling until the foreground pipeline exits,
1103
+ # making Ctrl+C unresponsive while Claude is running.
1104
+ (
1105
+ set -o pipefail
1106
+ cat "$prompt_file" | run_with_timeout "$timeout_seconds" claude "${claude_args[@]}" 2>&1 | tee "$claude_output_log" | _parse_stream_activity "$quiet_mode"
1107
+ ) &
1108
+ _CLAUDE_PIPELINE_PID=$!
1109
+ wait "$_CLAUDE_PIPELINE_PID" || claude_exit_code=$?
1110
+ _CLAUDE_PIPELINE_PID=""
720
1111
 
721
1112
  # Check for recoverable CLI crashes (transient API failures)
722
1113
  if grep -qE "(No messages returned|unhandled.*promise.*rejection)" "$claude_output_log" 2>/dev/null; then
@@ -752,6 +1143,55 @@ run_loop() {
752
1143
 
753
1144
  rm -f "$claude_output_log"
754
1145
 
1146
+ # Check for skip signal (user ran `ralph skip` while Claude was running)
1147
+ if [[ -f "$RALPH_DIR/.skip" ]]; then
1148
+ rm -f "$RALPH_DIR/.skip"
1149
+ print_warning "Skip signal received — skipping $story"
1150
+ log_progress "$story" "SKIPPED" "User requested skip"
1151
+ skipped_stories+=("$story")
1152
+ jq --arg id "$story" '(.stories[] | select(.id==$id)) |= . + {skipped: true, skipReason: "user skipped"}' \
1153
+ "$RALPH_DIR/prd.json" > "$RALPH_DIR/prd.json.tmp" && mv "$RALPH_DIR/prd.json.tmp" "$RALPH_DIR/prd.json"
1154
+ last_story=""
1155
+ consecutive_failures=0
1156
+ consecutive_timeouts=0
1157
+ session_started=false
1158
+ rm -f "$prompt_file"
1159
+ continue
1160
+ fi
1161
+
1162
+ # Check for blocked signal (Claude created .blocked to indicate it's stuck)
1163
+ if [[ -f "$RALPH_DIR/.blocked" ]]; then
1164
+ local block_reason
1165
+ block_reason=$(cat "$RALPH_DIR/.blocked" 2>/dev/null | head -1)
1166
+ rm -f "$RALPH_DIR/.blocked"
1167
+ print_error "Story $story blocked — ${block_reason:-no reason given}"
1168
+ log_progress "$story" "BLOCKED" "${block_reason:-Claude signaled blocked}"
1169
+ echo ""
1170
+ echo " Claude signaled it cannot complete this story."
1171
+ echo " Check .ralph/progress.txt for details on what was attempted."
1172
+ echo ""
1173
+ mkdir -p "$RALPH_DIR/failures"
1174
+ echo "${block_reason:-Claude signaled blocked}" > "$RALPH_DIR/failures/$story.txt"
1175
+ local passed failed
1176
+ passed=$(jq '[.stories[] | select(.passes==true)] | length' "$RALPH_DIR/prd.json" 2>/dev/null || echo "0")
1177
+ failed=$(jq '[.stories[] | select(.passes==false)] | length' "$RALPH_DIR/prd.json" 2>/dev/null || echo "0")
1178
+ send_notification "🛑 Ralph stopped: $story blocked. $passed passed, $failed remaining"
1179
+ print_progress_summary "$start_time" "$total_attempts" "0"
1180
+ rm -f "$prompt_file"
1181
+ return 1
1182
+ fi
1183
+
1184
+ # Check for stop signal (user ran `ralph stop` or Ctrl+C while Claude was running)
1185
+ if [[ -f "$RALPH_DIR/.stop" ]]; then
1186
+ rm -f "$RALPH_DIR/.stop" "$prompt_file"
1187
+ print_warning "Stop signal received. Exiting gracefully."
1188
+ local passed failed
1189
+ passed=$(jq '[.stories[] | select(.passes==true)] | length' "$RALPH_DIR/prd.json" 2>/dev/null || echo "0")
1190
+ failed=$(jq '[.stories[] | select(.passes==false)] | length' "$RALPH_DIR/prd.json" 2>/dev/null || echo "0")
1191
+ send_notification "🛑 Ralph stopped: $passed passed, $failed remaining"
1192
+ return 0
1193
+ fi
1194
+
755
1195
  if [[ $claude_exit_code -ne 0 ]]; then
756
1196
  ((consecutive_timeouts++))
757
1197
  print_warning "Claude session ended (timeout or error) - timeout $consecutive_timeouts/$max_timeouts"
@@ -761,24 +1201,27 @@ run_loop() {
761
1201
  # Session may be broken - reset for next attempt
762
1202
  session_started=false
763
1203
 
764
- # Skip on repeated timeouts (story is too large/complex for single session)
1204
+ # Stop loop on repeated timeouts story needs manual intervention
765
1205
  if [[ $consecutive_timeouts -ge $max_timeouts ]]; then
766
- print_error "Story $story timed out $max_timeouts times - needs to be broken up"
1206
+ print_error "Story $story timed out $max_timeouts consecutive times stopping loop"
767
1207
  echo ""
768
- echo " Consecutive timeouts indicate the story is too large for a single"
769
- echo " Claude session (${timeout_seconds}s). Consider:"
770
- echo " - Breaking it into smaller, focused stories"
771
- echo " - Increasing maxSessionSeconds in config.json"
1208
+ echo " The story timed out ${max_timeouts}x (${timeout_seconds}s each). This usually means:"
1209
+ echo " - The story is too large for a single Claude session"
1210
+ echo " - Claude is stuck in a retry loop within the session"
1211
+ echo ""
1212
+ echo " To fix:"
1213
+ echo " - Break the story into smaller stories"
1214
+ echo " - Increase maxSessionSeconds in .ralph/config.json"
1215
+ echo " - Check .ralph/progress.txt for what Claude was attempting"
772
1216
  echo ""
773
1217
  mkdir -p "$RALPH_DIR/failures"
774
1218
  echo "Story $story timed out $max_timeouts consecutive times (${timeout_seconds}s each)" > "$RALPH_DIR/failures/$story.txt"
775
- echo "Consider breaking this story into smaller pieces." >> "$RALPH_DIR/failures/$story.txt"
776
- skipped_stories+=("$story")
777
- jq --arg id "$story" '(.stories[] | select(.id==$id)) |= . + {skipped: true, skipReason: "repeated timeouts"}' "$RALPH_DIR/prd.json" > "$RALPH_DIR/prd.json.tmp" && mv "$RALPH_DIR/prd.json.tmp" "$RALPH_DIR/prd.json"
778
- last_story=""
779
- consecutive_failures=0
780
- consecutive_timeouts=0
781
- continue
1219
+ local passed failed
1220
+ passed=$(jq '[.stories[] | select(.passes==true)] | length' "$RALPH_DIR/prd.json" 2>/dev/null || echo "0")
1221
+ failed=$(jq '[.stories[] | select(.passes==false)] | length' "$RALPH_DIR/prd.json" 2>/dev/null || echo "0")
1222
+ send_notification "🛑 Ralph stopped: $story timed out ${max_timeouts}x. $passed passed, $failed remaining"
1223
+ print_progress_summary "$start_time" "$total_attempts" "0"
1224
+ return 1
782
1225
  fi
783
1226
 
784
1227
  # If running specific story, exit on failure
@@ -792,6 +1235,19 @@ run_loop() {
792
1235
  rm -f "$prompt_file"
793
1236
  session_started=true # Mark session as active for subsequent stories
794
1237
 
1238
+ # Reset any story state changes Claude made — Ralph owns passes/retryCount/skipped
1239
+ local passes_after
1240
+ passes_after=$(jq '[.stories[] | {id, passes}]' "$RALPH_DIR/prd.json" 2>/dev/null)
1241
+ if [[ "$passes_before" != "$passes_after" ]]; then
1242
+ print_info "Resetting story state — verification will determine pass/fail"
1243
+ log_progress "$story" "RESET" "Story state modified during session, restored before verification"
1244
+ local restored
1245
+ restored=$(jq --argjson before "$passes_before" '
1246
+ .stories |= [.[] | . as $s | ($before[] | select(.id == $s.id)) as $orig |
1247
+ if $orig then $s + {passes: $orig.passes} else $s end]
1248
+ ' "$RALPH_DIR/prd.json") && echo "$restored" > "$RALPH_DIR/prd.json"
1249
+ fi
1250
+
795
1251
  # 5. Run migrations BEFORE verification (tests need DB schema)
796
1252
  if ! run_migrations_if_needed "$pre_story_sha"; then
797
1253
  log_progress "$story" "FAILED" "Migration failed"
@@ -804,8 +1260,9 @@ run_loop() {
804
1260
  echo ""
805
1261
  # Capture verification output for failure context
806
1262
  local verify_log="$RALPH_DIR/last_verification.log"
807
- set -o pipefail
808
- if run_verification "$story" 2>&1 | tee "$verify_log"; then
1263
+ local verify_exit=0
1264
+ run_verification "$story" 2>&1 | tee "$verify_log" || verify_exit=$?
1265
+ if [[ $verify_exit -eq 0 ]]; then
809
1266
  # Mark story as complete and reset retry count
810
1267
  update_json "$RALPH_DIR/prd.json" \
811
1268
  --arg id "$story" '(.stories[] | select(.id==$id)) |= . + {passes: true, retryCount: 0}'
@@ -895,8 +1352,19 @@ run_loop() {
895
1352
  # If running specific story, we're done
896
1353
  [[ -n "$specific_story" ]] && return 0
897
1354
  else
898
- log_progress "$story" "FAILED" "Verification failed, will retry"
899
- print_warning "Verification failed for $story, iterating..."
1355
+ # Show which step failed so users can diagnose stuck loops
1356
+ local failed_at=""
1357
+ if [[ -f "$verify_log" ]]; then
1358
+ # Strip ANSI codes before searching (print_error adds color)
1359
+ failed_at=$(sed 's/\x1b\[[0-9;]*m//g' "$verify_log" | grep -o "Verification failed at: .*" | sed 's/ =*$//' | tail -1)
1360
+ fi
1361
+ if [[ -n "$failed_at" ]]; then
1362
+ log_progress "$story" "FAILED" "$failed_at"
1363
+ print_warning "$failed_at — will retry $story..."
1364
+ else
1365
+ log_progress "$story" "FAILED" "Verification failed, will retry"
1366
+ print_warning "Verification failed for $story, iterating..."
1367
+ fi
900
1368
 
901
1369
  # If running specific story, exit on failure
902
1370
  [[ -n "$specific_story" ]] && return 1
@@ -1103,6 +1571,18 @@ build_prompt() {
1103
1571
  echo '```'
1104
1572
  fi
1105
1573
 
1574
+ # Session boundaries — Ralph controls story state, not Claude
1575
+ echo ""
1576
+ echo "## Session Rules"
1577
+ echo ""
1578
+ echo "- **When done implementing, stop.** Ralph runs verification (lint, tests, build) after your session and marks the story."
1579
+ echo "- **Don't edit prd.json** — Ralph manages story state. Any changes to passes/retryCount/skipped are reset before verification."
1580
+ echo "- **If blocked**, write the reason and stop:"
1581
+ echo ' ```'
1582
+ echo ' echo "BLOCKED: [describe the issue]" > .ralph/.blocked'
1583
+ echo ' ```'
1584
+ echo " Ralph will stop the loop so the issue can be resolved."
1585
+
1106
1586
  # Signs are critical - always inject to prevent repeated mistakes
1107
1587
  _inject_signs
1108
1588
  }
@@ -1220,6 +1700,6 @@ archive_feature() {
1220
1700
  echo "All stories passed! PRD kept at: $RALPH_DIR/prd.json"
1221
1701
  echo ""
1222
1702
  echo "Next:"
1223
- echo " /idea 'new feature' # Add more stories (will append)"
1703
+ echo " Start a Claude Code session and run /idea to brainstorm your next feature."
1224
1704
  echo " ralph status # See completed stories"
1225
1705
  }