agentic-loop 3.21.0 → 3.22.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.21.0",
3
+ "version": "3.22.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",
package/ralph/loop.sh CHANGED
@@ -334,8 +334,8 @@ $existing_signs
334
334
  - Extract a single, actionable pattern that prevents this class of failure
335
335
  - The pattern should be general enough to apply to future stories, not specific to this one
336
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"
337
+ Instead of: \"Login with admin@example.com / Password123\"
338
+ Write: \"Use Playwright to login with test credentials from environment variables\"
339
339
  - If the failure is trivial, unclear, or you can't extract a useful pattern, respond with just: NONE
340
340
  - Category must be one of: backend, frontend, testing, general, database, security
341
341
 
package/ralph/setup.sh CHANGED
@@ -307,6 +307,11 @@ setup_gitignore() {
307
307
  ".ralph/last_*"
308
308
  ".ralph/screenshots/"
309
309
  ".ralph/archive/"
310
+ ".ralph/progress.txt"
311
+ ".ralph/tool-log.txt"
312
+ ".ralph/suggested-signs.txt"
313
+ ".ralph/.preflight_cache"
314
+ ".ralph/.lock"
310
315
  ".backups/"
311
316
  ".claude/settings.json"
312
317
  )
@@ -353,9 +358,13 @@ setup_claude_hooks() {
353
358
 
354
359
  # Copy hooks into the project (so they survive package moves)
355
360
  mkdir -p "$project_hooks_dir"
356
- cp "$src_hooks_dir"/*.sh "$project_hooks_dir/" 2>/dev/null || true
357
- chmod +x "$project_hooks_dir"/*.sh 2>/dev/null || true
358
- echo " Copied hooks to $project_hooks_dir/"
361
+ if cp "$src_hooks_dir"/*.sh "$project_hooks_dir/" 2>/dev/null; then
362
+ chmod +x "$project_hooks_dir"/*.sh 2>/dev/null || true
363
+ echo " Copied hooks to $project_hooks_dir/"
364
+ else
365
+ print_warning "Failed to copy hooks from $src_hooks_dir to $project_hooks_dir/"
366
+ echo " Hooks will reference package paths instead (may break after npm update)"
367
+ fi
359
368
 
360
369
  # Note if global hooks exist
361
370
  if [[ -d "$global_hooks_dir" ]] && ls -1 "$global_hooks_dir"/*.sh &>/dev/null; then
@@ -631,12 +640,26 @@ setup_mcp() {
631
640
  tmp=$(mktemp)
632
641
  jq '.mcpServers["chrome-devtools"] = {
633
642
  "command": "npx",
634
- "args": ["chrome-devtools-mcp@latest"]
643
+ "args": ["-y", "chrome-devtools-mcp@latest"]
635
644
  }' "$claude_json" > "$tmp" && mv "$tmp" "$claude_json"
636
645
  echo " Added chrome-devtools MCP server (debugging & inspection)"
637
646
  added_any=true
638
647
  fi
639
648
 
649
+ # Add WebMCP if not configured
650
+ # Allows websites to expose custom MCP tools to Claude via WebSocket
651
+ if ! jq -e '.mcpServers["webmcp"]' "$claude_json" > /dev/null 2>&1; then
652
+ [[ "$added_any" == "false" ]] && echo "Configuring MCP servers..."
653
+ local tmp
654
+ tmp=$(mktemp)
655
+ jq '.mcpServers["webmcp"] = {
656
+ "command": "npx",
657
+ "args": ["-y", "@jason.today/webmcp@latest"]
658
+ }' "$claude_json" > "$tmp" && mv "$tmp" "$claude_json"
659
+ echo " Added WebMCP server (website-exposed tools via WebSocket)"
660
+ added_any=true
661
+ fi
662
+
640
663
  # Ask about test credentials
641
664
  if [[ "$added_any" == "true" ]]; then
642
665
  setup_test_credentials
package/ralph/uat.sh CHANGED
@@ -32,6 +32,14 @@ UAT_MODE_LABEL=""
32
32
  UAT_CONFIG_NS="" # config namespace: "uat" or "chaos"
33
33
  UAT_CMD_NAME="" # CLI command name: "uat" or "chaos-agent"
34
34
 
35
+ # Docker isolation state (set by _should_use_docker_isolation / _chaos_docker_up)
36
+ CHAOS_ISOLATION_RESULT=""
37
+ CHAOS_FRONTEND_URL=""
38
+ CHAOS_API_URL=""
39
+ CHAOS_OVERRIDE_FILE=""
40
+ CHAOS_COMPOSE_FILE=""
41
+ CHAOS_COMPOSE_CMD=""
42
+
35
43
  # TDD phases
36
44
  readonly UAT_PHASE_RED="RED"
37
45
  readonly UAT_PHASE_GREEN="GREEN"
@@ -45,6 +53,9 @@ readonly DEFAULT_UAT_MAX_CASE_RETRIES=5
45
53
  readonly DEFAULT_UAT_SESSION_SECONDS=1800
46
54
  readonly DEFAULT_CHAOS_SESSION_SECONDS=1800
47
55
 
56
+ # Archive retention
57
+ readonly MAX_UAT_ARCHIVE_COUNT=20
58
+
48
59
  # ============================================================================
49
60
  # DIRECTORY INIT
50
61
  # ============================================================================
@@ -148,7 +159,7 @@ run_uat() {
148
159
  print_info "Phase 1: Exploring your app and building a test plan"
149
160
  echo ""
150
161
  if ! _discover_and_plan "$quiet_mode" "uat"; then
151
- print_error "Something went wrong while exploring your app. See the progress log for details."
162
+ _print_discovery_failure_help
152
163
  return 1
153
164
  fi
154
165
  fi
@@ -179,6 +190,12 @@ run_uat() {
179
190
  # Phase 3: Report
180
191
  _print_report
181
192
 
193
+ # Archive and reset for next run
194
+ if [[ "$UAT_TESTS_WRITTEN" -gt 0 ]]; then
195
+ _archive_plan
196
+ rm -f "$UAT_PLAN_FILE"
197
+ fi
198
+
182
199
  return $loop_exit
183
200
  }
184
201
 
@@ -211,6 +228,30 @@ run_chaos() {
211
228
  # Banner
212
229
  _print_chaos_banner
213
230
 
231
+ # Isolation: spin up Docker copy for chaos to attack
232
+ # Call directly (not in $() subshell) so globals are preserved
233
+ local use_docker=false
234
+ _should_use_docker_isolation
235
+ if [[ "$CHAOS_ISOLATION_RESULT" == "true" ]]; then
236
+ print_info "Starting isolated Docker environment..."
237
+ if _chaos_docker_up; then
238
+ use_docker=true
239
+ else
240
+ print_warning "Docker isolation failed — testing against live app"
241
+ print_warning "Non-destructive guardrails are active"
242
+ fi
243
+ fi
244
+
245
+ # Helper to tear down Docker on early exit
246
+ _chaos_early_exit() {
247
+ local code="$1"
248
+ if [[ "$use_docker" == "true" ]]; then
249
+ print_info "Tearing down isolated environment..."
250
+ _chaos_docker_down
251
+ fi
252
+ return "$code"
253
+ }
254
+
214
255
  # Phase 1: Adversarial Discovery + Plan
215
256
  if [[ ! -f "$UAT_PLAN_FILE" ]] || [[ "$force_review" == "true" ]] || [[ "$plan_only" == "true" ]]; then
216
257
  if [[ -f "$UAT_PLAN_FILE" ]] && [[ "$force_review" == "true" ]]; then
@@ -220,7 +261,8 @@ run_chaos() {
220
261
  print_info "Phase 1: Red team exploring your app for vulnerabilities"
221
262
  echo ""
222
263
  if ! _discover_and_plan "$quiet_mode" "chaos"; then
223
- print_error "Something went wrong during red team exploration. See the progress log for details."
264
+ _print_discovery_failure_help
265
+ _chaos_early_exit 1
224
266
  return 1
225
267
  fi
226
268
  fi
@@ -228,11 +270,13 @@ run_chaos() {
228
270
  # Review the plan
229
271
  if ! _review_plan; then
230
272
  print_info "Plan review cancelled. No changes were made."
273
+ _chaos_early_exit 0
231
274
  return 0
232
275
  fi
233
276
 
234
277
  if [[ "$plan_only" == "true" ]]; then
235
278
  print_success "Plan generated. Run 'npx agentic-loop chaos-agent' to execute."
279
+ _chaos_early_exit 0
236
280
  return 0
237
281
  fi
238
282
  else
@@ -251,6 +295,18 @@ run_chaos() {
251
295
  # Phase 3: Report
252
296
  _print_report
253
297
 
298
+ # Archive and reset for next run
299
+ if [[ "$UAT_TESTS_WRITTEN" -gt 0 ]]; then
300
+ _archive_plan
301
+ rm -f "$UAT_PLAN_FILE"
302
+ fi
303
+
304
+ # Isolation: tear down Docker environment
305
+ if [[ "$use_docker" == "true" ]]; then
306
+ print_info "Tearing down isolated environment..."
307
+ _chaos_docker_down
308
+ fi
309
+
254
310
  return $loop_exit
255
311
  }
256
312
 
@@ -278,17 +334,82 @@ _acquire_uat_lock() {
278
334
 
279
335
  _uat_cleanup() {
280
336
  rm -f "$RALPH_DIR/.lock"
337
+ # Safety net: tear down Docker if still running
338
+ if [[ -n "${CHAOS_OVERRIDE_FILE:-}" ]]; then
339
+ _chaos_docker_down 2>/dev/null
340
+ fi
281
341
  }
282
342
 
283
343
  _uat_interrupt() {
284
344
  echo ""
285
345
  print_warning "Interrupted. Wrapping up $UAT_MODE_LABEL..."
346
+ if [[ -n "${CHAOS_OVERRIDE_FILE:-}" ]]; then
347
+ print_info "Tearing down isolated Docker environment..."
348
+ _chaos_docker_down
349
+ fi
286
350
  # Kill all child processes (Claude sessions, test runners)
287
351
  kill 0 2>/dev/null || true
288
352
  _uat_cleanup
289
353
  exit 130
290
354
  }
291
355
 
356
+ # ============================================================================
357
+ # DISCOVERY FAILURE RECOVERY
358
+ # ============================================================================
359
+
360
+ _print_discovery_failure_help() {
361
+ echo ""
362
+ echo " ┌──────────────────────────────────────────────────────┐"
363
+ echo " │ Discovery failed — here's how to recover │"
364
+ echo " └──────────────────────────────────────────────────────┘"
365
+ echo ""
366
+
367
+ # Check common causes and give specific advice
368
+ local has_config=false has_app_url=false app_url=""
369
+ if [[ -f "$RALPH_DIR/config.json" ]]; then
370
+ has_config=true
371
+ app_url=$(jq -r '.frontendUrl // .url // empty' "$RALPH_DIR/config.json" 2>/dev/null)
372
+ [[ -n "$app_url" ]] && has_app_url=true
373
+ fi
374
+
375
+ # Check if the app is reachable
376
+ if [[ "$has_app_url" == "true" ]]; then
377
+ if ! curl -s --max-time 3 "$app_url" > /dev/null 2>&1; then
378
+ echo " Likely cause: Your app at $app_url is not responding."
379
+ echo ""
380
+ echo " Fix: Start your app first, then retry:"
381
+ echo " npm run dev # or whatever starts your app"
382
+ echo " npx agentic-loop $UAT_CMD_NAME"
383
+ echo ""
384
+ return
385
+ fi
386
+ fi
387
+
388
+ if [[ "$has_config" == "false" ]]; then
389
+ echo " Likely cause: No .ralph/config.json found."
390
+ echo ""
391
+ echo " Fix: Run 'npx agentic-loop init' to create one."
392
+ echo ""
393
+ return
394
+ fi
395
+
396
+ # Generic recovery: show progress log and suggest retry
397
+ echo " What happened:"
398
+ if [[ -f "$UAT_PROGRESS_FILE" ]]; then
399
+ echo ""
400
+ tail -5 "$UAT_PROGRESS_FILE" | sed 's/^/ /'
401
+ echo ""
402
+ fi
403
+
404
+ echo " To retry:"
405
+ echo " npx agentic-loop $UAT_CMD_NAME"
406
+ echo ""
407
+ echo " To retry with more time (default: ${DEFAULT_UAT_SESSION_SECONDS}s):"
408
+ echo " Set $UAT_CONFIG_NS.sessionSeconds in .ralph/config.json"
409
+ echo ""
410
+ echo " Full log: $UAT_PROGRESS_FILE"
411
+ }
412
+
292
413
  # ============================================================================
293
414
  # PHASE 1: DISCOVER + PLAN
294
415
  # ============================================================================
@@ -326,20 +447,26 @@ _discover_and_plan() {
326
447
 
327
448
  if [[ $claude_exit -ne 0 ]]; then
328
449
  _log_uat "DISCOVER" "Claude session failed (exit $claude_exit)"
329
- print_error "App exploration session failed"
330
- if [[ -f "$output_file" ]]; then
331
- echo " Last output:"
332
- tail -10 "$output_file" | sed 's/^/ /'
450
+ if [[ $claude_exit -eq 124 ]]; then
451
+ print_error "Discovery timed out after ${timeout}s"
452
+ echo " The exploration ran out of time before finishing."
453
+ echo " Increase timeout: set $UAT_CONFIG_NS.sessionSeconds in .ralph/config.json"
454
+ else
455
+ print_error "Discovery session crashed (exit code $claude_exit)"
456
+ if [[ -f "$output_file" ]]; then
457
+ echo " Last output:"
458
+ tail -5 "$output_file" | sed 's/^/ /'
459
+ fi
333
460
  fi
334
461
  return 1
335
462
  fi
336
463
 
337
464
  # Validate plan was generated
338
465
  if [[ ! -f "$UAT_PLAN_FILE" ]]; then
339
- print_error "No test plan was created"
466
+ print_error "Discovery finished but no test plan was written"
340
467
  echo ""
341
- echo " The exploration finished but didn't produce a plan."
342
- echo " Check the output above for what went wrong."
468
+ echo " Claude explored the app but didn't write .ralph/$UAT_CONFIG_NS/plan.json."
469
+ echo " This usually means the app wasn't reachable or had no testable features."
343
470
  return 1
344
471
  fi
345
472
 
@@ -965,6 +1092,7 @@ _run_green_phase() {
965
1092
  print_success "$case_id: Fixed! Test passes and nothing else broke"
966
1093
  _mark_passed "$case_id"
967
1094
  _track_fixed_files "$case_id"
1095
+ _auto_sign_from_case "$case_id"
968
1096
  UAT_BUGS_FIXED=$((UAT_BUGS_FIXED + 1))
969
1097
  _commit_result "$case_id" "$test_file"
970
1098
  UAT_CASES_PASSED=$((UAT_CASES_PASSED + 1))
@@ -1757,6 +1885,7 @@ _print_uat_banner() {
1757
1885
  echo " | |_| / ___ \\| | | |__| (_) | (_) | |_) |"
1758
1886
  echo " \\___/_/ \\_\\_| |_____\\___/ \\___/| .__/"
1759
1887
  echo " |_|"
1888
+ echo " Acceptance testing loop — verifying things work"
1760
1889
  echo ""
1761
1890
  }
1762
1891
 
@@ -1768,7 +1897,7 @@ _print_chaos_banner() {
1768
1897
  echo " | |___| | | | (_| | (_) \\__ \\/ ___ \\ (_| | __/ | | | |_ "
1769
1898
  echo " \\____|_| |_|\\__,_|\\___/|___/_/ \\_\\__, |\\___|_| |_|\\__|"
1770
1899
  echo " |___/ "
1771
- echo " Red team mode — trying to break things"
1900
+ echo " Red team loop — trying to break things"
1772
1901
  echo ""
1773
1902
  }
1774
1903
 
@@ -1970,11 +2099,505 @@ This is NOT a copy of the template — it's ground truth from the red team's exp
1970
2099
  - `targetFiles` should list the app source files the test covers
1971
2100
  - `testFile` path should use the project's test directory conventions
1972
2101
  - Always clean up: shutdown teammates and delete team when done
2102
+
1973
2103
  PROMPT_SECTION
1974
2104
 
2105
+ # Conditional section: Docker isolation vs non-destructive guardrails
2106
+ if [[ -n "${CHAOS_FRONTEND_URL:-}" ]]; then
2107
+ cat >> "$prompt_file" << PROMPT_DOCKER
2108
+ ### ISOLATED ENVIRONMENT (Docker)
2109
+
2110
+ You are attacking an ISOLATED Docker copy of the application.
2111
+ The developer's live server is NOT affected. Go deeper and harder.
2112
+
2113
+ - Frontend: ${CHAOS_FRONTEND_URL}
2114
+ - API: ${CHAOS_API_URL}
2115
+
2116
+ Use THESE URLs for all testing. Ignore URLs in .ralph/config.json.
2117
+ You CAN test destructive operations (DELETE endpoints, data mutations, etc.)
2118
+ since this environment is disposable.
2119
+ PROMPT_DOCKER
2120
+ else
2121
+ cat >> "$prompt_file" << 'PROMPT_SAFE'
2122
+ ### Non-Destructive Testing (CRITICAL)
2123
+
2124
+ The developer is actively running this app. Your testing MUST NOT corrupt application state:
2125
+
2126
+ - **OBSERVE, don't destroy** — read data, don't delete it. Test inputs, don't wipe databases.
2127
+ - **NO destructive API calls** — do NOT call DELETE endpoints, DROP tables, or clear/reset data
2128
+ - **NO mass mutations** — don't create thousands of records, flood queues, or exhaust rate limits
2129
+ - **Prefer GET over POST/PUT/DELETE** for reconnaissance
2130
+ - **Test XSS/injection via form inputs**, not direct database manipulation
2131
+ - **If you find a destructive vulnerability**, DOCUMENT IT in the plan — don't exploit it live
2132
+ - **Leave the app in a usable state** after each agent finishes
2133
+ - **If the app crashes or becomes unresponsive**, stop testing and report what caused it
2134
+ PROMPT_SAFE
2135
+ fi
2136
+
1975
2137
  _inject_prompt_context "$prompt_file"
1976
2138
  }
1977
2139
 
2140
+ # ============================================================================
2141
+ # ISOLATION: DOCKER-BASED CHAOS ENVIRONMENT
2142
+ # ============================================================================
2143
+
2144
+ # Check whether Docker isolation should be used for chaos-agent runs.
2145
+ # Sets CHAOS_ISOLATION_RESULT to "true" or "false".
2146
+ # Must be called directly (not in a $() subshell) so globals are preserved.
2147
+ # Also sets: CHAOS_COMPOSE_CMD, CHAOS_COMPOSE_FILE
2148
+ _should_use_docker_isolation() {
2149
+ CHAOS_ISOLATION_RESULT="false"
2150
+
2151
+ # Read chaos.isolate directly — get_config uses `// empty` which treats
2152
+ # boolean false as falsy and falls through to the default
2153
+ local isolate="true"
2154
+ local config="$RALPH_DIR/config.json"
2155
+ if [[ -f "$config" ]]; then
2156
+ local raw
2157
+ raw=$(jq -r 'if .chaos.isolate == false then "false" elif .chaos.isolate then .chaos.isolate else "unset" end' "$config" 2>/dev/null)
2158
+ [[ "$raw" != "unset" && "$raw" != "null" && -n "$raw" ]] && isolate="$raw"
2159
+ fi
2160
+ if [[ "$isolate" != "true" ]]; then
2161
+ print_info "Docker isolation disabled (chaos.isolate=false)"
2162
+ return 0
2163
+ fi
2164
+
2165
+ CHAOS_COMPOSE_CMD=$(_detect_compose_cmd)
2166
+ if [[ -z "$CHAOS_COMPOSE_CMD" ]]; then
2167
+ print_info "Docker not available — skipping isolation"
2168
+ return 0
2169
+ fi
2170
+
2171
+ # Find compose file: config override, then standard names
2172
+ local compose_file
2173
+ compose_file=$(get_config '.docker.composeFile' "")
2174
+ if [[ -n "$compose_file" && -f "$compose_file" ]]; then
2175
+ CHAOS_COMPOSE_FILE="$compose_file"
2176
+ CHAOS_ISOLATION_RESULT="true"
2177
+ return 0
2178
+ fi
2179
+
2180
+ for candidate in "docker-compose.yml" "docker-compose.yaml" "compose.yml" "compose.yaml"; do
2181
+ if [[ -f "$candidate" ]]; then
2182
+ CHAOS_COMPOSE_FILE="$candidate"
2183
+ CHAOS_ISOLATION_RESULT="true"
2184
+ return 0
2185
+ fi
2186
+ done
2187
+
2188
+ print_info "No compose file found — skipping Docker isolation"
2189
+ }
2190
+
2191
+ # Parse the compose file for port mappings and generate an override file
2192
+ # with ports offset by chaos.docker.portOffset (default: 10000).
2193
+ # Sets: CHAOS_OVERRIDE_FILE, CHAOS_COMPOSE_FILE
2194
+ _generate_chaos_override() {
2195
+ local port_offset
2196
+ port_offset=$(get_config '.chaos.docker.portOffset' "10000")
2197
+
2198
+ local override_file
2199
+ override_file=$(create_temp_file ".chaos-override.yml")
2200
+
2201
+ # Check for network_mode: host (at service-level indentation, 4+ spaces)
2202
+ if grep -qE '^[[:space:]]{4,}network_mode:[[:space:]]*"?host"?' "$CHAOS_COMPOSE_FILE" 2>/dev/null; then
2203
+ print_error "Compose file uses network_mode: host — cannot isolate ports"
2204
+ return 1
2205
+ fi
2206
+
2207
+ # Build override YAML
2208
+ echo "services:" > "$override_file"
2209
+
2210
+ local current_service=""
2211
+ local in_ports=false
2212
+ local service_has_ports=false
2213
+
2214
+ while IFS= read -r line; do
2215
+ # Detect top-level service name: 2-space indent, alphanumeric/dot/dash/underscore, colon
2216
+ # Allows trailing whitespace and comments (e.g., " web: # my service")
2217
+ if [[ "$line" =~ ^[[:space:]]{2}[a-zA-Z0-9._-]+:[[:space:]]*(#.*)?$ ]] && ! [[ "$line" =~ ^[[:space:]]{4} ]]; then
2218
+ current_service=$(echo "$line" | sed 's/^[[:space:]]*//' | sed 's/:[[:space:]]*#.*//' | tr -d ':')
2219
+ in_ports=false
2220
+ service_has_ports=false
2221
+ fi
2222
+
2223
+ # Detect ports: section (must be under a service, i.e. 4+ spaces)
2224
+ if [[ "$line" =~ ^[[:space:]]{4,}ports:[[:space:]]*(#.*)?$ ]]; then
2225
+ in_ports=true
2226
+ continue
2227
+ fi
2228
+
2229
+ # Parse port mappings within a ports: section
2230
+ if [[ "$in_ports" == "true" ]]; then
2231
+ # Handle three-part format: "IP:HOST:CONTAINER" (e.g., "127.0.0.1:8080:8080")
2232
+ if [[ "$line" =~ ^[[:space:]]*-[[:space:]]*\"?([0-9.]+):([0-9]+):([0-9]+)\"? ]]; then
2233
+ local bind_ip="${BASH_REMATCH[1]}"
2234
+ local host_port="${BASH_REMATCH[2]}"
2235
+ local container_port="${BASH_REMATCH[3]}"
2236
+ local new_port=$((host_port + port_offset))
2237
+
2238
+ if [[ "$new_port" -gt 65535 ]]; then
2239
+ print_error "Port ${host_port}+${port_offset}=${new_port} exceeds 65535"
2240
+ print_error "Reduce chaos.docker.portOffset in .ralph/config.json"
2241
+ return 1
2242
+ fi
2243
+
2244
+ if [[ "$service_has_ports" == "false" ]]; then
2245
+ echo " ${current_service}:" >> "$override_file"
2246
+ echo " ports:" >> "$override_file"
2247
+ service_has_ports=true
2248
+ fi
2249
+
2250
+ echo " - \"${bind_ip}:${new_port}:${container_port}\"" >> "$override_file"
2251
+ # Standard two-part format: "HOST:CONTAINER" (e.g., "8001:8001")
2252
+ elif [[ "$line" =~ ^[[:space:]]*-[[:space:]]*\"?([0-9]+):([0-9]+)\"? ]]; then
2253
+ local host_port="${BASH_REMATCH[1]}"
2254
+ local container_port="${BASH_REMATCH[2]}"
2255
+ local new_port=$((host_port + port_offset))
2256
+
2257
+ if [[ "$new_port" -gt 65535 ]]; then
2258
+ print_error "Port ${host_port}+${port_offset}=${new_port} exceeds 65535"
2259
+ print_error "Reduce chaos.docker.portOffset in .ralph/config.json"
2260
+ return 1
2261
+ fi
2262
+
2263
+ # Write service header on first port
2264
+ if [[ "$service_has_ports" == "false" ]]; then
2265
+ echo " ${current_service}:" >> "$override_file"
2266
+ echo " ports:" >> "$override_file"
2267
+ service_has_ports=true
2268
+ fi
2269
+
2270
+ echo " - \"${new_port}:${container_port}\"" >> "$override_file"
2271
+ elif [[ ! "$line" =~ ^[[:space:]]*- ]] && [[ ! "$line" =~ ^[[:space:]]*$ ]] && [[ ! "$line" =~ ^[[:space:]]*# ]]; then
2272
+ # Non-list, non-blank, non-comment line means we exited the ports section
2273
+ in_ports=false
2274
+ fi
2275
+ fi
2276
+ done < "$CHAOS_COMPOSE_FILE"
2277
+
2278
+ CHAOS_OVERRIDE_FILE="$override_file"
2279
+ }
2280
+
2281
+ # Start the isolated Docker stack for chaos-agent.
2282
+ # Sets: CHAOS_FRONTEND_URL, CHAOS_API_URL
2283
+ _chaos_docker_up() {
2284
+ # Clean up any stale containers from interrupted runs
2285
+ _chaos_docker_down 2>/dev/null
2286
+
2287
+ # Call directly (not in $() subshell) so CHAOS_OVERRIDE_FILE global is preserved
2288
+ _generate_chaos_override || return 1
2289
+
2290
+ local port_offset health_timeout
2291
+ port_offset=$(get_config '.chaos.docker.portOffset' "10000")
2292
+ health_timeout=$(get_config '.chaos.docker.healthTimeout' "120")
2293
+
2294
+ # Read chaos.docker.build directly — get_config treats boolean false as falsy
2295
+ local should_build="true"
2296
+ local config="$RALPH_DIR/config.json"
2297
+ if [[ -f "$config" ]]; then
2298
+ local raw_build
2299
+ raw_build=$(jq -r 'if .chaos.docker.build == false then "false" elif .chaos.docker.build then .chaos.docker.build else "unset" end' "$config" 2>/dev/null)
2300
+ [[ "$raw_build" != "unset" && "$raw_build" != "null" && -n "$raw_build" ]] && should_build="$raw_build"
2301
+ fi
2302
+
2303
+ local build_flag=""
2304
+ [[ "$should_build" == "true" ]] && build_flag="--build"
2305
+
2306
+ # Check if compose v2 supports --wait
2307
+ local wait_flag=""
2308
+ if $CHAOS_COMPOSE_CMD up --help 2>&1 | grep -q '\-\-wait'; then
2309
+ wait_flag="--wait --wait-timeout $health_timeout"
2310
+ fi
2311
+
2312
+ _log_uat "ISOLATE" "Starting Docker stack: $CHAOS_COMPOSE_CMD -p ralph-chaos up -d $build_flag $wait_flag"
2313
+
2314
+ # shellcheck disable=SC2086
2315
+ if ! $CHAOS_COMPOSE_CMD -f "$CHAOS_COMPOSE_FILE" -f "$CHAOS_OVERRIDE_FILE" \
2316
+ -p ralph-chaos up -d $build_flag $wait_flag 2>&1; then
2317
+ print_error "Docker stack failed to start"
2318
+ _log_uat "ISOLATE" "Docker stack failed"
2319
+ _chaos_docker_down 2>/dev/null
2320
+ return 1
2321
+ fi
2322
+
2323
+ # If --wait wasn't available, poll for health
2324
+ if [[ -z "$wait_flag" ]]; then
2325
+ if ! _chaos_poll_health "$port_offset" "$health_timeout"; then
2326
+ print_error "Health check timed out after ${health_timeout}s"
2327
+ _log_uat "ISOLATE" "Health check timeout"
2328
+ _chaos_docker_down 2>/dev/null
2329
+ return 1
2330
+ fi
2331
+ fi
2332
+
2333
+ # Compute isolated URLs from offset ports
2334
+ # Extract port after the last colon in URL (handles http://host:PORT/path)
2335
+ local frontend_port api_port
2336
+ frontend_port=$(get_config '.urls.frontend' "http://localhost:5173" | grep -oE ':[0-9]+' | tail -1 | tr -d ':')
2337
+ api_port=$(get_config '.urls.api' "" | grep -oE ':[0-9]+' | tail -1 | tr -d ':')
2338
+
2339
+ if [[ -n "$frontend_port" ]]; then
2340
+ CHAOS_FRONTEND_URL="http://localhost:$((frontend_port + port_offset))"
2341
+ fi
2342
+ if [[ -n "$api_port" ]]; then
2343
+ CHAOS_API_URL="http://localhost:$((api_port + port_offset))"
2344
+ fi
2345
+
2346
+ _log_uat "ISOLATE" "Docker stack ready (frontend: ${CHAOS_FRONTEND_URL:-none}, api: ${CHAOS_API_URL:-none})"
2347
+ print_info "Isolated environment ready (frontend: ${CHAOS_FRONTEND_URL:-none}, api: ${CHAOS_API_URL:-none})"
2348
+ return 0
2349
+ }
2350
+
2351
+ # Fallback health check when --wait is unavailable.
2352
+ # Polls the API health endpoint or checks container state.
2353
+ _chaos_poll_health() {
2354
+ local port_offset="$1"
2355
+ local timeout="$2"
2356
+
2357
+ local health_endpoint
2358
+ health_endpoint=$(get_config '.api.healthEndpoint' "/health")
2359
+ local api_port
2360
+ api_port=$(get_config '.urls.api' "" | grep -oE ':[0-9]+' | tail -1 | tr -d ':')
2361
+
2362
+ local start_time
2363
+ start_time=$(date +%s)
2364
+
2365
+ if [[ -n "$api_port" ]]; then
2366
+ local url="http://localhost:$((api_port + port_offset))${health_endpoint}"
2367
+ print_info "Waiting for health check at $url..."
2368
+ while true; do
2369
+ local now
2370
+ now=$(date +%s)
2371
+ [[ $((now - start_time)) -ge "$timeout" ]] && break
2372
+ if curl -sf --max-time 5 "$url" >/dev/null 2>&1; then
2373
+ return 0
2374
+ fi
2375
+ sleep 3
2376
+ done
2377
+ else
2378
+ # No API URL — just wait for containers to be running
2379
+ print_info "Waiting for containers to be running..."
2380
+ while true; do
2381
+ local now
2382
+ now=$(date +%s)
2383
+ [[ $((now - start_time)) -ge "$timeout" ]] && break
2384
+ # shellcheck disable=SC2086
2385
+ local running
2386
+ running=$($CHAOS_COMPOSE_CMD -p ralph-chaos ps --format json 2>/dev/null | \
2387
+ grep -c '"running"' 2>/dev/null || echo "0")
2388
+ if [[ "$running" -gt 0 ]]; then
2389
+ return 0
2390
+ fi
2391
+ sleep 3
2392
+ done
2393
+ fi
2394
+
2395
+ return 1
2396
+ }
2397
+
2398
+ # Tear down the isolated Docker stack. Idempotent — safe to call when nothing is running.
2399
+ _chaos_docker_down() {
2400
+ if [[ -z "${CHAOS_COMPOSE_CMD:-}" || -z "${CHAOS_COMPOSE_FILE:-}" ]]; then
2401
+ return 0
2402
+ fi
2403
+
2404
+ if [[ -n "${CHAOS_OVERRIDE_FILE:-}" && -f "${CHAOS_OVERRIDE_FILE:-}" ]]; then
2405
+ $CHAOS_COMPOSE_CMD -f "$CHAOS_COMPOSE_FILE" -f "$CHAOS_OVERRIDE_FILE" \
2406
+ -p ralph-chaos down -v --timeout 10 2>/dev/null
2407
+ else
2408
+ $CHAOS_COMPOSE_CMD -f "$CHAOS_COMPOSE_FILE" \
2409
+ -p ralph-chaos down -v --timeout 10 2>/dev/null
2410
+ fi
2411
+
2412
+ CHAOS_FRONTEND_URL=""
2413
+ CHAOS_API_URL=""
2414
+ CHAOS_OVERRIDE_FILE=""
2415
+ }
2416
+
2417
+ # ============================================================================
2418
+ # SELF-LEARNING: ARCHIVE, AUTO-SIGN, HISTORY
2419
+ # ============================================================================
2420
+
2421
+ # Auto-add a sign when chaos-agent fixes a vulnerability (GREEN success only).
2422
+ # UAT mode is skipped — functional test titles are too generic to be useful signs.
2423
+ _auto_sign_from_case() {
2424
+ local case_id="$1"
2425
+
2426
+ # Only for chaos-agent — security findings are high-signal
2427
+ [[ "$UAT_CONFIG_NS" != "chaos" ]] && return 0
2428
+
2429
+ # Read case data from plan.json
2430
+ local case_json title test_approach pattern
2431
+ case_json=$(jq --arg id "$case_id" '.testCases[] | select(.id==$id)' "$UAT_PLAN_FILE" 2>/dev/null)
2432
+ [[ -z "$case_json" ]] && return 0
2433
+
2434
+ title=$(echo "$case_json" | jq -r '.title // empty')
2435
+ [[ -z "$title" ]] && return 0
2436
+
2437
+ test_approach=$(echo "$case_json" | jq -r '.testApproach // empty')
2438
+
2439
+ # Build pattern: "title -- testApproach" or just title
2440
+ if [[ -n "$test_approach" ]]; then
2441
+ pattern="$title -- $test_approach"
2442
+ else
2443
+ pattern="$title"
2444
+ fi
2445
+
2446
+ # Truncate at 200 chars
2447
+ [[ ${#pattern} -gt 200 ]] && pattern="${pattern:0:200}"
2448
+
2449
+ # Check for duplicates
2450
+ if _sign_is_duplicate "$pattern"; then
2451
+ _log_uat "$case_id" "AUTO_SIGN: Skipped duplicate — $pattern"
2452
+ return 0
2453
+ fi
2454
+
2455
+ # Add sign with output suppressed (redirect to log)
2456
+ if ralph_sign "$pattern" "security" "true" "$case_id" > /dev/null 2>&1; then
2457
+ _log_uat "$case_id" "AUTO_SIGN: Added [security] $pattern"
2458
+ print_info "Learned: [security] $pattern"
2459
+ else
2460
+ _log_uat "$case_id" "AUTO_SIGN: Failed to add sign"
2461
+ fi
2462
+ }
2463
+
2464
+ # Archive a completed plan for future reference.
2465
+ _archive_plan() {
2466
+ local archive_dir="$UAT_MODE_DIR/archive"
2467
+ mkdir -p "$archive_dir"
2468
+
2469
+ local timestamp
2470
+ timestamp=$(date +%Y%m%d-%H%M%S 2>/dev/null || date +%Y%m%d-%H%M%S)
2471
+
2472
+ local archive_file="$archive_dir/plan-${timestamp}.json"
2473
+
2474
+ # Record current git hash in the archived plan
2475
+ local git_hash=""
2476
+ if command -v git &>/dev/null && [[ -d ".git" ]]; then
2477
+ git_hash=$(git rev-parse HEAD 2>/dev/null || echo "")
2478
+ fi
2479
+
2480
+ if [[ -n "$git_hash" ]]; then
2481
+ jq --arg hash "$git_hash" '.testSuite.gitHash = $hash' "$UAT_PLAN_FILE" > "$archive_file" 2>/dev/null
2482
+ else
2483
+ cp "$UAT_PLAN_FILE" "$archive_file"
2484
+ fi
2485
+
2486
+ _prune_archives
2487
+ _log_uat "ARCHIVE" "Plan archived: $archive_file"
2488
+ print_info "Plan archived for future reference"
2489
+ }
2490
+
2491
+ # Remove oldest archives beyond retention limit.
2492
+ _prune_archives() {
2493
+ local archive_dir="$UAT_MODE_DIR/archive"
2494
+ [[ ! -d "$archive_dir" ]] && return 0
2495
+
2496
+ local count
2497
+ count=$(find "$archive_dir" -name 'plan-*.json' -type f 2>/dev/null | wc -l | tr -d ' ')
2498
+
2499
+ if [[ "$count" -gt "$MAX_UAT_ARCHIVE_COUNT" ]]; then
2500
+ local to_remove=$((count - MAX_UAT_ARCHIVE_COUNT))
2501
+ # Sort by modification time (oldest first), remove excess
2502
+ find "$archive_dir" -name 'plan-*.json' -type f -print0 2>/dev/null \
2503
+ | xargs -0 ls -1t 2>/dev/null \
2504
+ | tail -"$to_remove" \
2505
+ | while IFS= read -r f; do
2506
+ rm -f "$f"
2507
+ done
2508
+ _log_uat "ARCHIVE" "Pruned $to_remove old archive(s)"
2509
+ fi
2510
+ }
2511
+
2512
+ # Read git hash from the most recent archived plan.
2513
+ # Returns 1 if no archive exists.
2514
+ _get_last_run_git_hash() {
2515
+ local archive_dir="$UAT_MODE_DIR/archive"
2516
+ [[ ! -d "$archive_dir" ]] && return 1
2517
+
2518
+ # Find most recent archive by name (timestamps sort lexically)
2519
+ local latest
2520
+ latest=$(find "$archive_dir" -name 'plan-*.json' -type f 2>/dev/null | sort -r | head -1)
2521
+ [[ -z "$latest" ]] && return 1
2522
+
2523
+ local hash
2524
+ hash=$(jq -r '.testSuite.gitHash // empty' "$latest" 2>/dev/null)
2525
+ [[ -z "$hash" ]] && return 1
2526
+
2527
+ echo "$hash"
2528
+ }
2529
+
2530
+ # List files changed since last run (excluding .ralph/).
2531
+ # Returns empty if no prior run or git unavailable.
2532
+ _get_changed_files_since_last_run() {
2533
+ command -v git &>/dev/null || return 0
2534
+ [[ -d ".git" ]] || return 0
2535
+
2536
+ local last_hash
2537
+ last_hash=$(_get_last_run_git_hash) || return 0
2538
+
2539
+ # Verify the hash is still valid (not from a force push)
2540
+ if ! git rev-parse --verify "$last_hash" &>/dev/null; then
2541
+ return 0
2542
+ fi
2543
+
2544
+ git diff --name-only "${last_hash}..HEAD" 2>/dev/null | grep -v '\.ralph/' || true
2545
+ }
2546
+
2547
+ # Build markdown summary of the last 5 archived plans.
2548
+ _build_archive_summary() {
2549
+ local archive_dir="$UAT_MODE_DIR/archive"
2550
+ [[ ! -d "$archive_dir" ]] && return 0
2551
+
2552
+ local archives
2553
+ archives=$(find "$archive_dir" -name 'plan-*.json' -type f 2>/dev/null | sort -r | head -5)
2554
+ [[ -z "$archives" ]] && return 0
2555
+
2556
+ local archive_count
2557
+ archive_count=$(find "$archive_dir" -name 'plan-*.json' -type f 2>/dev/null | wc -l | tr -d ' ')
2558
+
2559
+ echo ""
2560
+ echo "### Prior Run History ($archive_count previous run$([ "$archive_count" -ne 1 ] && echo "s"))"
2561
+ echo ""
2562
+ echo "These tests have ALREADY been run. Do NOT repeat them."
2563
+ echo ""
2564
+
2565
+ local run_num=0
2566
+ while IFS= read -r archive_file; do
2567
+ [[ -z "$archive_file" ]] && continue
2568
+ run_num=$((run_num + 1))
2569
+
2570
+ # Extract timestamp from filename: plan-YYYYMMDD-HHMMSS.json
2571
+ local ts
2572
+ ts=$(basename "$archive_file" .json | sed 's/^plan-//')
2573
+
2574
+ echo "**Run $run_num** ($ts):"
2575
+
2576
+ # List test cases with status
2577
+ jq -r '.testCases[] | " \(.id) [\(.category // "general")] \(.title) — \(if .passes then (if .skipped then "SKIPPED" else "PASSED" end) else "FAILED" end)"' \
2578
+ "$archive_file" 2>/dev/null || true
2579
+
2580
+ echo ""
2581
+ done <<< "$archives"
2582
+ }
2583
+
2584
+ # Build markdown section listing files changed since last run.
2585
+ _build_changed_files_section() {
2586
+ local changed_files
2587
+ changed_files=$(_get_changed_files_since_last_run)
2588
+ [[ -z "$changed_files" ]] && return 0
2589
+
2590
+ local file_count
2591
+ file_count=$(echo "$changed_files" | wc -l | tr -d ' ')
2592
+
2593
+ echo ""
2594
+ echo "### Files Changed Since Last Run ($file_count file$([ "$file_count" -ne 1 ] && echo "s"))"
2595
+ echo ""
2596
+ echo "PRIORITIZE testing these files — they are most likely to have new vulnerabilities."
2597
+ echo ""
2598
+ echo "$changed_files"
2599
+ }
2600
+
1978
2601
  # ============================================================================
1979
2602
  # HELPERS
1980
2603
  # ============================================================================
@@ -2002,6 +2625,32 @@ _inject_prompt_context() {
2002
2625
  echo "Read \`.ralph/config.json\` for URLs and directories." >> "$prompt_file"
2003
2626
  fi
2004
2627
 
2628
+ # Inject prior run history (what was already tested)
2629
+ _build_archive_summary >> "$prompt_file"
2630
+
2631
+ # Inject changed files (what to focus on)
2632
+ _build_changed_files_section >> "$prompt_file"
2633
+
2634
+ # "Do Not Repeat" instruction block
2635
+ local has_history=false
2636
+ [[ -d "$UAT_MODE_DIR/archive" ]] && \
2637
+ [[ -n "$(find "$UAT_MODE_DIR/archive" -name 'plan-*.json' -type f 2>/dev/null | head -1)" ]] && \
2638
+ has_history=true
2639
+
2640
+ if [[ "$has_history" == "true" ]]; then
2641
+ cat >> "$prompt_file" << 'DO_NOT_REPEAT'
2642
+
2643
+ ### Focus: New Ground Only
2644
+
2645
+ You have access to prior run history above. Follow these rules:
2646
+ - Do NOT repeat tests that already passed in prior runs
2647
+ - PRIORITIZE files changed since the last run
2648
+ - Go DEEPER — find new attack vectors, edge cases, and cross-feature interactions
2649
+ - If prior runs tested a feature superficially, test it more thoroughly
2650
+ - Focus on interactions BETWEEN features (e.g., auth + forms, navigation + data)
2651
+ DO_NOT_REPEAT
2652
+ fi
2653
+
2005
2654
  # Inject signs
2006
2655
  _inject_signs >> "$prompt_file"
2007
2656
  }
package/ralph/utils.sh CHANGED
@@ -859,7 +859,9 @@ find_all_migration_tools() {
859
859
  done
860
860
 
861
861
  # Return unique tools
862
- printf '%s\n' "${tools[@]}" | sort -u
862
+ if [[ ${#tools[@]} -gt 0 ]]; then
863
+ printf '%s\n' "${tools[@]}" | sort -u
864
+ fi
863
865
  }
864
866
 
865
867
  # Validate batch assignments in a PRD file
@@ -1027,3 +1029,15 @@ run_migrations_if_needed() {
1027
1029
  return 1
1028
1030
  fi
1029
1031
  }
1032
+
1033
+ # Detect the available Docker Compose command.
1034
+ # Returns "docker compose" (v2) or "docker-compose" (v1), or empty string if neither.
1035
+ _detect_compose_cmd() {
1036
+ if docker compose version &>/dev/null; then
1037
+ echo "docker compose"
1038
+ elif command -v docker-compose &>/dev/null; then
1039
+ echo "docker-compose"
1040
+ else
1041
+ echo ""
1042
+ fi
1043
+ }