agentic-loop 3.19.0 → 3.22.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.
- package/.claude/commands/tour.md +11 -7
- package/.claude/commands/vibe-help.md +5 -2
- package/.claude/commands/vibe-list.md +17 -2
- package/.claude/skills/prd/SKILL.md +21 -6
- package/.claude/skills/setup-review/SKILL.md +56 -0
- package/.claude/skills/tour/SKILL.md +11 -7
- package/.claude/skills/vibe-help/SKILL.md +2 -1
- package/.claude/skills/vibe-list/SKILL.md +5 -2
- package/.pre-commit-hooks.yaml +8 -0
- package/README.md +4 -0
- package/bin/agentic-loop.sh +7 -0
- package/bin/ralph.sh +29 -0
- package/dist/checks/check-signs-secrets.d.ts +9 -0
- package/dist/checks/check-signs-secrets.d.ts.map +1 -0
- package/dist/checks/check-signs-secrets.js +57 -0
- package/dist/checks/check-signs-secrets.js.map +1 -0
- package/dist/checks/index.d.ts +2 -5
- package/dist/checks/index.d.ts.map +1 -1
- package/dist/checks/index.js +4 -9
- package/dist/checks/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/ralph/hooks/common.sh +47 -0
- package/ralph/hooks/warn-debug.sh +12 -26
- package/ralph/hooks/warn-empty-catch.sh +21 -34
- package/ralph/hooks/warn-secrets.sh +39 -52
- package/ralph/hooks/warn-urls.sh +25 -45
- package/ralph/init.sh +58 -82
- package/ralph/loop.sh +506 -53
- package/ralph/prd-check.sh +177 -236
- package/ralph/prd.sh +5 -2
- package/ralph/setup/quick-setup.sh +2 -16
- package/ralph/setup.sh +68 -80
- package/ralph/signs.sh +8 -0
- package/ralph/uat.sh +2664 -0
- package/ralph/utils.sh +213 -70
- package/ralph/verify/tests.sh +65 -10
- package/templates/PROMPT.md +10 -4
- package/templates/UAT-PROMPT.md +197 -0
- package/templates/config/elixir.json +0 -2
- package/templates/config/fastmcp.json +0 -2
- package/templates/config/fullstack.json +2 -4
- package/templates/config/go.json +0 -2
- package/templates/config/minimal.json +0 -2
- package/templates/config/node.json +0 -2
- package/templates/config/python.json +0 -2
- package/templates/config/rust.json +0 -2
- 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 '.
|
|
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="
|
|
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=$(
|
|
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=$(
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
+
}
|
|
545
|
+
|
|
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
|
|
397
572
|
|
|
398
|
-
|
|
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 "
|
|
464
|
-
echo "
|
|
465
|
-
echo "
|
|
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
|
-
|
|
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
|
-
|
|
687
|
-
|
|
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
|
-
#
|
|
700
|
-
#
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
#
|
|
716
|
-
|
|
717
|
-
#
|
|
718
|
-
|
|
719
|
-
|
|
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
|
|
@@ -768,6 +1159,28 @@ run_loop() {
|
|
|
768
1159
|
continue
|
|
769
1160
|
fi
|
|
770
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
|
+
|
|
771
1184
|
# Check for stop signal (user ran `ralph stop` or Ctrl+C while Claude was running)
|
|
772
1185
|
if [[ -f "$RALPH_DIR/.stop" ]]; then
|
|
773
1186
|
rm -f "$RALPH_DIR/.stop" "$prompt_file"
|
|
@@ -788,24 +1201,27 @@ run_loop() {
|
|
|
788
1201
|
# Session may be broken - reset for next attempt
|
|
789
1202
|
session_started=false
|
|
790
1203
|
|
|
791
|
-
#
|
|
1204
|
+
# Stop loop on repeated timeouts — story needs manual intervention
|
|
792
1205
|
if [[ $consecutive_timeouts -ge $max_timeouts ]]; then
|
|
793
|
-
print_error "Story $story timed out $max_timeouts times
|
|
1206
|
+
print_error "Story $story timed out $max_timeouts consecutive times — stopping loop"
|
|
794
1207
|
echo ""
|
|
795
|
-
echo "
|
|
796
|
-
echo "
|
|
797
|
-
echo " -
|
|
798
|
-
echo "
|
|
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"
|
|
799
1216
|
echo ""
|
|
800
1217
|
mkdir -p "$RALPH_DIR/failures"
|
|
801
1218
|
echo "Story $story timed out $max_timeouts consecutive times (${timeout_seconds}s each)" > "$RALPH_DIR/failures/$story.txt"
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
jq
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
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
|
|
809
1225
|
fi
|
|
810
1226
|
|
|
811
1227
|
# If running specific story, exit on failure
|
|
@@ -819,6 +1235,19 @@ run_loop() {
|
|
|
819
1235
|
rm -f "$prompt_file"
|
|
820
1236
|
session_started=true # Mark session as active for subsequent stories
|
|
821
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
|
+
|
|
822
1251
|
# 5. Run migrations BEFORE verification (tests need DB schema)
|
|
823
1252
|
if ! run_migrations_if_needed "$pre_story_sha"; then
|
|
824
1253
|
log_progress "$story" "FAILED" "Migration failed"
|
|
@@ -831,8 +1260,9 @@ run_loop() {
|
|
|
831
1260
|
echo ""
|
|
832
1261
|
# Capture verification output for failure context
|
|
833
1262
|
local verify_log="$RALPH_DIR/last_verification.log"
|
|
834
|
-
|
|
835
|
-
|
|
1263
|
+
local verify_exit=0
|
|
1264
|
+
run_verification "$story" 2>&1 | tee "$verify_log" || verify_exit=$?
|
|
1265
|
+
if [[ $verify_exit -eq 0 ]]; then
|
|
836
1266
|
# Mark story as complete and reset retry count
|
|
837
1267
|
update_json "$RALPH_DIR/prd.json" \
|
|
838
1268
|
--arg id "$story" '(.stories[] | select(.id==$id)) |= . + {passes: true, retryCount: 0}'
|
|
@@ -922,8 +1352,19 @@ run_loop() {
|
|
|
922
1352
|
# If running specific story, we're done
|
|
923
1353
|
[[ -n "$specific_story" ]] && return 0
|
|
924
1354
|
else
|
|
925
|
-
|
|
926
|
-
|
|
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
|
|
927
1368
|
|
|
928
1369
|
# If running specific story, exit on failure
|
|
929
1370
|
[[ -n "$specific_story" ]] && return 1
|
|
@@ -1130,6 +1571,18 @@ build_prompt() {
|
|
|
1130
1571
|
echo '```'
|
|
1131
1572
|
fi
|
|
1132
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
|
+
|
|
1133
1586
|
# Signs are critical - always inject to prevent repeated mistakes
|
|
1134
1587
|
_inject_signs
|
|
1135
1588
|
}
|
|
@@ -1247,6 +1700,6 @@ archive_feature() {
|
|
|
1247
1700
|
echo "All stories passed! PRD kept at: $RALPH_DIR/prd.json"
|
|
1248
1701
|
echo ""
|
|
1249
1702
|
echo "Next:"
|
|
1250
|
-
echo " /idea
|
|
1703
|
+
echo " Start a Claude Code session and run /idea to brainstorm your next feature."
|
|
1251
1704
|
echo " ralph status # See completed stories"
|
|
1252
1705
|
}
|