agentic-loop 3.16.3 → 3.17.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-loop",
3
- "version": "3.16.3",
3
+ "version": "3.17.1",
4
4
  "description": "Autonomous AI coding loop - PRD-driven development with Claude Code",
5
5
  "author": "Allie Jones <allie@allthrive.ai>",
6
6
  "license": "MIT",
@@ -100,6 +100,7 @@ run_verification() {
100
100
  export RALPH_STORY_TYPE="$story_type"
101
101
 
102
102
  local failed=0
103
+ local failed_step=""
103
104
 
104
105
  # ========================================
105
106
  # STEP 1: Run lint checks
@@ -107,6 +108,7 @@ run_verification() {
107
108
  echo " [1/5] Running lint checks..."
108
109
  if ! run_configured_checks "$story_type"; then
109
110
  failed=1
111
+ failed_step="lint"
110
112
  fi
111
113
 
112
114
  # ========================================
@@ -118,8 +120,10 @@ run_verification() {
118
120
  # First check that test files exist for new code
119
121
  if ! verify_test_files_exist; then
120
122
  failed=1
123
+ failed_step="test files missing"
121
124
  elif ! run_unit_tests; then
122
125
  failed=1
126
+ failed_step="unit tests"
123
127
  fi
124
128
  fi
125
129
 
@@ -131,6 +135,7 @@ run_verification() {
131
135
  echo " [3/5] Running PRD test steps..."
132
136
  if ! verify_prd_criteria "$story"; then
133
137
  failed=1
138
+ failed_step="PRD test steps"
134
139
  fi
135
140
  fi
136
141
 
@@ -140,6 +145,7 @@ run_verification() {
140
145
  if [[ $failed -eq 0 ]]; then
141
146
  if ! run_api_smoke_test "$story"; then
142
147
  failed=1
148
+ failed_step="API smoke test"
143
149
  fi
144
150
  fi
145
151
 
@@ -149,6 +155,7 @@ run_verification() {
149
155
  if [[ $failed -eq 0 ]]; then
150
156
  if ! run_frontend_smoke_test "$story"; then
151
157
  failed=1
158
+ failed_step="frontend smoke test"
152
159
  fi
153
160
  fi
154
161
 
@@ -160,7 +167,7 @@ run_verification() {
160
167
  print_success "=== All verification passed ==="
161
168
  return 0
162
169
  else
163
- print_error "=== Verification failed ==="
170
+ print_error "=== Verification failed at: $failed_step ==="
164
171
  save_failure_context "$story"
165
172
  return 1
166
173
  fi
@@ -194,8 +201,20 @@ save_failure_context() {
194
201
  echo ""
195
202
  echo "=== Attempt $attempt failed for $story ==="
196
203
  echo ""
204
+ # Include migration failure if present (verification may not have run)
205
+ if [[ -f "$RALPH_DIR/last_migration_failure.log" ]]; then
206
+ echo "--- Migration Error ---"
207
+ tail -30 "$RALPH_DIR/last_migration_failure.log"
208
+ echo ""
209
+ fi
210
+ # Include pre-commit failure if present
211
+ if [[ -f "$RALPH_DIR/last_precommit_failure.log" ]]; then
212
+ echo "--- Pre-commit Error ---"
213
+ tail -30 "$RALPH_DIR/last_precommit_failure.log"
214
+ echo ""
215
+ fi
216
+ # Include verification output (lint, tests, API, etc.)
197
217
  if [[ -f "$RALPH_DIR/last_verification.log" ]]; then
198
- # Shorter excerpt per attempt since we're accumulating
199
218
  tail -50 "$RALPH_DIR/last_verification.log"
200
219
  fi
201
220
  echo ""
package/ralph/loop.sh CHANGED
@@ -2,9 +2,13 @@
2
2
  # shellcheck shell=bash
3
3
  # loop.sh - The autonomous development loop
4
4
 
5
- # Pre-flight checks to catch common issues before wasting iterations
5
+ # Pre-loop checks to catch common issues before wasting iterations
6
6
  preflight_checks() {
7
- echo "--- Pre-flight Checks ---"
7
+ echo ""
8
+ echo " ┌──────────────────────────────────┐"
9
+ echo " │ ✅ Pre-Loop Checks │"
10
+ echo " └──────────────────────────────────┘"
11
+ echo ""
8
12
  local warnings=0
9
13
 
10
14
  # Check API connectivity if configured
@@ -86,14 +90,287 @@ preflight_checks() {
86
90
 
87
91
  echo ""
88
92
  if [[ $warnings -gt 0 ]]; then
89
- print_warning "$warnings pre-flight warning(s) - loop may fail on connectivity issues"
93
+ print_warning "$warnings pre-loop warning(s) - loop may fail on connectivity issues"
90
94
  echo ""
91
95
  read -r -p "Continue anyway? [Y/n] " response
92
96
  if [[ "$response" =~ ^[Nn] ]]; then
93
97
  echo "Aborted. Fix the issues and try again."
94
98
  exit 1
95
99
  fi
100
+ return 1 # Had warnings — don't cache this result
101
+ fi
102
+ }
103
+
104
+ # ============================================================================
105
+ # PREFLIGHT / PRD CACHE
106
+ # ============================================================================
107
+ # Caches preflight and PRD validation results so restarts within 10 minutes
108
+ # skip the slow connectivity checks and Claude auto-fix.
109
+ # Cache is invalidated by TTL expiry or config/PRD file changes (by hash).
110
+
111
+ _file_hash() {
112
+ [[ ! -f "$1" ]] && echo "no_file" && return
113
+ if command -v md5sum &>/dev/null; then
114
+ md5sum "$1" 2>/dev/null | cut -d' ' -f1
115
+ else
116
+ md5 -q "$1" 2>/dev/null
117
+ fi
118
+ }
119
+
120
+ _is_preflight_cached() {
121
+ local cache_file="$RALPH_DIR/.preflight_cache"
122
+ [[ ! -f "$cache_file" ]] && return 1
123
+
124
+ local cached_time cached_hash
125
+ read -r cached_time cached_hash < "$cache_file"
126
+
127
+ local now
128
+ now=$(date +%s)
129
+ [[ $(( now - cached_time )) -gt $PREFLIGHT_CACHE_TTL_SECONDS ]] && return 1
130
+
131
+ local config_hash
132
+ config_hash=$(_file_hash "$RALPH_DIR/config.json")
133
+ [[ "$cached_hash" != "$config_hash" ]] && return 1
134
+
135
+ return 0
136
+ }
137
+
138
+ _write_preflight_cache() {
139
+ local config_hash
140
+ config_hash=$(_file_hash "$RALPH_DIR/config.json")
141
+ echo "$(date +%s) $config_hash" > "$RALPH_DIR/.preflight_cache"
142
+ }
143
+
144
+ _is_prd_cached() {
145
+ local cache_file="$RALPH_DIR/.prd_validated"
146
+ [[ ! -f "$cache_file" ]] && return 1
147
+
148
+ local cached_time cached_hash
149
+ read -r cached_time cached_hash < "$cache_file"
150
+
151
+ local now
152
+ now=$(date +%s)
153
+ [[ $(( now - cached_time )) -gt $PREFLIGHT_CACHE_TTL_SECONDS ]] && return 1
154
+
155
+ local prd_hash
156
+ prd_hash=$(_file_hash "$RALPH_DIR/prd.json")
157
+ [[ "$cached_hash" != "$prd_hash" ]] && return 1
158
+
159
+ return 0
160
+ }
161
+
162
+ _write_prd_cache() {
163
+ local prd_hash
164
+ prd_hash=$(_file_hash "$RALPH_DIR/prd.json")
165
+ echo "$(date +%s) $prd_hash" > "$RALPH_DIR/.prd_validated"
166
+ }
167
+
168
+ # Check if failure context is trivial (lint/format-only retries)
169
+ # Returns 0 (trivial) if ALL error lines match trivial patterns
170
+ _is_trivial_failure() {
171
+ local context="$1"
172
+
173
+ # Count non-empty, non-whitespace lines
174
+ local total_lines
175
+ total_lines=$(printf '%s\n' "$context" | grep -cE '\S' || echo "0")
176
+
177
+ # If very short context, consider trivial
178
+ [[ "$total_lines" -lt 3 ]] && return 0
179
+
180
+ # Count error/warning/fail lines that do NOT match trivial patterns
181
+ # Trivial patterns: auto-fix, formatting tools, style-only issues
182
+ local non_trivial_errors
183
+ non_trivial_errors=$(printf '%s\n' "$context" | grep -iE '(error|warning|fail)' | \
184
+ grep -cviE '(auto.?fix|prettier|eslint --fix|trailing whitespace|import order|isort|black|ruff format|ruff check.*--fix|no-unused-vars|Missing semicolon|Expected indentation)' \
185
+ || echo "0")
186
+
187
+ # Trivial if no error lines survive the trivial-pattern filter
188
+ [[ "$non_trivial_errors" -eq 0 ]] && return 0
189
+
190
+ return 1
191
+ }
192
+
193
+ # Check if a proposed sign pattern is a duplicate of existing signs
194
+ # Returns 0 (is duplicate) if pattern is too similar to existing
195
+ _sign_is_duplicate() {
196
+ local pattern="$1"
197
+
198
+ [[ ! -f "$RALPH_DIR/signs.json" ]] && return 1
199
+
200
+ # Normalize: lowercase, strip punctuation
201
+ local normalized
202
+ normalized=$(printf '%s\n' "$pattern" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 ]//g' | tr -s ' ')
203
+
204
+ # Check each existing sign
205
+ local existing_patterns
206
+ existing_patterns=$(jq -r '.signs[].pattern' "$RALPH_DIR/signs.json" 2>/dev/null)
207
+
208
+ while IFS= read -r existing; do
209
+ [[ -z "$existing" ]] && continue
210
+
211
+ local existing_normalized
212
+ existing_normalized=$(printf '%s\n' "$existing" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 ]//g' | tr -s ' ')
213
+
214
+ # Substring match in either direction (only for patterns long enough to be meaningful)
215
+ local shorter_len=${#normalized}
216
+ [[ ${#existing_normalized} -lt $shorter_len ]] && shorter_len=${#existing_normalized}
217
+ if [[ $shorter_len -ge 30 ]]; then
218
+ if [[ "$normalized" == *"$existing_normalized"* ]] || [[ "$existing_normalized" == *"$normalized"* ]]; then
219
+ return 0
220
+ fi
221
+ fi
222
+
223
+ # Keyword overlap: extract words 4+ chars, flag as duplicate if >60% overlap
224
+ local new_words existing_words
225
+ new_words=$(printf '%s\n' "$normalized" | tr ' ' '\n' | awk 'length >= 4' | sort -u)
226
+ existing_words=$(printf '%s\n' "$existing_normalized" | tr ' ' '\n' | awk 'length >= 4' | sort -u)
227
+
228
+ local new_count overlap_count
229
+ new_count=$(printf '%s\n' "$new_words" | grep -cE '\S' || echo "0")
230
+ [[ "$new_count" -eq 0 ]] && continue
231
+
232
+ # Count overlapping words (use -xF for whole-line match, not substring)
233
+ overlap_count=0
234
+ while IFS= read -r word; do
235
+ [[ -z "$word" ]] && continue
236
+ if printf '%s\n' "$existing_words" | grep -qxF "$word"; then
237
+ overlap_count=$((overlap_count + 1))
238
+ fi
239
+ done <<< "$new_words"
240
+
241
+ # >60% overlap = duplicate
242
+ if [[ $((overlap_count * 100 / new_count)) -gt 60 ]]; then
243
+ return 0
244
+ fi
245
+ done <<< "$existing_patterns"
246
+
247
+ return 1
248
+ }
249
+
250
+ # Auto-promote a sign from retry failure context
251
+ # Called when a story passes after multiple retries
252
+ _maybe_promote_sign() {
253
+ local story="$1"
254
+ local retries="$2"
255
+ local config="$RALPH_DIR/config.json"
256
+
257
+ # Check config: read .autoPromoteSigns directly (avoid get_config - its // operator
258
+ # treats false as falsy and returns the default). Default to true if key is absent/null.
259
+ if [[ -f "$config" ]]; then
260
+ local auto_promote
261
+ auto_promote=$(jq -r '.autoPromoteSigns' "$config" 2>/dev/null)
262
+ if [[ "$auto_promote" == "false" ]]; then
263
+ return 0
264
+ fi
265
+ fi
266
+
267
+ # Read failure context (safety check - caller also gates on file existence)
268
+ local failure_context
269
+ if [[ ! -f "$RALPH_DIR/last_failure.txt" ]]; then
270
+ return 0
96
271
  fi
272
+ failure_context=$(head -"$MAX_SIGN_CONTEXT_LINES" "$RALPH_DIR/last_failure.txt")
273
+
274
+ # Skip trivial failures (lint/format only)
275
+ if _is_trivial_failure "$failure_context"; then
276
+ log_progress "$story" "SIGN_AUTO" "Skipped - trivial failure (lint/format only)"
277
+ return 0
278
+ fi
279
+
280
+ # Load existing sign patterns for dedup context
281
+ local existing_signs=""
282
+ if [[ -f "$RALPH_DIR/signs.json" ]]; then
283
+ existing_signs=$(jq -r '.signs[].pattern' "$RALPH_DIR/signs.json" 2>/dev/null | head -"$MAX_SIGN_DEDUP_EXISTING")
284
+ fi
285
+
286
+ # Build extraction prompt
287
+ local prompt
288
+ prompt="You are analyzing a development failure that was resolved after $retries attempts.
289
+
290
+ Extract ONE reusable pattern (a \"sign\") that would prevent this failure in future stories.
291
+
292
+ ## Failure Context
293
+ \`\`\`
294
+ $failure_context
295
+ \`\`\`
296
+
297
+ ## Existing Signs (do NOT duplicate these)
298
+ $existing_signs
299
+
300
+ ## Rules
301
+ - Extract a single, actionable pattern that prevents this class of failure
302
+ - The pattern should be general enough to apply to future stories, not specific to this one
303
+ - If the failure is trivial, unclear, or you can't extract a useful pattern, respond with just: NONE
304
+ - Category must be one of: backend, frontend, testing, general, database, security
305
+
306
+ ## Good Examples
307
+ CATEGORY: backend
308
+ PATTERN: Always run database migrations before executing test suites
309
+
310
+ CATEGORY: testing
311
+ PATTERN: Use waitFor() instead of fixed delays when testing async UI updates
312
+
313
+ CATEGORY: frontend
314
+ PATTERN: Import CSS modules with .module.css extension in Next.js projects
315
+
316
+ ## Bad Examples (too specific, too vague)
317
+ PATTERN: Fix the login button color (too specific to one story)
318
+ PATTERN: Write better code (too vague to be actionable)
319
+ PATTERN: Always check for errors (too vague)
320
+
321
+ ## Response Format
322
+ Respond with exactly two lines (or just NONE):
323
+ CATEGORY: <category>
324
+ PATTERN: <pattern>"
325
+
326
+ # Call Claude with timeout (one-shot, non-interactive)
327
+ local response
328
+ response=$(printf '%s\n' "$prompt" | run_with_timeout "$SIGN_EXTRACTION_TIMEOUT_SECONDS" claude -p 2>/dev/null) || {
329
+ log_progress "$story" "SIGN_AUTO" "Skipped - Claude extraction timed out"
330
+ return 0
331
+ }
332
+
333
+ # Check for NONE response
334
+ if printf '%s\n' "$response" | grep -qi '^NONE'; then
335
+ log_progress "$story" "SIGN_AUTO" "Skipped - no actionable pattern found"
336
+ return 0
337
+ fi
338
+
339
+ # Parse response for CATEGORY: and PATTERN: lines (use sed, not grep -P for macOS)
340
+ local category pattern
341
+ category=$(echo "$response" | sed -n 's/^CATEGORY:[[:space:]]*//p' | head -1 | tr -d '\r')
342
+ pattern=$(echo "$response" | sed -n 's/^PATTERN:[[:space:]]*//p' | head -1 | tr -d '\r')
343
+
344
+ # Validate extracted values
345
+ if [[ -z "$category" || -z "$pattern" ]]; then
346
+ log_progress "$story" "SIGN_AUTO" "Skipped - could not parse Claude response"
347
+ return 0
348
+ fi
349
+
350
+ # Validate category
351
+ case "$category" in
352
+ backend|frontend|testing|general|database|security) ;;
353
+ *)
354
+ log_progress "$story" "SIGN_AUTO" "Skipped - invalid category: $category"
355
+ return 0
356
+ ;;
357
+ esac
358
+
359
+ # Check for duplicates before adding
360
+ if _sign_is_duplicate "$pattern"; then
361
+ log_progress "$story" "SIGN_AUTO" "Skipped - duplicate of existing sign"
362
+ return 0
363
+ fi
364
+
365
+ # Add the sign (3rd arg = autoPromoted, 4th arg = learnedFrom override)
366
+ if ralph_sign "$pattern" "$category" "true" "$story"; then
367
+ log_progress "$story" "SIGN_AUTO" "Added [$category]: $pattern"
368
+ print_info "Auto-promoted sign: [$category] $pattern"
369
+ else
370
+ log_progress "$story" "SIGN_AUTO" "Failed to add sign"
371
+ fi
372
+
373
+ return 0
97
374
  }
98
375
 
99
376
  run_loop() {
@@ -128,8 +405,16 @@ run_loop() {
128
405
  # Validate prerequisites
129
406
  check_dependencies
130
407
 
131
- # Pre-flight checks to catch issues before wasting iterations
132
- preflight_checks
408
+ # Pre-loop checks to catch issues before wasting iterations
409
+ if [[ "$fast_mode" == "true" ]]; then
410
+ print_info "Fast mode: skipping connectivity checks"
411
+ elif _is_preflight_cached; then
412
+ print_info "Pre-loop checks passed recently, skipping"
413
+ else
414
+ if preflight_checks; then
415
+ _write_preflight_cache
416
+ fi
417
+ fi
133
418
 
134
419
  if [[ ! -f "$RALPH_DIR/prd.json" ]]; then
135
420
  # Check for misplaced PRD in subdirectories
@@ -171,8 +456,17 @@ run_loop() {
171
456
  fi
172
457
 
173
458
  # Validate PRD structure
174
- if ! validate_prd "$RALPH_DIR/prd.json"; then
175
- return 1
459
+ if [[ "$fast_mode" == "true" ]]; then
460
+ print_info "Fast mode: structural PRD check only"
461
+ validate_prd "$RALPH_DIR/prd.json" "true" || return 1
462
+ elif _is_prd_cached; then
463
+ print_info "PRD validated recently, structural check only"
464
+ validate_prd "$RALPH_DIR/prd.json" "true" || return 1
465
+ else
466
+ if ! validate_prd "$RALPH_DIR/prd.json"; then
467
+ return 1
468
+ fi
469
+ _write_prd_cache
176
470
  fi
177
471
 
178
472
  local iteration=0
@@ -181,9 +475,9 @@ run_loop() {
181
475
  local consecutive_timeouts=0
182
476
  local max_story_retries
183
477
  local max_timeouts=5 # Skip after 5 consecutive timeouts (likely too large/complex)
184
- # Default to 8 retries - enough for transient issues, catches infinite loops
185
- # Override with config.json: "maxStoryRetries": 12
186
- max_story_retries=$(get_config '.maxStoryRetries' "8")
478
+ # Default to 5 retries - enough for transient issues, stops before wasting cycles
479
+ # Override with config.json: "maxStoryRetries": 8
480
+ max_story_retries=$(get_config '.maxStoryRetries' "5")
187
481
  local total_attempts=0
188
482
  local skipped_stories=()
189
483
  local start_time
@@ -264,35 +558,32 @@ run_loop() {
264
558
  '(.stories[] | select(.id==$id)) |= . + {retryCount: $count}' \
265
559
  "$RALPH_DIR/prd.json" > "$RALPH_DIR/prd.json.tmp" && mv "$RALPH_DIR/prd.json.tmp" "$RALPH_DIR/prd.json"
266
560
 
267
- # Circuit breaker: skip to next story after max retries (prevents infinite loops)
268
- # Note: This is NOT meant to stop legitimate retrying - 8 attempts is enough.
269
- # If a story consistently fails after this many tries, it likely needs manual review
270
- # (vague test steps, missing prerequisites, or fundamentally broken requirements).
561
+ # Circuit breaker: stop the loop after max retries (stories depend on each other)
562
+ # If a story consistently fails after this many tries, it needs manual review.
271
563
  if [[ $consecutive_failures -gt $max_story_retries ]]; then
272
- print_error "Story $story has failed $consecutive_failures times - likely needs manual review"
564
+ print_error "Story $story has failed $consecutive_failures times - stopping loop"
273
565
  echo ""
274
- echo " This usually means:"
275
- echo " - Test steps are too vague or ambiguous"
276
- echo " - Missing prerequisites (DB setup, env vars, etc.)"
277
- echo " - Story scope is too large - consider breaking it up"
278
- echo ""
279
- echo " Failure context saved to: $RALPH_DIR/failures/$story.txt"
280
566
  mkdir -p "$RALPH_DIR/failures"
281
567
  cp "$RALPH_DIR/last_failure.txt" "$RALPH_DIR/failures/$story.txt" 2>/dev/null || true
282
- rm -f "$RALPH_DIR/last_failure.txt"
283
- skipped_stories+=("$story")
284
- jq --arg id "$story" '(.stories[] | select(.id==$id)) |= . + {skipped: true, skipReason: "exceeded max retries"}' "$RALPH_DIR/prd.json" > "$RALPH_DIR/prd.json.tmp" && mv "$RALPH_DIR/prd.json.tmp" "$RALPH_DIR/prd.json"
285
- last_story=""
286
- consecutive_failures=0
287
- continue
568
+ # Show the actual last error instead of generic guesses
569
+ if [[ -f "$RALPH_DIR/last_failure.txt" ]]; then
570
+ echo " Last failure:"
571
+ tail -20 "$RALPH_DIR/last_failure.txt" | sed 's/^/ /'
572
+ fi
573
+ echo ""
574
+ echo " Full failure context saved to: $RALPH_DIR/failures/$story.txt"
575
+ local passed failed
576
+ passed=$(jq '[.stories[] | select(.passes==true)] | length' "$RALPH_DIR/prd.json" 2>/dev/null || echo "0")
577
+ failed=$(jq '[.stories[] | select(.passes==false)] | length' "$RALPH_DIR/prd.json" 2>/dev/null || echo "0")
578
+ send_notification "🛑 Ralph stopped: $story failed $consecutive_failures times. $passed passed, $failed remaining"
579
+ print_progress_summary "$start_time" "$total_attempts" "0"
580
+ return 1
288
581
  fi
289
582
 
290
583
  # Show retry status (but don't make it scary - retrying is normal!)
291
584
  if [[ $consecutive_failures -gt 1 ]]; then
292
585
  if [[ $consecutive_failures -le 3 ]]; then
293
586
  print_info "Attempt $consecutive_failures for $story (normal - refining solution)"
294
- elif [[ $consecutive_failures -le 8 ]]; then
295
- print_warning "Attempt $consecutive_failures/$max_story_retries for $story"
296
587
  else
297
588
  print_warning "Attempt $consecutive_failures/$max_story_retries for $story (getting close to limit)"
298
589
  fi
@@ -424,18 +715,22 @@ run_loop() {
424
715
  break
425
716
  done
426
717
 
427
- rm -f "$claude_output_log"
428
-
429
718
  if [[ $crash_attempt -ge $max_crash_retries ]]; then
430
719
  echo ""
431
720
  print_warning "Claude API unavailable after $max_crash_retries attempts"
721
+ if [[ -f "$claude_output_log" ]]; then
722
+ echo " Last error:"
723
+ tail -5 "$claude_output_log" | sed 's/^/ /'
724
+ fi
432
725
  print_info "Waiting 60s before retrying... (Ctrl+C to stop, then 'npx agentic-loop run' to restart)"
433
726
  log_progress "$story" "CLI_CRASH" "API unavailable, waiting 60s before next iteration"
434
- rm -f "$prompt_file"
727
+ rm -f "$prompt_file" "$claude_output_log"
435
728
  sleep 60 # Longer cooldown before retrying
436
729
  continue # Continue main loop instead of stopping
437
730
  fi
438
731
 
732
+ rm -f "$claude_output_log"
733
+
439
734
  if [[ $claude_exit_code -ne 0 ]]; then
440
735
  ((consecutive_timeouts++))
441
736
  print_warning "Claude session ended (timeout or error) - timeout $consecutive_timeouts/$max_timeouts"
@@ -494,8 +789,15 @@ run_loop() {
494
789
  update_json "$RALPH_DIR/prd.json" \
495
790
  --arg id "$story" '(.stories[] | select(.id==$id)) |= . + {passes: true, retryCount: 0}'
496
791
 
792
+ # Auto-promote sign if story required retries
793
+ if [[ $consecutive_failures -gt 1 && -f "$RALPH_DIR/last_failure.txt" ]]; then
794
+ _maybe_promote_sign "$story" "$consecutive_failures"
795
+ fi
796
+
497
797
  # Clear failure context on success
498
798
  rm -f "$RALPH_DIR/last_failure.txt"
799
+ rm -f "$RALPH_DIR"/last_*_failure.log
800
+ rm -f "$RALPH_DIR"/last_*_check.log
499
801
  rm -f "$RALPH_DIR/last_verification.log"
500
802
 
501
803
  # Get story title for commit message and completion display
@@ -102,6 +102,7 @@
102
102
  # Returns 0 if valid (possibly after auto-fix), 1 if unrecoverable error
103
103
  validate_prd() {
104
104
  local prd_file="$1"
105
+ local dry_run="${2:-false}"
105
106
 
106
107
  # Check file exists
107
108
  if [[ ! -f "$prd_file" ]]; then
@@ -219,15 +220,17 @@ validate_prd() {
219
220
  echo ""
220
221
  fi
221
222
 
222
- # Validate API smoke test configuration
223
- _validate_api_config "$config"
223
+ # Validate API smoke test configuration (skip in fast/cached mode)
224
+ if [[ "$dry_run" != "true" ]]; then
225
+ _validate_api_config "$config"
226
+ fi
224
227
 
225
228
  # Replace hardcoded paths with config placeholders
226
229
  fix_hardcoded_paths "$prd_file" "$config"
227
230
 
228
231
  # Validate and fix individual stories
229
- # $2 is optional dry_run flag — when "true", skip auto-fix
230
- _validate_and_fix_stories "$prd_file" "${2:-}" || return 1
232
+ # dry_run flag — when "true", skip auto-fix
233
+ _validate_and_fix_stories "$prd_file" "$dry_run" || return 1
231
234
 
232
235
  return 0
233
236
  }
@@ -323,6 +326,7 @@ _validate_and_fix_stories() {
323
326
  local cnt_frontend_tsc=0 cnt_frontend_url=0 cnt_frontend_context=0 cnt_frontend_mcp=0
324
327
  local cnt_auth_security=0 cnt_list_pagination=0 cnt_prose_steps=0
325
328
  local cnt_migration_prereq=0 cnt_naming_convention=0 cnt_bare_pytest=0
329
+ local cnt_server_only=0
326
330
  local cnt_custom=0
327
331
 
328
332
  echo " Checking test coverage..."
@@ -471,6 +475,32 @@ _validate_and_fix_stories() {
471
475
  fi
472
476
  fi
473
477
 
478
+ # Check 9: Stories where ALL testSteps depend on a live server
479
+ # If every testStep is a curl/wget/httpie command and none are offline
480
+ # (npm test, pytest, tsc, playwright, cargo test, go test, etc.),
481
+ # the story will always fail without a running server.
482
+ if [[ -n "$test_steps" ]]; then
483
+ local has_offline_step=false
484
+ local has_server_step=false
485
+ local step_list
486
+ step_list=$(jq -r --arg id "$story_id" \
487
+ '.stories[] | select(.id==$id) | .testSteps[]?' "$prd_file")
488
+
489
+ while IFS= read -r single_step; do
490
+ [[ -z "$single_step" ]] && continue
491
+ if echo "$single_step" | grep -qE "^(curl |wget |http )"; then
492
+ has_server_step=true
493
+ else
494
+ has_offline_step=true
495
+ fi
496
+ done <<< "$step_list"
497
+
498
+ if [[ "$has_server_step" == "true" && "$has_offline_step" == "false" ]]; then
499
+ story_issues+="all testSteps need a live server (add offline test: npm test, tsc --noEmit, pytest), "
500
+ cnt_server_only=$((cnt_server_only + 1))
501
+ fi
502
+ fi
503
+
474
504
  # Snapshot built-in issues before custom checks append
475
505
  local builtin_story_issues="$story_issues"
476
506
 
@@ -517,6 +547,7 @@ _validate_and_fix_stories() {
517
547
  [[ $cnt_migration_prereq -gt 0 ]] && echo " ${cnt_migration_prereq}x migration: add prerequisites (DB reset)"
518
548
  [[ $cnt_naming_convention -gt 0 ]] && echo " ${cnt_naming_convention}x API consumer: add camelCase transformation note"
519
549
  [[ $cnt_bare_pytest -gt 0 ]] && echo " ${cnt_bare_pytest}x use 'uv run pytest' not bare 'pytest'"
550
+ [[ $cnt_server_only -gt 0 ]] && echo " ${cnt_server_only}x all testSteps need live server (add offline fallback)"
520
551
  [[ $cnt_custom -gt 0 ]] && echo " ${cnt_custom} stories with custom check issues"
521
552
 
522
553
  # Skip auto-fix in dry-run mode
@@ -630,6 +661,10 @@ RULES:
630
661
  Example: \"notes\": \"Transform API responses from snake_case to camelCase. Create typed interfaces with camelCase properties and map: const user = { userName: data.user_name }\"
631
662
  10. Each story should include its own techStack and constraints fields. Do NOT add these at the PRD root level.
632
663
  Move any root-level techStack, globalConstraints, originalContext, testing, architecture, or testUsers into the relevant stories.
664
+ 11. Stories where ALL testSteps are curl commands MUST also include at least one offline test step
665
+ that can verify code correctness without a running server.
666
+ Examples: \"npm test\", \"npx tsc --noEmit\", \"pytest tests/unit/\", \"go test ./...\"
667
+ This prevents wasted retries when the server isn't running.
633
668
 
634
669
  CURRENT PRD:
635
670
  $(cat "$prd_file")
@@ -788,6 +823,24 @@ validate_stories_quick() {
788
823
  fi
789
824
  fi
790
825
  fi
826
+
827
+ # Check 8: All testSteps are server-dependent
828
+ if [[ -n "$test_steps" ]]; then
829
+ local has_offline=false has_server=false
830
+ local steps
831
+ steps=$(jq -r --arg id "$story_id" \
832
+ '.stories[] | select(.id==$id) | .testSteps[]?' "$prd_file")
833
+ while IFS= read -r s; do
834
+ [[ -z "$s" ]] && continue
835
+ if echo "$s" | grep -qE "^(curl |wget |http )"; then
836
+ has_server=true
837
+ else
838
+ has_offline=true
839
+ fi
840
+ done <<< "$steps"
841
+ [[ "$has_server" == "true" && "$has_offline" == "false" ]] && \
842
+ issues+="$story_id: all testSteps need live server, "
843
+ fi
791
844
  done <<< "$story_ids"
792
845
 
793
846
  echo "$issues"
package/ralph/signs.sh CHANGED
@@ -16,6 +16,8 @@ ralph_sign() {
16
16
 
17
17
  local pattern="$1"
18
18
  local category="${2:-general}"
19
+ local auto_promoted="${3:-false}"
20
+ local learned_from_override="${4:-}"
19
21
 
20
22
  # Ensure .ralph directory exists
21
23
  if [[ ! -d "$RALPH_DIR" ]]; then
@@ -34,8 +36,11 @@ ralph_sign() {
34
36
  local sign_id="sign-$(printf '%03d' $((sign_count + 1)))"
35
37
 
36
38
  # Get current story if available (for learnedFrom field)
39
+ # Override can be passed as 4th arg (used by auto-promote, since story is already marked passed)
37
40
  local learned_from=""
38
- if [[ -f "$RALPH_DIR/prd.json" ]]; then
41
+ if [[ -n "$learned_from_override" ]]; then
42
+ learned_from="$learned_from_override"
43
+ elif [[ -f "$RALPH_DIR/prd.json" ]]; then
39
44
  learned_from=$(jq -r '.stories[] | select(.passes==false) | .id' "$RALPH_DIR/prd.json" 2>/dev/null | head -1)
40
45
  fi
41
46
 
@@ -52,11 +57,13 @@ ralph_sign() {
52
57
  --arg category "$category" \
53
58
  --arg learnedFrom "$learned_from" \
54
59
  --arg createdAt "$timestamp" \
60
+ --argjson autoPromoted "$( [[ "$auto_promoted" == "true" ]] && echo "true" || echo "false" )" \
55
61
  '.signs += [{
56
62
  id: $id,
57
63
  pattern: $pattern,
58
64
  category: $category,
59
65
  learnedFrom: (if $learnedFrom == "" then null else $learnedFrom end),
66
+ autoPromoted: $autoPromoted,
60
67
  createdAt: $createdAt
61
68
  }]' "$RALPH_DIR/signs.json" > "$tmpfile" && jq -e . "$tmpfile" >/dev/null 2>&1; then
62
69
  mv "$tmpfile" "$RALPH_DIR/signs.json"
@@ -100,7 +107,7 @@ ralph_signs() {
100
107
  [[ -z "$category" ]] && continue
101
108
 
102
109
  echo "[$category]"
103
- jq -r --arg cat "$category" '.signs[] | select(.category==$cat) | " - \(.pattern)"' "$RALPH_DIR/signs.json"
110
+ jq -r --arg cat "$category" '.signs[] | select(.category==$cat) | " - \(.pattern)\(if .autoPromoted == true then " (auto)" else "" end)"' "$RALPH_DIR/signs.json"
104
111
  echo ""
105
112
  done <<< "$categories"
106
113
  }
package/ralph/test.sh CHANGED
@@ -134,11 +134,15 @@ run_all_prd_tests() {
134
134
  [[ -z "$step" ]] && continue
135
135
  ((total++))
136
136
 
137
- echo -n " $step... "
137
+ # Expand config placeholders (e.g., {config.urls.backend})
138
+ local expanded_step
139
+ expanded_step=$(_expand_config_vars "$step")
140
+
141
+ echo -n " $expanded_step... "
138
142
 
139
143
  local step_log
140
144
  step_log=$(mktemp)
141
- if safe_exec "$step" "$step_log"; then
145
+ if safe_exec "$expanded_step" "$step_log"; then
142
146
  print_success "passed"
143
147
  ((passed++))
144
148
  else
package/ralph/utils.sh CHANGED
@@ -10,6 +10,8 @@ readonly MAX_OUTPUT_PREVIEW_LINES=20
10
10
  readonly MAX_ERROR_PREVIEW_LINES=40
11
11
  readonly MAX_LINT_ERROR_LINES=20
12
12
  readonly MAX_PROGRESS_FILE_LINES=1000
13
+ readonly MAX_SIGN_CONTEXT_LINES=150
14
+ readonly MAX_SIGN_DEDUP_EXISTING=20
13
15
 
14
16
  # Constants - Timeouts (centralized to avoid magic numbers)
15
17
  readonly ITERATION_DELAY_SECONDS=0
@@ -19,6 +21,8 @@ readonly CODE_REVIEW_TIMEOUT_SECONDS=120
19
21
  readonly BROWSER_TIMEOUT_SECONDS=60
20
22
  readonly BROWSER_PAGE_TIMEOUT_MS=30000
21
23
  readonly CURL_TIMEOUT_SECONDS=10
24
+ readonly SIGN_EXTRACTION_TIMEOUT_SECONDS=30
25
+ readonly PREFLIGHT_CACHE_TTL_SECONDS=600
22
26
 
23
27
  # Common project directories (avoid duplication across files)
24
28
  readonly FRONTEND_DIRS=("apps/web" "frontend" "client" "web")
@@ -598,7 +602,9 @@ fix_hardcoded_paths() {
598
602
  local original_content="$prd_content"
599
603
 
600
604
  # Check for hardcoded absolute paths (non-portable)
601
- if echo "$prd_content" | grep -qE '"/Users/|"/home/|"C:\\|"/var/|"/opt/' ; then
605
+ # Note: stderr suppressed on echo|grep -q pipes to silence "broken pipe" noise
606
+ # (grep -q exits early on match, closing the pipe while echo is still writing)
607
+ if echo "$prd_content" 2>/dev/null | grep -qE '"/Users/|"/home/|"C:\\|"/var/|"/opt/' ; then
602
608
  echo " Removing hardcoded absolute paths..."
603
609
  # Remove common absolute path prefixes, keep relative path
604
610
  prd_content=$(echo "$prd_content" | sed -E 's|"/Users/[^"]*/([^"]+)"|"\1"|g')
@@ -607,7 +613,7 @@ fix_hardcoded_paths() {
607
613
  fi
608
614
 
609
615
  # Replace hardcoded backend URLs with {config.urls.backend}
610
- if [[ -n "$backend_url" ]] && echo "$prd_content" | grep -qF "$backend_url" ; then
616
+ if [[ -n "$backend_url" ]] && echo "$prd_content" 2>/dev/null | grep -qF "$backend_url" ; then
611
617
  echo " Replacing hardcoded backend URL with {config.urls.backend}..."
612
618
  local escaped_url
613
619
  escaped_url=$(_escape_sed_pattern "$backend_url")
@@ -616,7 +622,7 @@ fix_hardcoded_paths() {
616
622
  fi
617
623
 
618
624
  # Replace hardcoded frontend URLs with {config.urls.frontend}
619
- if [[ -n "$frontend_url" ]] && echo "$prd_content" | grep -qF "$frontend_url" ; then
625
+ if [[ -n "$frontend_url" ]] && echo "$prd_content" 2>/dev/null | grep -qF "$frontend_url" ; then
620
626
  echo " Replacing hardcoded frontend URL with {config.urls.frontend}..."
621
627
  local escaped_url
622
628
  escaped_url=$(_escape_sed_pattern "$frontend_url")
@@ -625,7 +631,7 @@ fix_hardcoded_paths() {
625
631
  fi
626
632
 
627
633
  # Replace hardcoded health endpoints with config placeholder
628
- if echo "$prd_content" | grep -qE '/api(/v[0-9]+)?/health|/health' ; then
634
+ if echo "$prd_content" 2>/dev/null | grep -qE '/api(/v[0-9]+)?/health|/health' ; then
629
635
  echo " Replacing hardcoded health endpoints with {config.api.healthEndpoint}..."
630
636
  prd_content=$(echo "$prd_content" | sed -E 's|/api/v[0-9]+/health|{config.api.healthEndpoint}|g')
631
637
  prd_content=$(echo "$prd_content" | sed -E 's|/api/health|{config.api.healthEndpoint}|g')
@@ -637,7 +643,7 @@ fix_hardcoded_paths() {
637
643
  # Note: Use # as delimiter since | appears in regex alternation
638
644
  if [[ -z "$backend_url" ]]; then
639
645
  # Common backend ports: 8000, 8001, 8080, 3001, 4000, 5000
640
- if echo "$prd_content" | grep -qE 'http://localhost:(8000|8001|8080|3001|4000|5000)' ; then
646
+ if echo "$prd_content" 2>/dev/null | grep -qE 'http://localhost:(8000|8001|8080|3001|4000|5000)' ; then
641
647
  echo " Replacing hardcoded localhost backend URLs with {config.urls.backend}..."
642
648
  prd_content=$(echo "$prd_content" | sed -E 's#http://localhost:(8000|8001|8080|3001|4000|5000)#{config.urls.backend}#g')
643
649
  modified=true
@@ -646,7 +652,7 @@ fix_hardcoded_paths() {
646
652
 
647
653
  if [[ -z "$frontend_url" ]]; then
648
654
  # Common frontend ports: 3000, 5173, 4200
649
- if echo "$prd_content" | grep -qE 'http://localhost:(3000|5173|4200)' ; then
655
+ if echo "$prd_content" 2>/dev/null | grep -qE 'http://localhost:(3000|5173|4200)' ; then
650
656
  echo " Replacing hardcoded localhost frontend URLs with {config.urls.frontend}..."
651
657
  prd_content=$(echo "$prd_content" | sed -E 's#http://localhost:(3000|5173|4200)#{config.urls.frontend}#g')
652
658
  modified=true
@@ -226,8 +226,11 @@ run_unit_tests() {
226
226
  }
227
227
 
228
228
  # Expand config placeholders in a string
229
- # Usage: expand_config_vars "curl {config.urls.backend}/api"
230
- # Expands: {config.urls.backend}, {config.urls.frontend}, {config.directories.*}
229
+ # Usage: _expand_config_vars "curl {config.urls.backend}/api"
230
+ # Expands any {config.X.Y} placeholder from .ralph/config.json via jq.
231
+ # Known placeholders have fallback paths for backward compatibility:
232
+ # {config.urls.backend} -> .urls.backend // .api.baseUrl
233
+ # {config.urls.frontend} -> .urls.frontend // .testUrlBase
231
234
  _expand_config_vars() {
232
235
  local input="$1"
233
236
  local config="$RALPH_DIR/config.json"
@@ -237,41 +240,38 @@ _expand_config_vars() {
237
240
 
238
241
  local result="$input"
239
242
 
240
- # Expand {config.urls.backend}
243
+ # Known placeholders with backward-compatible fallback paths
241
244
  if [[ "$result" == *"{config.urls.backend}"* ]]; then
242
- local backend_url
243
- backend_url=$(jq -r '.urls.backend // .api.baseUrl // empty' "$config" 2>/dev/null)
244
- if [[ -n "$backend_url" ]]; then
245
- result="${result//\{config.urls.backend\}/$backend_url}"
246
- fi
245
+ local val
246
+ val=$(jq -r '.urls.backend // .api.baseUrl // empty' "$config" 2>/dev/null)
247
+ [[ -n "$val" ]] && result="${result//\{config.urls.backend\}/$val}"
247
248
  fi
248
249
 
249
- # Expand {config.urls.frontend}
250
250
  if [[ "$result" == *"{config.urls.frontend}"* ]]; then
251
- local frontend_url
252
- frontend_url=$(jq -r '.urls.frontend // .testUrlBase // empty' "$config" 2>/dev/null)
253
- if [[ -n "$frontend_url" ]]; then
254
- result="${result//\{config.urls.frontend\}/$frontend_url}"
255
- fi
256
- fi
257
-
258
- # Expand {config.directories.backend}
259
- if [[ "$result" == *"{config.directories.backend}"* ]]; then
260
- local backend_dir
261
- backend_dir=$(jq -r '.directories.backend // empty' "$config" 2>/dev/null)
262
- if [[ -n "$backend_dir" ]]; then
263
- result="${result//\{config.directories.backend\}/$backend_dir}"
264
- fi
251
+ local val
252
+ val=$(jq -r '.urls.frontend // .testUrlBase // empty' "$config" 2>/dev/null)
253
+ [[ -n "$val" ]] && result="${result//\{config.urls.frontend\}/$val}"
265
254
  fi
266
255
 
267
- # Expand {config.directories.frontend}
268
- if [[ "$result" == *"{config.directories.frontend}"* ]]; then
269
- local frontend_dir
270
- frontend_dir=$(jq -r '.directories.frontend // empty' "$config" 2>/dev/null)
271
- if [[ -n "$frontend_dir" ]]; then
272
- result="${result//\{config.directories.frontend\}/$frontend_dir}"
256
+ # Generic expansion for any remaining {config.X.Y.Z} placeholders
257
+ # Handles {config.urls.app}, {config.api.healthEndpoint}, {config.directories.*}, etc.
258
+ local max_expansions=10
259
+ while [[ "$result" =~ \{config\.([a-zA-Z0-9_.]+)\} ]] && [[ $max_expansions -gt 0 ]]; do
260
+ local placeholder="${BASH_REMATCH[0]}"
261
+ local config_path="${BASH_REMATCH[1]}"
262
+ local jq_path=".${config_path}"
263
+
264
+ local val
265
+ val=$(jq -r "$jq_path // empty" "$config" 2>/dev/null)
266
+ if [[ -n "$val" ]]; then
267
+ result="${result//$placeholder/$val}"
268
+ else
269
+ # Unresolvable — warn and stop to avoid infinite loop
270
+ print_warning "Unresolved config placeholder: $placeholder (key '$config_path' not in config.json)" >&2
271
+ break
273
272
  fi
274
- fi
273
+ ((max_expansions--))
274
+ done
275
275
 
276
276
  echo "$result"
277
277
  }