agentic-loop 3.17.3 → 3.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-loop",
3
- "version": "3.17.3",
3
+ "version": "3.18.0",
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",
@@ -2,18 +2,6 @@
2
2
  "_comment": "Copy this 'hooks' section into your ~/.claude/settings.json or .claude/settings.json",
3
3
  "_instructions": "Replace VIBE_PATH with the path to your agentic-loop installation",
4
4
  "hooks": {
5
- "PreToolUse": [
6
- {
7
- "matcher": "Edit|Write",
8
- "hooks": [
9
- {
10
- "type": "command",
11
- "command": "VIBE_PATH/ralph/hooks/protect-prd.sh",
12
- "timeout": 5
13
- }
14
- ]
15
- }
16
- ],
17
5
  "PostToolUse": [
18
6
  {
19
7
  "matcher": "Edit|Write",
@@ -111,18 +111,6 @@ fi
111
111
  # Build hooks config with actual path
112
112
  HOOKS_CONFIG=$(cat <<EOF
113
113
  {
114
- "PreToolUse": [
115
- {
116
- "matcher": "Edit|Write",
117
- "hooks": [
118
- {
119
- "type": "command",
120
- "command": "$SCRIPT_DIR/protect-prd.sh",
121
- "timeout": 5
122
- }
123
- ]
124
- }
125
- ],
126
114
  "PostToolUse": [
127
115
  {
128
116
  "matcher": "Edit|Write",
@@ -195,7 +183,6 @@ echo "$MERGED" > "$SETTINGS_FILE"
195
183
  echo -e "${GREEN}✓ Hooks installed successfully!${NC}"
196
184
  echo ""
197
185
  echo "Hooks enabled:"
198
- echo " • protect-prd.sh - Blocks edits to prd.json"
199
186
  echo " • warn-debug.sh - Warns about console.log/debugger"
200
187
  echo " • warn-secrets.sh - Warns about hardcoded secrets/API keys"
201
188
  echo " • warn-urls.sh - Warns about hardcoded localhost URLs"
package/ralph/loop.sh CHANGED
@@ -88,6 +88,23 @@ preflight_checks() {
88
88
  fi
89
89
  done
90
90
 
91
+ # Check for timeout utility (critical for session enforcement)
92
+ printf " Timeout utility... "
93
+ if command -v timeout &>/dev/null; then
94
+ print_success "ok (timeout)"
95
+ elif command -v gtimeout &>/dev/null; then
96
+ print_success "ok (gtimeout)"
97
+ else
98
+ print_warning "not found (using bash fallback)"
99
+ echo " Session timeouts use a bash fallback. For better reliability:"
100
+ if [[ "$(uname)" == "Darwin" ]]; then
101
+ echo " brew install coreutils"
102
+ else
103
+ echo " Install GNU coreutils"
104
+ fi
105
+ ((warnings++))
106
+ fi
107
+
91
108
  echo ""
92
109
  if [[ $warnings -gt 0 ]]; then
93
110
  print_warning "$warnings pre-loop warning(s) - loop may fail on connectivity issues"
@@ -50,7 +50,7 @@
50
50
  # - Has prerequisites array with DB reset command
51
51
  # - Prevents infinite retries on schema mismatch errors
52
52
  #
53
- # CUSTOM CHECKS (.ralph/checks/prd/ or ~/.config/ralph/checks/prd/):
53
+ # CUSTOM CHECKS (.ralph/checks/prd/):
54
54
  # - User-provided scripts that receive story JSON on stdin
55
55
  # - Output issue descriptions to stdout (one per line)
56
56
  # - Excluded from auto-fix (reported for manual review)
@@ -63,12 +63,22 @@
63
63
  # ============================================================================
64
64
  # AUTO-FIX
65
65
  # ============================================================================
66
- # When issues are found, Claude is invoked to fix them automatically:
66
+ # When issues are found, a two-tier fix runs automatically:
67
67
  #
68
- # 1. Issues are summarized (e.g., "3x backend: add curl tests")
69
- # 2. Claude receives the PRD + issues + fix rules
70
- # 3. Fixed PRD is validated and saved
71
- # 4. Timestamped backup preserved (prd.json.20240115-143022.bak)
68
+ # Tier 1 Mechanical fixes (instant, no LLM):
69
+ # - Missing mcp on frontend ["playwright", "devtools"]
70
+ # - Bare pytest prefixed with detected runner (uv/poetry/pipenv)
71
+ # - Missing camelCase note → standard text appended to .notes
72
+ # - Missing migration prerequisites → template prerequisite array
73
+ # - Server-only testSteps → offline fallback appended
74
+ #
75
+ # Tier 2 — Parallel Claude subagents (one per story, concurrent):
76
+ # - For issues needing creative input (apiContract, prose testSteps, etc.)
77
+ # - Each story gets a small prompt with just its JSON + specific issues
78
+ # - All stories fix in parallel (wall-clock = time for 1 story)
79
+ # - Results merged back via update_json; failures left unchanged
80
+ #
81
+ # Timestamped backup preserved before any modifications.
72
82
  #
73
83
  # If Claude is unavailable or fix fails, loop continues with warnings.
74
84
  #
@@ -81,7 +91,6 @@
81
91
  # .api.baseUrl - API base URL (enables API config validation)
82
92
  # .api.healthEndpoint - Health check path (default: /health, empty to disable)
83
93
  # .ralph/checks/prd/check-* - Project-level custom checks (per-story)
84
- # ~/.config/ralph/checks/prd/ - User-global custom checks (per-story)
85
94
  # .checks.custom.<name> - Enable/disable individual custom checks
86
95
  #
87
96
  # ============================================================================
@@ -220,9 +229,13 @@ validate_prd() {
220
229
  echo ""
221
230
  fi
222
231
 
223
- # Validate API smoke test configuration (skip in fast/cached mode)
232
+ # Validate API smoke test configuration in background (skip in fast/cached mode)
233
+ # Capture output to a temp file to avoid garbled terminal output
234
+ local api_check_pid="" api_check_output=""
224
235
  if [[ "$dry_run" != "true" ]]; then
225
- _validate_api_config "$config"
236
+ api_check_output=$(create_temp_file ".api-check.out")
237
+ _validate_api_config "$config" > "$api_check_output" 2>&1 &
238
+ api_check_pid=$!
226
239
  fi
227
240
 
228
241
  # Replace hardcoded paths with config placeholders
@@ -232,6 +245,12 @@ validate_prd() {
232
245
  # dry_run flag — when "true", skip auto-fix
233
246
  _validate_and_fix_stories "$prd_file" "$dry_run" || return 1
234
247
 
248
+ # Wait for background API health check and print its output
249
+ if [[ -n "$api_check_pid" ]]; then
250
+ wait "$api_check_pid" 2>/dev/null
251
+ [[ -s "$api_check_output" ]] && cat "$api_check_output"
252
+ fi
253
+
235
254
  return 0
236
255
  }
237
256
 
@@ -504,8 +523,8 @@ _validate_and_fix_stories() {
504
523
  # Snapshot built-in issues before custom checks append
505
524
  local builtin_story_issues="$story_issues"
506
525
 
507
- # Check 8: User-defined custom checks (.ralph/checks/prd/ or ~/.config/ralph/checks/prd/)
508
- if [[ -d ".ralph/checks/prd" ]] || [[ -d "$HOME/.config/ralph/checks/prd" ]]; then
526
+ # Check 8: User-defined custom checks (.ralph/checks/prd/)
527
+ if [[ -d ".ralph/checks/prd" ]]; then
509
528
  local story_json
510
529
  story_json=$(jq --arg id "$story_id" '.stories[] | select(.id==$id)' "$prd_file")
511
530
  local custom_output
@@ -555,12 +574,32 @@ _validate_and_fix_stories() {
555
574
  return 0
556
575
  fi
557
576
 
558
- # Check if Claude is available for auto-fix
559
- if command -v claude &>/dev/null; then
560
- _fix_stories_with_claude "$prd_file" "$issues"
577
+ # Create backup before any modifications
578
+ local backup_file="${prd_file}.$(date +%Y%m%d-%H%M%S).bak"
579
+ cp "$prd_file" "$backup_file"
580
+
581
+ # Tier 1: Instant mechanical fixes (no LLM needed)
582
+ _apply_mechanical_fixes "$prd_file"
583
+
584
+ # Re-check what's still broken after mechanical fixes
585
+ # validate_stories_quick returns "ID: issue, ID: issue, ..." on one line
586
+ # Group into one line per story for _fix_stories_parallel
587
+ local remaining_raw
588
+ remaining_raw=$(validate_stories_quick "$prd_file")
589
+ local remaining_grouped=""
590
+ [[ -n "$remaining_raw" ]] && remaining_grouped=$(_group_issues_by_story "$remaining_raw")
591
+
592
+ if [[ -n "$remaining_grouped" ]]; then
593
+ # Tier 2: Parallel Claude subagents for creative fixes
594
+ if command -v claude &>/dev/null; then
595
+ _fix_stories_parallel "$prd_file" "$remaining_grouped" "$backup_file"
596
+ else
597
+ print_warning "Claude CLI not found - mechanical fixes applied, but some stories need manual review"
598
+ echo " Backup at: $backup_file"
599
+ return 0
600
+ fi
561
601
  else
562
- print_warning "Claude CLI not found - run manually to optimize test coverage"
563
- return 1
602
+ print_success "All issues resolved with mechanical fixes (backup at $backup_file)"
564
603
  fi
565
604
  else
566
605
  print_success "Test coverage looks good"
@@ -579,49 +618,188 @@ _run_custom_prd_checks() {
579
618
  local custom_issues=""
580
619
  local custom_log="$RALPH_DIR/last_custom_check.log"
581
620
 
582
- local check_dirs=()
583
- [[ -d ".ralph/checks/prd" ]] && check_dirs+=(".ralph/checks/prd")
584
- [[ -d "$HOME/.config/ralph/checks/prd" ]] && check_dirs+=("$HOME/.config/ralph/checks/prd")
585
- [[ ${#check_dirs[@]} -eq 0 ]] && return 0
586
-
587
- for check_dir in "${check_dirs[@]}"; do
588
- for check_script in "$check_dir"/check-*; do
589
- [[ ! -f "$check_script" || ! -x "$check_script" ]] && continue
590
-
591
- local check_key
592
- check_key=$(basename "$check_script")
593
- check_key="${check_key%.*}"
594
- # Read directly instead of get_config — jq's // operator treats false as falsy
595
- local enabled="true"
596
- if [[ -f "$RALPH_DIR/config.json" ]]; then
597
- local raw
598
- raw=$(jq -r --arg key "$check_key" '.checks.custom[$key]' "$RALPH_DIR/config.json" 2>/dev/null)
599
- [[ -n "$raw" && "$raw" != "null" ]] && enabled="$raw"
621
+ local check_dir=".ralph/checks/prd"
622
+ [[ ! -d "$check_dir" ]] && return 0
623
+
624
+ for check_script in "$check_dir"/check-*; do
625
+ [[ ! -f "$check_script" || ! -x "$check_script" ]] && continue
626
+
627
+ local check_key
628
+ check_key=$(basename "$check_script")
629
+ check_key="${check_key%.*}"
630
+ # Read directly instead of get_config — jq's // operator treats false as falsy
631
+ local enabled="true"
632
+ if [[ -f "$RALPH_DIR/config.json" ]]; then
633
+ local raw
634
+ raw=$(jq -r --arg key "$check_key" '.checks.custom[$key]' "$RALPH_DIR/config.json" 2>/dev/null)
635
+ [[ -n "$raw" && "$raw" != "null" ]] && enabled="$raw"
636
+ fi
637
+ [[ "$enabled" == "false" ]] && continue
638
+
639
+ # Run check — capture stdout for issues, stderr to log for debugging
640
+ local output=""
641
+ if ! output=$(echo "$story_json" | run_with_timeout 30 "$check_script" "$story_id" "$prd_file" 2>>"$custom_log"); then
642
+ # Script failed to execute — warn, don't silently swallow
643
+ print_warning "Custom check '$check_key' failed for story $story_id (see .ralph/last_custom_check.log)"
644
+ fi
645
+
646
+ if [[ -n "$output" ]]; then
647
+ while IFS= read -r line; do
648
+ [[ -n "$line" ]] && custom_issues+="${line}, "
649
+ done <<< "$output"
650
+ fi
651
+ done
652
+
653
+ echo "$custom_issues"
654
+ }
655
+
656
+ # Group flat "ID: issue, ID: issue, ..." string into one line per story
657
+ # Input: "S1: missing curl tests, S1: missing apiContract, S2: missing testUrl, "
658
+ # Output: "S1: missing curl tests, missing apiContract\nS2: missing testUrl"
659
+ _group_issues_by_story() {
660
+ local raw="$1"
661
+ # Split on ", " boundaries that precede a story ID pattern (word: )
662
+ # Use awk to accumulate issues per story ID
663
+ echo "$raw" | tr ',' '\n' | sed 's/^ *//' | while IFS= read -r entry; do
664
+ [[ -z "$entry" ]] && continue
665
+ if [[ "$entry" =~ ^([A-Za-z0-9._-]+):\ (.+) ]]; then
666
+ echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"
667
+ fi
668
+ done | awk -F'\t' '{
669
+ if (seen[$1]) {
670
+ issues[$1] = issues[$1] ", " $2
671
+ } else {
672
+ seen[$1] = 1
673
+ issues[$1] = $2
674
+ order[++n] = $1
675
+ }
676
+ } END {
677
+ for (i = 1; i <= n; i++) {
678
+ print order[i] ": " issues[order[i]]
679
+ }
680
+ }'
681
+ }
682
+
683
+ # Apply instant mechanical fixes using jq (no LLM needed)
684
+ # Fixes: missing mcp, bare pytest, missing camelCase note, missing migration prerequisites,
685
+ # server-only testSteps
686
+ _apply_mechanical_fixes() {
687
+ local prd_file="$1"
688
+ local fixed=0
689
+
690
+ # Detect Python runner once for bare pytest fixes
691
+ local py_runner
692
+ py_runner=$(detect_python_runner ".")
693
+
694
+ local story_ids
695
+ story_ids=$(jq -r '.stories[] | select(.passes != true) | .id' "$prd_file" 2>/dev/null)
696
+
697
+ while IFS= read -r story_id; do
698
+ [[ -z "$story_id" ]] && continue
699
+
700
+ local story_type
701
+ story_type=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .type // "unknown"' "$prd_file")
702
+
703
+ # Fix: Frontend missing mcp → set to ["playwright", "devtools"]
704
+ if [[ "$story_type" == "frontend" ]]; then
705
+ local mcp_len
706
+ mcp_len=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .mcp // [] | length' "$prd_file")
707
+ if [[ "$mcp_len" == "0" ]]; then
708
+ update_json "$prd_file" --arg id "$story_id" \
709
+ '(.stories[] | select(.id==$id) | .mcp) = ["playwright", "devtools"]' && fixed=$((fixed + 1))
600
710
  fi
601
- [[ "$enabled" == "false" ]] && continue
711
+ fi
602
712
 
603
- # Run check capture stdout for issues, stderr to log for debugging
604
- local output=""
605
- if ! output=$(echo "$story_json" | run_with_timeout 30 "$check_script" "$story_id" "$prd_file" 2>>"$custom_log"); then
606
- # Script failed to execute warn, don't silently swallow
607
- print_warning "Custom check '$check_key' failed for story $story_id (see .ralph/last_custom_check.log)"
713
+ # Fix: Bare pytest prefix with detected runner
714
+ if [[ -n "$py_runner" ]]; then
715
+ local test_steps_raw
716
+ test_steps_raw=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .testSteps // [] | join("\n")' "$prd_file")
717
+ if echo "$test_steps_raw" | grep -qE '(^|[; ])pytest ' && ! echo "$test_steps_raw" | grep -qE "(uv run|poetry run|pipenv run) pytest"; then
718
+ update_json "$prd_file" --arg id "$story_id" --arg runner "$py_runner" \
719
+ '(.stories[] | select(.id==$id) | .testSteps) |= [.[]? | gsub("(?<pre>^|[; ])pytest "; "\(.pre)\($runner) pytest ")]' && fixed=$((fixed + 1))
608
720
  fi
721
+ fi
609
722
 
610
- if [[ -n "$output" ]]; then
611
- while IFS= read -r line; do
612
- [[ -n "$line" ]] && custom_issues+="${line}, "
613
- done <<< "$output"
723
+ # Fix: Frontend/general API consumer missing camelCase note
724
+ if [[ "$story_type" == "frontend" || "$story_type" == "general" ]]; then
725
+ local story_desc
726
+ story_desc=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | (.title + " " + (.acceptanceCriteria // [] | join(" ")) + " " + (.notes // ""))' "$prd_file")
727
+ if echo "$story_desc" | grep -qiE "(api|fetch|axios|endpoint|backend|response)"; then
728
+ local story_notes
729
+ story_notes=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .notes // ""' "$prd_file")
730
+ if ! echo "$story_notes" | grep -qiE "(camelCase|snake_case|naming)"; then
731
+ local camel_note="Transform API responses from snake_case to camelCase. Create typed interfaces with camelCase properties."
732
+ if [[ -z "$story_notes" ]]; then
733
+ update_json "$prd_file" --arg id "$story_id" --arg note "$camel_note" \
734
+ '(.stories[] | select(.id==$id) | .notes) = $note' && fixed=$((fixed + 1))
735
+ else
736
+ update_json "$prd_file" --arg id "$story_id" --arg note "$camel_note" \
737
+ '(.stories[] | select(.id==$id) | .notes) += (" " + $note)' && fixed=$((fixed + 1))
738
+ fi
739
+ fi
614
740
  fi
615
- done
616
- done
741
+ fi
617
742
 
618
- echo "$custom_issues"
743
+ # Fix: Migration story missing prerequisites
744
+ local story_files
745
+ story_files=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | (.files.create // []) + (.files.modify // []) | join(" ")' "$prd_file")
746
+ if echo "$story_files" | grep -qiE "(alembic/versions|migrations/|\.migration\.|models\.py|models/|schema\.)"; then
747
+ local has_prereq
748
+ has_prereq=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .prerequisites // [] | length' "$prd_file")
749
+ if [[ "$has_prereq" == "0" ]]; then
750
+ update_json "$prd_file" --arg id "$story_id" \
751
+ '(.stories[] | select(.id==$id) | .prerequisites) = [{"name": "Reset test DB", "command": "npm run db:reset:test", "when": "schema changes"}]' && fixed=$((fixed + 1))
752
+ fi
753
+ fi
754
+
755
+ # Fix: All testSteps are server-dependent → append offline test step
756
+ local test_steps
757
+ test_steps=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .testSteps // [] | join(" ")' "$prd_file")
758
+ if [[ -n "$test_steps" ]]; then
759
+ local has_offline=false has_server=false
760
+ local step_list
761
+ step_list=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .testSteps[]?' "$prd_file")
762
+ while IFS= read -r single_step; do
763
+ [[ -z "$single_step" ]] && continue
764
+ if echo "$single_step" | grep -qE "^(curl |wget |http )"; then
765
+ has_server=true
766
+ else
767
+ has_offline=true
768
+ fi
769
+ done <<< "$step_list"
770
+
771
+ if [[ "$has_server" == "true" && "$has_offline" == "false" ]]; then
772
+ # Pick an offline step based on story type and project tooling
773
+ local offline_step="npx tsc --noEmit"
774
+ if [[ "$story_type" == "backend" ]]; then
775
+ if [[ -n "$py_runner" ]]; then
776
+ offline_step="$py_runner pytest tests/unit/"
777
+ elif [[ -f "go.mod" ]]; then
778
+ offline_step="go test ./..."
779
+ else
780
+ offline_step="npm test"
781
+ fi
782
+ fi
783
+ update_json "$prd_file" --arg id "$story_id" --arg step "$offline_step" \
784
+ '(.stories[] | select(.id==$id) | .testSteps) += [$step]' && fixed=$((fixed + 1))
785
+ fi
786
+ fi
787
+
788
+ done <<< "$story_ids"
789
+
790
+ if [[ $fixed -gt 0 ]]; then
791
+ echo " Applied $fixed mechanical fixes (no LLM needed)"
792
+ fi
793
+
794
+ return 0
619
795
  }
620
796
 
621
- # Optimize story test coverage using Claude
622
- _fix_stories_with_claude() {
797
+ # Fix stories with remaining issues using parallel Claude subagents (one per story)
798
+ # $1: prd_file $2: newline-separated "story_id: issues" lines $3: backup file path
799
+ _fix_stories_parallel() {
623
800
  local prd_file="$1"
624
801
  local issues="$2"
802
+ local backup_file="$3"
625
803
 
626
804
  # Read config values for context
627
805
  local config_file="$RALPH_DIR/config.json"
@@ -631,102 +809,129 @@ _fix_stories_with_claude() {
631
809
  frontend_url=$(jq -r '.urls.frontend // .playwright.baseUrl // "http://localhost:3000"' "$config_file" 2>/dev/null)
632
810
  fi
633
811
 
634
- local fix_prompt="Enhance test coverage for these stories. Output the COMPLETE updated prd.json.
812
+ # Parse issues into per-story fix jobs
813
+ local pids=()
814
+ local story_ids_to_fix=()
815
+ local output_files=()
816
+
817
+ while IFS= read -r line; do
818
+ [[ -z "$line" ]] && continue
819
+ local sid="${line%%:*}"
820
+ local story_issues="${line#*: }"
821
+ [[ -z "$sid" || -z "$story_issues" ]] && continue
822
+
823
+ # Extract this story's JSON
824
+ local story_json
825
+ story_json=$(jq --arg id "$sid" '.stories[] | select(.id==$id)' "$prd_file" 2>/dev/null)
826
+ [[ -z "$story_json" ]] && continue
827
+
828
+ # Build a small per-story prompt
829
+ local prompt_file
830
+ prompt_file=$(create_temp_file ".prompt.txt")
831
+ local output_file
832
+ output_file=$(create_temp_file ".fix.json")
833
+
834
+ cat > "$prompt_file" <<PROMPT_EOF
835
+ Fix this story's issues. Output ONLY the fixed story JSON object (not the full PRD).
836
+
837
+ STORY JSON:
838
+ $story_json
635
839
 
636
- STORIES TO OPTIMIZE:
637
- $issues
840
+ ISSUES TO FIX:
841
+ $story_issues
638
842
 
639
- CONFIG VALUES (use these):
843
+ CONFIG VALUES:
640
844
  - Backend URL: $backend_url (use as {config.urls.backend} in testSteps)
641
845
  - Frontend URL: $frontend_url (use as {config.urls.frontend} in testUrl)
642
846
 
643
847
  RULES:
644
- 1. Backend stories MUST have testSteps with curl commands that hit real endpoints
645
- Example: curl -s -X POST {config.urls.backend}/api/users -d '...' | jq -e '.id'
646
- 2. Backend stories MUST have apiContract with endpoint, request, response
647
- 3. Frontend stories MUST have testUrl set to {config.urls.frontend}/[page-path]
648
- - Derive page path from story title (e.g., 'login form' → '/login', 'dashboard' → '/dashboard')
649
- 4. Frontend stories MUST have contextFiles array (include idea file path in each story's contextFiles)
650
- 5. Frontend stories MUST have mcp array with browser tools: [\"playwright\", \"devtools\"]
651
- 6. Auth stories MUST have security acceptanceCriteria:
652
- - Passwords hashed with bcrypt (cost 10+)
653
- - Passwords NEVER in API responses
654
- - Rate limiting on login attempts
655
- 7. List endpoints MUST have pagination acceptanceCriteria:
656
- - Returns paginated results (max 100 per page)
657
- - Accepts ?page=N&limit=N query params
658
- 8. Migration stories (creating alembic/versions, migrations/, or modifying models) MUST have prerequisites:
659
- Example: \"prerequisites\": [{\"name\": \"Reset test DB\", \"command\": \"npm run db:reset:test\", \"when\": \"schema changes\"}]
660
- 9. Frontend/general stories that consume APIs MUST have notes about naming conventions:
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 }\"
662
- 10. Each story should include its own techStack and constraints fields. Do NOT add these at the PRD root level.
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.
668
-
669
- CURRENT PRD:
670
- $(cat "$prd_file")
671
-
672
- Output ONLY the fixed JSON, no explanation. Start with { and end with }."
673
-
674
- local raw_response
675
- raw_response=$(echo "$fix_prompt" | run_with_timeout "$CODE_REVIEW_TIMEOUT_SECONDS" claude -p 2>/dev/null)
676
-
677
- # Extract JSON from response (Claude often wraps in markdown code fences)
678
- local fixed_prd
679
- # First strip markdown code fences if present
680
- fixed_prd=$(echo "$raw_response" | sed 's/^```json//; s/^```$//' | sed -n '/^[[:space:]]*{/,/^[[:space:]]*}[[:space:]]*$/p' | head -1000)
681
-
682
- # If sed extraction failed, try removing fences and using raw
683
- if [[ -z "$fixed_prd" ]]; then
684
- fixed_prd=$(echo "$raw_response" | sed 's/^```json//; s/^```//; s/```$//')
848
+ - Backend stories MUST have testSteps with curl commands hitting real endpoints
849
+ Example: curl -s -X POST {config.urls.backend}/api/users -d '...' | jq -e '.id'
850
+ - Backend stories MUST have apiContract with endpoint, request, response
851
+ - Frontend stories MUST have testUrl set to {config.urls.frontend}/[page-path]
852
+ - Frontend stories MUST have contextFiles array
853
+ - Auth stories MUST have security acceptanceCriteria (bcrypt, rate limiting)
854
+ - List endpoints MUST have pagination acceptanceCriteria (?page=N&limit=N)
855
+ - Stories with only curl testSteps MUST also have an offline test step (npm test, tsc --noEmit, pytest)
856
+ - Keep ALL existing fields. Only add/fix what's missing.
857
+
858
+ Output ONLY the fixed story JSON object. Start with { and end with }.
859
+ PROMPT_EOF
860
+
861
+ # Background a Claude call for this story
862
+ ( run_with_timeout 60 claude -p < "$prompt_file" > "$output_file" 2>/dev/null ) &
863
+ pids+=($!)
864
+ story_ids_to_fix+=("$sid")
865
+ output_files+=("$output_file")
866
+ done <<< "$issues"
867
+
868
+ local job_count=${#pids[@]}
869
+ if [[ $job_count -eq 0 ]]; then
870
+ return 0
685
871
  fi
686
872
 
687
- # Create backup BEFORE any validation/write attempts
688
- local backup_file="${prd_file}.$(date +%Y%m%d-%H%M%S).bak"
689
- cp "$prd_file" "$backup_file"
690
-
691
- # Get original story count for validation
692
- local orig_story_count
693
- orig_story_count=$(jq '.stories | length' "$prd_file" 2>/dev/null || echo "0")
694
-
695
- # Validate the response is valid JSON with required structure
696
- if echo "$fixed_prd" | jq -e '.stories' >/dev/null 2>&1; then
697
- # Critical: Check story count is preserved (not just that .stories exists)
698
- local new_story_count
699
- new_story_count=$(echo "$fixed_prd" | jq '.stories | length' 2>/dev/null || echo "0")
700
- if [[ "$new_story_count" -lt "$orig_story_count" ]]; then
701
- print_warning "Fixed PRD has fewer stories ($orig_story_count -> $new_story_count) - keeping original"
702
- echo " Backup preserved at: $backup_file"
703
- return 0
704
- fi
873
+ echo " Fixing $job_count stories in parallel..."
705
874
 
706
- # Safety check: ensure we're not writing drastically smaller content
707
- local orig_size new_size
708
- orig_size=$(wc -c < "$prd_file" | tr -d ' ')
709
- new_size=${#fixed_prd}
710
- if [[ $new_size -lt $((orig_size / 3)) ]]; then
711
- print_warning "Fixed PRD seems too small ($orig_size -> $new_size bytes) - keeping original"
712
- echo " Backup preserved at: $backup_file"
713
- return 0
875
+ # Wait for all background jobs
876
+ for pid in "${pids[@]}"; do
877
+ wait "$pid" 2>/dev/null
878
+ done
879
+
880
+ # Merge results back into PRD
881
+ local merged=0 failed=0
882
+ for i in "${!story_ids_to_fix[@]}"; do
883
+ local sid="${story_ids_to_fix[$i]}"
884
+ local output_file="${output_files[$i]}"
885
+
886
+ [[ ! -s "$output_file" ]] && { failed=$((failed + 1)); continue; }
887
+
888
+ # Extract JSON from response (strip markdown fences if present)
889
+ local raw_response
890
+ raw_response=$(cat "$output_file")
891
+ local fixed_story
892
+ fixed_story=$(echo "$raw_response" | sed 's/^```json//; s/^```$//' | sed -n '/^[[:space:]]*{/,/^[[:space:]]*}[[:space:]]*$/p')
893
+
894
+ if [[ -z "$fixed_story" ]]; then
895
+ fixed_story=$(echo "$raw_response" | sed 's/^```json//; s/^```//; s/```$//')
714
896
  fi
715
897
 
716
- # Write fixed PRD
717
- echo "$fixed_prd" > "$prd_file"
718
- print_success "Test coverage optimized (backup at $backup_file)"
898
+ # Validate it's a valid JSON object with an id field matching this story
899
+ local response_id
900
+ response_id=$(echo "$fixed_story" | jq -r '.id // empty' 2>/dev/null)
901
+ if [[ "$response_id" != "$sid" ]]; then
902
+ # Try to salvage: if valid JSON, force the correct id
903
+ if echo "$fixed_story" | jq -e '.' >/dev/null 2>&1; then
904
+ fixed_story=$(echo "$fixed_story" | jq --arg id "$sid" '.id = $id')
905
+ response_id="$sid"
906
+ else
907
+ failed=$((failed + 1))
908
+ continue
909
+ fi
910
+ fi
719
911
 
720
- # Re-validate to confirm
721
- local remaining_issues
722
- remaining_issues=$(validate_stories_quick "$prd_file")
723
- if [[ -n "$remaining_issues" ]]; then
724
- echo " Some stories may need manual review"
912
+ # Merge fixed story back into PRD using update_json
913
+ local fixed_story_escaped
914
+ fixed_story_escaped=$(echo "$fixed_story" | jq -c '.')
915
+ if update_json "$prd_file" --arg id "$sid" --argjson fixed "$fixed_story_escaped" \
916
+ '(.stories[] | select(.id==$id)) = $fixed'; then
917
+ merged=$((merged + 1))
918
+ else
919
+ failed=$((failed + 1))
725
920
  fi
726
- else
727
- print_warning "Could not auto-optimize - continuing with current PRD"
728
- echo " Backup preserved at: $backup_file"
729
- return 0 # Don't fail, just continue
921
+ done
922
+
923
+ if [[ $merged -gt 0 ]]; then
924
+ print_success "Fixed $merged stories with Claude (backup at $backup_file)"
925
+ fi
926
+ if [[ $failed -gt 0 ]]; then
927
+ print_warning "$failed stories could not be auto-fixed — review with /prd"
928
+ fi
929
+
930
+ # Final validation pass
931
+ local remaining_issues
932
+ remaining_issues=$(validate_stories_quick "$prd_file")
933
+ if [[ -n "$remaining_issues" ]]; then
934
+ echo " Some stories may still need manual review"
730
935
  fi
731
936
  }
732
937
 
package/ralph/setup.sh CHANGED
@@ -92,6 +92,33 @@ ralph_setup() {
92
92
  local pkg_root
93
93
  pkg_root="$(cd "$RALPH_LIB/.." && pwd)"
94
94
 
95
+ # Install timeout utility if missing (critical for session enforcement)
96
+ if ! command -v timeout &>/dev/null && ! command -v gtimeout &>/dev/null; then
97
+ if [[ "$(uname)" == "Darwin" ]]; then
98
+ if command -v brew &>/dev/null; then
99
+ echo ""
100
+ echo " Installing coreutils (provides gtimeout for session enforcement)..."
101
+ if brew install coreutils 2>/dev/null; then
102
+ print_success " coreutils installed"
103
+ else
104
+ print_warning " Failed to install coreutils — session timeouts will use a bash fallback"
105
+ echo " Try manually: brew install coreutils"
106
+ fi
107
+ echo ""
108
+ else
109
+ echo ""
110
+ print_warning "No timeout utility found — session timeouts will use a bash fallback"
111
+ echo " Install Homebrew (https://brew.sh), then: brew install coreutils"
112
+ echo ""
113
+ fi
114
+ else
115
+ echo ""
116
+ print_warning "No timeout utility found — session timeouts will use a bash fallback"
117
+ echo " Install GNU coreutils for reliable timeout enforcement"
118
+ echo ""
119
+ fi
120
+ fi
121
+
95
122
  # Run all setup steps
96
123
  setup_ralph_dir "$pkg_root"
97
124
  setup_custom_checks
@@ -353,8 +380,7 @@ setup_claude_hooks() {
353
380
  }
354
381
 
355
382
  # Resolve each hook path (project hooks take priority over global)
356
- local protect_prd warn_debug warn_secrets warn_urls warn_empty_catch log_tools inject_context save_learnings
357
- protect_prd=$(_resolve_hook "protect-prd.sh")
383
+ local warn_debug warn_secrets warn_urls warn_empty_catch log_tools inject_context save_learnings
358
384
  warn_debug=$(_resolve_hook "warn-debug.sh")
359
385
  warn_secrets=$(_resolve_hook "warn-secrets.sh")
360
386
  warn_urls=$(_resolve_hook "warn-urls.sh")
@@ -364,11 +390,7 @@ setup_claude_hooks() {
364
390
  save_learnings=$(_resolve_hook "save-learnings.sh")
365
391
 
366
392
  # Build hooks arrays using jq for proper JSON
367
- local pre_tool_hooks post_edit_hooks post_all_hooks session_start_hooks stop_hooks
368
-
369
- # PreToolUse: protect-prd on Edit|Write
370
- pre_tool_hooks="[]"
371
- [[ -n "$protect_prd" ]] && pre_tool_hooks=$(jq -n --arg cmd "$protect_prd" '[{"type": "command", "command": $cmd, "timeout": 5}]')
393
+ local post_edit_hooks post_all_hooks session_start_hooks stop_hooks
372
394
 
373
395
  # PostToolUse: warn-* hooks on Edit|Write
374
396
  post_edit_hooks="[]"
@@ -391,13 +413,11 @@ setup_claude_hooks() {
391
413
  # Build the complete hooks config
392
414
  local hooks_config
393
415
  hooks_config=$(jq -n \
394
- --argjson pre_tool "$pre_tool_hooks" \
395
416
  --argjson post_edit "$post_edit_hooks" \
396
417
  --argjson post_all "$post_all_hooks" \
397
418
  --argjson session_start "$session_start_hooks" \
398
419
  --argjson stop "$stop_hooks" \
399
420
  '{
400
- "PreToolUse": [{"matcher": "Edit|Write", "hooks": $pre_tool}],
401
421
  "PostToolUse": [
402
422
  {"matcher": "Edit|Write", "hooks": $post_edit},
403
423
  {"matcher": "*", "hooks": $post_all}
package/ralph/utils.sh CHANGED
@@ -316,8 +316,27 @@ run_with_timeout() {
316
316
  elif command -v gtimeout &>/dev/null; then
317
317
  gtimeout "$seconds" "$@"
318
318
  else
319
- # Fallback: just run without timeout (safe - Claude sessions complete on their own)
320
- "$@"
319
+ # Bash-native fallback: background the command, kill after timeout.
320
+ # Capture stdin to a temp file so the backgrounded process can read it
321
+ # (backgrounded commands lose access to the pipeline's stdin).
322
+ local stdin_file
323
+ stdin_file=$(mktemp)
324
+ cat > "$stdin_file"
325
+ "$@" < "$stdin_file" &
326
+ local cmd_pid=$!
327
+ rm -f "$stdin_file"
328
+ ( sleep "$seconds" && kill -TERM "$cmd_pid" 2>/dev/null ) &
329
+ local watchdog_pid=$!
330
+ wait "$cmd_pid" 2>/dev/null
331
+ local exit_code=$?
332
+ kill "$watchdog_pid" 2>/dev/null
333
+ wait "$watchdog_pid" 2>/dev/null
334
+ # If the process received SIGTERM, return 124 (same as GNU timeout).
335
+ # Note: this cannot distinguish our watchdog from other SIGTERM sources.
336
+ if [[ $exit_code -eq 143 ]]; then # 143 = 128 + 15 (SIGTERM)
337
+ return 124
338
+ fi
339
+ return "$exit_code"
321
340
  fi
322
341
  }
323
342
 
@@ -140,6 +140,7 @@ run_frontend_smoke_test() {
140
140
  # 3. Story-specific testUrl from PRD
141
141
  local test_url
142
142
  test_url=$(jq -r --arg id "$story" '.stories[] | select(.id==$id) | .testUrl // empty' "$RALPH_DIR/prd.json" 2>/dev/null)
143
+ test_url=$(_expand_config_vars "$test_url")
143
144
  if [[ -n "$test_url" ]]; then
144
145
  # testUrl can be full URL or just path
145
146
  if [[ "$test_url" =~ ^https?:// ]]; then
@@ -1,30 +0,0 @@
1
- #!/usr/bin/env bash
2
- # protect-prd.sh - Protect prd.json from marking stories as passed
3
- # Hook: PreToolUse matcher: "Edit|Write"
4
- #
5
- # Allows: Most edits to prd.json (adding fields, fixing test steps, etc.)
6
- # Blocks: Edits that mark stories as "passes": true (Ralph handles this)
7
-
8
- set -euo pipefail
9
-
10
- INPUT=$(cat)
11
- FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""')
12
-
13
- # Check if editing prd.json
14
- if [[ "$FILE_PATH" == *"prd.json"* ]]; then
15
- # Get the new content being written
16
- NEW_STRING=$(echo "$INPUT" | jq -r '.tool_input.new_string // .tool_input.content // ""')
17
-
18
- # Block if trying to set passes to true (Ralph handles story completion)
19
- if echo "$NEW_STRING" | grep -qE '"passes"\s*:\s*true'; then
20
- echo "BLOCKED: Cannot mark stories as passed. Ralph handles this after verification." >&2
21
- exit 2 # Exit code 2 = blocking error
22
- fi
23
-
24
- # Allow all other prd.json edits (adding mcp, constraints, fixing testSteps, etc.)
25
- echo '{"continue": true}'
26
- exit 0
27
- fi
28
-
29
- # Allow all other edits
30
- echo '{"continue": true}'