agentic-loop 3.10.2 → 3.11.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.
@@ -0,0 +1,498 @@
1
+ #!/usr/bin/env bash
2
+ # shellcheck shell=bash
3
+ #
4
+ # prd-check.sh - PRD validation and optimization for Ralph
5
+ #
6
+ # ============================================================================
7
+ # OVERVIEW
8
+ # ============================================================================
9
+ # Validates PRD structure and story quality BEFORE the loop starts. Catches
10
+ # issues early (missing test steps, vague requirements) rather than failing
11
+ # 50+ times during execution.
12
+ #
13
+ # This runs once at loop startup, not after each story.
14
+ #
15
+ # ============================================================================
16
+ # WHAT IT CHECKS
17
+ # ============================================================================
18
+ #
19
+ # Structure validation:
20
+ # - Valid JSON syntax
21
+ # - Has .feature.name
22
+ # - Has .stories array (non-empty)
23
+ # - Each story has id and title
24
+ # - Initializes passes=false for new stories
25
+ #
26
+ # Story quality checks (per story type):
27
+ #
28
+ # ALL STORIES:
29
+ # - Has testSteps (not empty)
30
+ # - testSteps are executable commands, not prose
31
+ # Good: "curl -s POST /api/login | jq -e '.token'"
32
+ # Bad: "Verify the user can log in"
33
+ #
34
+ # BACKEND STORIES:
35
+ # - Has curl commands in testSteps (not just "npm test")
36
+ # - Has apiContract with endpoint, request, response
37
+ #
38
+ # FRONTEND STORIES:
39
+ # - Has tsc or playwright in testSteps
40
+ # - Has testUrl for browser verification
41
+ # - Has contextFiles (design specs, etc.)
42
+ #
43
+ # AUTH STORIES (login, register, password):
44
+ # - Has security criteria (bcrypt, sanitize, rate limit)
45
+ #
46
+ # LIST ENDPOINTS (get all, index):
47
+ # - Has pagination criteria (limit, page params)
48
+ #
49
+ # MIGRATION STORIES (alembic, migrations, models):
50
+ # - Has prerequisites array with DB reset command
51
+ # - Prevents infinite retries on schema mismatch errors
52
+ #
53
+ # ============================================================================
54
+ # AUTO-FIX
55
+ # ============================================================================
56
+ # When issues are found, Claude is invoked to fix them automatically:
57
+ #
58
+ # 1. Issues are summarized (e.g., "3x backend: add curl tests")
59
+ # 2. Claude receives the PRD + issues + fix rules
60
+ # 3. Fixed PRD is validated and saved
61
+ # 4. Timestamped backup preserved (prd.json.20240115-143022.bak)
62
+ #
63
+ # If Claude is unavailable or fix fails, loop continues with warnings.
64
+ #
65
+ # ============================================================================
66
+ # CONFIGURATION
67
+ # ============================================================================
68
+ #
69
+ # .checks.requireTests - Warn if no test directory configured
70
+ # .tests.directory - Where tests live (for requireTests check)
71
+ #
72
+ # ============================================================================
73
+ # USAGE
74
+ # ============================================================================
75
+ #
76
+ # source ralph/prd-check.sh
77
+ #
78
+ # # Full validation with auto-fix
79
+ # validate_prd ".ralph/prd.json"
80
+ #
81
+ # # Quick check without auto-fix (returns issues string)
82
+ # issues=$(validate_stories_quick ".ralph/prd.json")
83
+ #
84
+ # ============================================================================
85
+
86
+ # Validate PRD structure and story quality
87
+ # Returns 0 if valid (possibly after auto-fix), 1 if unrecoverable error
88
+ validate_prd() {
89
+ local prd_file="$1"
90
+
91
+ # Check file exists
92
+ if [[ ! -f "$prd_file" ]]; then
93
+ print_error "PRD file not found: $prd_file"
94
+ return 1
95
+ fi
96
+
97
+ # Check valid JSON
98
+ if ! jq -e . "$prd_file" >/dev/null 2>&1; then
99
+ print_error "prd.json is not valid JSON."
100
+ echo ""
101
+ echo "Fix it manually or regenerate with:"
102
+ echo " /idea 'your feature'"
103
+ echo ""
104
+ return 1
105
+ fi
106
+
107
+ # Check feature.name is set
108
+ local feature_name
109
+ feature_name=$(jq -r '.feature.name // empty' "$prd_file" 2>/dev/null)
110
+ if [[ -z "$feature_name" || "$feature_name" == "null" ]]; then
111
+ print_error "prd.json is missing .feature.name"
112
+ echo ""
113
+ echo "Add a feature name to your PRD or regenerate with:"
114
+ echo " /idea 'your feature'"
115
+ echo ""
116
+ return 1
117
+ fi
118
+
119
+ # Check for stories array
120
+ if ! jq -e '.stories' "$prd_file" >/dev/null 2>&1; then
121
+ print_error "prd.json is missing 'stories' array."
122
+ echo ""
123
+ echo "Regenerate with: /idea 'your feature'"
124
+ echo ""
125
+ return 1
126
+ fi
127
+
128
+ # Check stories is not empty
129
+ local story_count
130
+ story_count=$(jq '.stories | length' "$prd_file" 2>/dev/null || echo "0")
131
+ if [[ "$story_count" == "0" ]]; then
132
+ print_error "prd.json has no stories."
133
+ echo ""
134
+ echo "Regenerate with: /idea 'your feature'"
135
+ echo ""
136
+ return 1
137
+ fi
138
+
139
+ # Check each story has required fields
140
+ local invalid_stories
141
+ invalid_stories=$(jq -r '.stories[] | select(.id == null or .id == "" or .title == null or .title == "") | .id // "unnamed"' "$prd_file" 2>/dev/null)
142
+ if [[ -n "$invalid_stories" ]]; then
143
+ print_error "Some stories are missing required fields (id, title):"
144
+ echo "$invalid_stories" | head -5
145
+ echo ""
146
+ echo "Fix the PRD or regenerate with: /idea 'your feature'"
147
+ echo ""
148
+ return 1
149
+ fi
150
+
151
+ # Check stories have passes field (initialize if missing)
152
+ local missing_passes
153
+ missing_passes=$(jq '[.stories[] | select(.passes == null)] | length' "$prd_file" 2>/dev/null || echo "0")
154
+ if [[ "$missing_passes" != "0" ]]; then
155
+ print_info "Initializing $missing_passes stories with passes=false..."
156
+ update_json "$prd_file" '(.stories[] | select(.passes == null) | .passes) = false'
157
+ fi
158
+
159
+ # Check if project has tests (from config)
160
+ local config="$RALPH_DIR/config.json"
161
+ if [[ -f "$config" ]]; then
162
+ local require_tests
163
+ require_tests=$(jq -r '.checks.requireTests // true' "$config" 2>/dev/null)
164
+ local test_dir
165
+ test_dir=$(jq -r '.tests.directory // empty' "$config" 2>/dev/null)
166
+
167
+ if [[ "$require_tests" == "true" && -z "$test_dir" ]]; then
168
+ echo ""
169
+ print_warning "No test directory configured in .ralph/config.json"
170
+ echo " Without tests, Ralph can only verify syntax and API responses."
171
+ echo " Import errors and integration issues won't be caught."
172
+ echo ""
173
+ echo " To fix: Add tests, or set in .ralph/config.json:"
174
+ echo " {\"tests\": {\"directory\": \"src\", \"patterns\": \"*.test.ts\"}}"
175
+ echo " To silence: {\"checks\": {\"requireTests\": false}}"
176
+ echo ""
177
+ fi
178
+ fi
179
+
180
+ # Replace hardcoded paths with config placeholders
181
+ fix_hardcoded_paths "$prd_file" "$config"
182
+
183
+ # Validate and fix individual stories
184
+ _validate_and_fix_stories "$prd_file" || return 1
185
+
186
+ return 0
187
+ }
188
+
189
+ # ============================================================================
190
+ # INTERNAL FUNCTIONS
191
+ # ============================================================================
192
+
193
+ # Validate individual stories and auto-fix with Claude if needed
194
+ _validate_and_fix_stories() {
195
+ local prd_file="$1"
196
+ local needs_fix=false
197
+ local issues=""
198
+ local story_count=0
199
+
200
+ # Issue counters (bash 3.2 compatible - no associative arrays)
201
+ local cnt_no_tests=0 cnt_backend_curl=0 cnt_backend_contract=0
202
+ local cnt_frontend_tsc=0 cnt_frontend_url=0 cnt_frontend_context=0
203
+ local cnt_auth_security=0 cnt_list_pagination=0 cnt_prose_steps=0
204
+ local cnt_migration_prereq=0
205
+
206
+ echo " Checking test coverage..."
207
+
208
+ # Only validate incomplete stories (skip stories that already passed)
209
+ local story_ids
210
+ story_ids=$(jq -r '.stories[] | select(.passes != true) | .id' "$prd_file" 2>/dev/null)
211
+
212
+ while IFS= read -r story_id; do
213
+ [[ -z "$story_id" ]] && continue
214
+
215
+ local story_issues=""
216
+ local story_type
217
+ story_type=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .type // "unknown"' "$prd_file")
218
+ local story_title
219
+ story_title=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .title // ""' "$prd_file")
220
+
221
+ # Check 1: testSteps quality
222
+ local test_steps
223
+ test_steps=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .testSteps // [] | join(" ")' "$prd_file")
224
+
225
+ if [[ -z "$test_steps" ]]; then
226
+ story_issues+="no testSteps, "
227
+ cnt_no_tests=$((cnt_no_tests + 1))
228
+ else
229
+ # Check test steps are executable commands, not prose
230
+ # Good: "curl -s POST /api/login | jq -e '.token'"
231
+ # Bad: "Verify the user can log in successfully"
232
+ if ! echo "$test_steps" | grep -qE "(curl |npm |pytest|go test|cargo test|mix test|rails test|bundle exec|python |node |sh |bash |\| jq)"; then
233
+ story_issues+="testSteps look like prose (need executable commands), "
234
+ cnt_prose_steps=$((cnt_prose_steps + 1))
235
+ fi
236
+
237
+ # Type-specific checks
238
+ if [[ "$story_type" == "backend" ]]; then
239
+ # Backend must have curl, not just npm test/pytest
240
+ if ! echo "$test_steps" | grep -q "curl "; then
241
+ story_issues+="backend needs curl tests, "
242
+ cnt_backend_curl=$((cnt_backend_curl + 1))
243
+ fi
244
+ elif [[ "$story_type" == "frontend" ]]; then
245
+ # Frontend must have tsc or playwright
246
+ if ! echo "$test_steps" | grep -qE "(tsc --noEmit|playwright)"; then
247
+ story_issues+="frontend needs tsc/playwright tests, "
248
+ cnt_frontend_tsc=$((cnt_frontend_tsc + 1))
249
+ fi
250
+ fi
251
+ fi
252
+
253
+ # Check 2: Backend needs apiContract
254
+ if [[ "$story_type" == "backend" ]]; then
255
+ local has_contract
256
+ has_contract=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .apiContract // empty' "$prd_file")
257
+ if [[ -z "$has_contract" || "$has_contract" == "null" ]]; then
258
+ story_issues+="missing apiContract, "
259
+ cnt_backend_contract=$((cnt_backend_contract + 1))
260
+ fi
261
+ fi
262
+
263
+ # Check 3: Frontend needs testUrl and contextFiles
264
+ if [[ "$story_type" == "frontend" ]]; then
265
+ local has_url
266
+ has_url=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .testUrl // empty' "$prd_file")
267
+ if [[ -z "$has_url" || "$has_url" == "null" ]]; then
268
+ story_issues+="missing testUrl, "
269
+ cnt_frontend_url=$((cnt_frontend_url + 1))
270
+ fi
271
+
272
+ local context_files
273
+ context_files=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .contextFiles // [] | length' "$prd_file")
274
+ if [[ "$context_files" == "0" ]]; then
275
+ story_issues+="missing contextFiles, "
276
+ cnt_frontend_context=$((cnt_frontend_context + 1))
277
+ fi
278
+ fi
279
+
280
+ # Check 4: Auth stories need security criteria
281
+ if echo "$story_title" | grep -qiE "(login|auth|password|register|signup|sign.?up)"; then
282
+ local criteria
283
+ criteria=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .acceptanceCriteria // [] | join(" ")' "$prd_file")
284
+ if ! echo "$criteria" | grep -qiE "(hash|bcrypt|sanitiz|inject|rate.?limit)"; then
285
+ story_issues+="missing security criteria, "
286
+ cnt_auth_security=$((cnt_auth_security + 1))
287
+ fi
288
+ fi
289
+
290
+ # Check 5: List endpoints need scale criteria
291
+ # Note: "search" excluded - search endpoints often return single/filtered results
292
+ if echo "$story_title" | grep -qiE "(list|get all|fetch all|index)"; then
293
+ local criteria
294
+ criteria=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .acceptanceCriteria // [] | join(" ")' "$prd_file")
295
+ if ! echo "$criteria" | grep -qiE "(pagina|limit|page=|per.?page)"; then
296
+ story_issues+="missing pagination criteria, "
297
+ cnt_list_pagination=$((cnt_list_pagination + 1))
298
+ fi
299
+ fi
300
+
301
+ # Check 6: Migration stories need DB prerequisites
302
+ # If story creates migration files or modifies models, it needs resetDb prerequisite
303
+ local story_files
304
+ story_files=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | (.files.create // []) + (.files.modify // []) | join(" ")' "$prd_file")
305
+ if echo "$story_files" | grep -qiE "(alembic/versions|migrations/|\.migration\.|models\.py|models/|schema\.)"; then
306
+ local has_prereq
307
+ has_prereq=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .prerequisites // [] | length' "$prd_file")
308
+ if [[ "$has_prereq" == "0" ]]; then
309
+ story_issues+="migration story needs prerequisites (DB reset), "
310
+ cnt_migration_prereq=$((cnt_migration_prereq + 1))
311
+ fi
312
+ fi
313
+
314
+ # Track this story if it has issues
315
+ if [[ -n "$story_issues" ]]; then
316
+ needs_fix=true
317
+ story_count=$((story_count + 1))
318
+ issues+="$story_id: ${story_issues%%, }
319
+ "
320
+ fi
321
+ done <<< "$story_ids"
322
+
323
+ # If issues found, show summary and attempt fix
324
+ if [[ "$needs_fix" == "true" ]]; then
325
+ echo " Optimizing test coverage for $story_count stories..."
326
+
327
+ # Print compact summary (only non-zero counts)
328
+ [[ $cnt_no_tests -gt 0 ]] && echo " ${cnt_no_tests}x missing testSteps"
329
+ [[ $cnt_prose_steps -gt 0 ]] && echo " ${cnt_prose_steps}x testSteps are prose (need executable commands)"
330
+ [[ $cnt_backend_curl -gt 0 ]] && echo " ${cnt_backend_curl}x backend: add curl tests"
331
+ [[ $cnt_backend_contract -gt 0 ]] && echo " ${cnt_backend_contract}x backend: add apiContract"
332
+ [[ $cnt_frontend_tsc -gt 0 ]] && echo " ${cnt_frontend_tsc}x frontend: add tsc/playwright"
333
+ [[ $cnt_frontend_url -gt 0 ]] && echo " ${cnt_frontend_url}x frontend: add testUrl"
334
+ [[ $cnt_frontend_context -gt 0 ]] && echo " ${cnt_frontend_context}x frontend: add contextFiles"
335
+ [[ $cnt_auth_security -gt 0 ]] && echo " ${cnt_auth_security}x auth: add security criteria"
336
+ [[ $cnt_list_pagination -gt 0 ]] && echo " ${cnt_list_pagination}x list: add pagination"
337
+ [[ $cnt_migration_prereq -gt 0 ]] && echo " ${cnt_migration_prereq}x migration: add prerequisites (DB reset)"
338
+
339
+ # Check if Claude is available for auto-fix
340
+ if command -v claude &>/dev/null; then
341
+ _fix_stories_with_claude "$prd_file" "$issues"
342
+ else
343
+ print_warning "Claude CLI not found - run manually to optimize test coverage"
344
+ return 1
345
+ fi
346
+ else
347
+ print_success "Test coverage looks good"
348
+ fi
349
+
350
+ return 0
351
+ }
352
+
353
+ # Optimize story test coverage using Claude
354
+ _fix_stories_with_claude() {
355
+ local prd_file="$1"
356
+ local issues="$2"
357
+
358
+ local fix_prompt="Enhance test coverage for these stories. Output the COMPLETE updated prd.json.
359
+
360
+ STORIES TO OPTIMIZE:
361
+ $issues
362
+
363
+ RULES:
364
+ 1. Backend stories MUST have testSteps with curl commands that hit real endpoints
365
+ Example: curl -s -X POST {config.urls.backend}/api/users -d '...' | jq -e '.id'
366
+ 2. Backend stories MUST have apiContract with endpoint, request, response
367
+ 3. Frontend stories MUST have testUrl set to {config.urls.frontend}/page
368
+ 4. Frontend stories MUST have contextFiles array (include idea file path from originalContext)
369
+ 5. Auth stories MUST have security acceptanceCriteria:
370
+ - Passwords hashed with bcrypt (cost 10+)
371
+ - Passwords NEVER in API responses
372
+ - Rate limiting on login attempts
373
+ 6. List endpoints MUST have pagination acceptanceCriteria:
374
+ - Returns paginated results (max 100 per page)
375
+ - Accepts ?page=N&limit=N query params
376
+ 7. Migration stories (creating alembic/versions, migrations/, or modifying models) MUST have prerequisites:
377
+ Example: \"prerequisites\": [{\"name\": \"Reset test DB\", \"command\": \"npm run db:reset:test\", \"when\": \"schema changes\"}]
378
+
379
+ CURRENT PRD:
380
+ $(cat "$prd_file")
381
+
382
+ Output ONLY the fixed JSON, no explanation. Start with { and end with }."
383
+
384
+ local raw_response
385
+ raw_response=$(echo "$fix_prompt" | run_with_timeout "$CODE_REVIEW_TIMEOUT_SECONDS" claude -p 2>/dev/null)
386
+
387
+ # Extract JSON from response (Claude often wraps in markdown code fences)
388
+ local fixed_prd
389
+ # First strip markdown code fences if present
390
+ fixed_prd=$(echo "$raw_response" | sed 's/^```json//; s/^```$//' | sed -n '/^[[:space:]]*{/,/^[[:space:]]*}[[:space:]]*$/p' | head -1000)
391
+
392
+ # If sed extraction failed, try removing fences and using raw
393
+ if [[ -z "$fixed_prd" ]]; then
394
+ fixed_prd=$(echo "$raw_response" | sed 's/^```json//; s/^```//; s/```$//')
395
+ fi
396
+
397
+ # Validate the response is valid JSON with required structure
398
+ if echo "$fixed_prd" | jq -e '.stories' >/dev/null 2>&1; then
399
+ # Timestamped backup (preserves history across multiple fixes)
400
+ local backup_file="${prd_file}.$(date +%Y%m%d-%H%M%S).bak"
401
+ cp "$prd_file" "$backup_file"
402
+
403
+ # Write fixed PRD
404
+ echo "$fixed_prd" > "$prd_file"
405
+ print_success "Test coverage optimized (backup at $backup_file)"
406
+
407
+ # Re-validate to confirm
408
+ local remaining_issues
409
+ remaining_issues=$(validate_stories_quick "$prd_file")
410
+ if [[ -n "$remaining_issues" ]]; then
411
+ echo " Some stories may need manual review"
412
+ fi
413
+ else
414
+ print_warning "Could not auto-optimize - continuing with current PRD"
415
+ return 0 # Don't fail, just continue
416
+ fi
417
+ }
418
+
419
+ # Quick validation without auto-fix (for re-checking after fix)
420
+ # Returns issues string (empty if all good)
421
+ validate_stories_quick() {
422
+ local prd_file="$1"
423
+ local issues=""
424
+
425
+ # Only check incomplete stories
426
+ local story_ids
427
+ story_ids=$(jq -r '.stories[] | select(.passes != true) | .id' "$prd_file" 2>/dev/null)
428
+
429
+ while IFS= read -r story_id; do
430
+ [[ -z "$story_id" ]] && continue
431
+
432
+ local story_type
433
+ story_type=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .type // "unknown"' "$prd_file")
434
+ local story_title
435
+ story_title=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .title // ""' "$prd_file")
436
+ local test_steps
437
+ test_steps=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .testSteps // [] | join(" ")' "$prd_file")
438
+
439
+ # Check 1: testSteps quality
440
+ if [[ "$story_type" == "backend" ]] && ! echo "$test_steps" | grep -q "curl "; then
441
+ issues+="$story_id: missing curl tests, "
442
+ fi
443
+ if [[ "$story_type" == "frontend" ]] && ! echo "$test_steps" | grep -qE "(tsc --noEmit|playwright)"; then
444
+ issues+="$story_id: missing tsc/playwright tests, "
445
+ fi
446
+
447
+ # Check 2: Backend needs apiContract
448
+ if [[ "$story_type" == "backend" ]]; then
449
+ local has_contract
450
+ has_contract=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .apiContract // empty' "$prd_file")
451
+ if [[ -z "$has_contract" || "$has_contract" == "null" ]]; then
452
+ issues+="$story_id: missing apiContract, "
453
+ fi
454
+ fi
455
+
456
+ # Check 3: Frontend needs testUrl and contextFiles
457
+ if [[ "$story_type" == "frontend" ]]; then
458
+ local has_url
459
+ has_url=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .testUrl // empty' "$prd_file")
460
+ [[ -z "$has_url" || "$has_url" == "null" ]] && issues+="$story_id: missing testUrl, "
461
+
462
+ local context_files
463
+ context_files=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .contextFiles // [] | length' "$prd_file")
464
+ [[ "$context_files" == "0" ]] && issues+="$story_id: missing contextFiles, "
465
+ fi
466
+
467
+ # Check 4: Auth stories need security criteria
468
+ if echo "$story_title" | grep -qiE "(login|auth|password|register|signup|sign.?up)"; then
469
+ local criteria
470
+ criteria=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .acceptanceCriteria // [] | join(" ")' "$prd_file")
471
+ if ! echo "$criteria" | grep -qiE "(hash|bcrypt|sanitiz|inject|rate.?limit)"; then
472
+ issues+="$story_id: missing security criteria, "
473
+ fi
474
+ fi
475
+
476
+ # Check 5: List endpoints need scale criteria
477
+ if echo "$story_title" | grep -qiE "(list|get all|fetch all|index)"; then
478
+ local criteria
479
+ criteria=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .acceptanceCriteria // [] | join(" ")' "$prd_file")
480
+ if ! echo "$criteria" | grep -qiE "(pagina|limit|page=|per.?page)"; then
481
+ issues+="$story_id: missing pagination criteria, "
482
+ fi
483
+ fi
484
+
485
+ # Check 6: Migration stories need prerequisites
486
+ local story_files
487
+ story_files=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | (.files.create // []) + (.files.modify // []) | join(" ")' "$prd_file")
488
+ if echo "$story_files" | grep -qiE "(alembic/versions|migrations/|\.migration\.|models\.py|models/|schema\.)"; then
489
+ local has_prereq
490
+ has_prereq=$(jq -r --arg id "$story_id" '.stories[] | select(.id==$id) | .prerequisites // [] | length' "$prd_file")
491
+ if [[ "$has_prereq" == "0" ]]; then
492
+ issues+="$story_id: migration needs prerequisites, "
493
+ fi
494
+ fi
495
+ done <<< "$story_ids"
496
+
497
+ echo "$issues"
498
+ }