agentic-loop 1.0.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.
Files changed (162) hide show
  1. package/.claude/commands/explain.md +114 -0
  2. package/.claude/commands/idea.md +398 -0
  3. package/.claude/commands/my-dna.md +122 -0
  4. package/.claude/commands/prd.md +286 -0
  5. package/.claude/commands/review.md +167 -0
  6. package/.claude/commands/sign.md +32 -0
  7. package/.claude/commands/styleguide.md +450 -0
  8. package/.claude/commands/tour.md +301 -0
  9. package/.claude/commands/vibe-check.md +116 -0
  10. package/.claude/commands/vibe-help.md +47 -0
  11. package/.claude/commands/vibe-list.md +203 -0
  12. package/.pre-commit-hooks.yaml +102 -0
  13. package/LICENSE +21 -0
  14. package/README.md +238 -0
  15. package/bin/agentic-loop.sh +24 -0
  16. package/bin/postinstall.sh +29 -0
  17. package/bin/ralph.sh +171 -0
  18. package/bin/vibe-check.js +19 -0
  19. package/dist/checks/check-any-types.d.ts +6 -0
  20. package/dist/checks/check-any-types.d.ts.map +1 -0
  21. package/dist/checks/check-any-types.js +73 -0
  22. package/dist/checks/check-any-types.js.map +1 -0
  23. package/dist/checks/check-commented-code.d.ts +6 -0
  24. package/dist/checks/check-commented-code.d.ts.map +1 -0
  25. package/dist/checks/check-commented-code.js +81 -0
  26. package/dist/checks/check-commented-code.js.map +1 -0
  27. package/dist/checks/check-console-error.d.ts +6 -0
  28. package/dist/checks/check-console-error.d.ts.map +1 -0
  29. package/dist/checks/check-console-error.js +41 -0
  30. package/dist/checks/check-console-error.js.map +1 -0
  31. package/dist/checks/check-debug-statements.d.ts +6 -0
  32. package/dist/checks/check-debug-statements.d.ts.map +1 -0
  33. package/dist/checks/check-debug-statements.js +120 -0
  34. package/dist/checks/check-debug-statements.js.map +1 -0
  35. package/dist/checks/check-deep-nesting.d.ts +6 -0
  36. package/dist/checks/check-deep-nesting.d.ts.map +1 -0
  37. package/dist/checks/check-deep-nesting.js +116 -0
  38. package/dist/checks/check-deep-nesting.js.map +1 -0
  39. package/dist/checks/check-docker-platform.d.ts +6 -0
  40. package/dist/checks/check-docker-platform.d.ts.map +1 -0
  41. package/dist/checks/check-docker-platform.js +42 -0
  42. package/dist/checks/check-docker-platform.js.map +1 -0
  43. package/dist/checks/check-dry-violations.d.ts +6 -0
  44. package/dist/checks/check-dry-violations.d.ts.map +1 -0
  45. package/dist/checks/check-dry-violations.js +124 -0
  46. package/dist/checks/check-dry-violations.js.map +1 -0
  47. package/dist/checks/check-empty-catch.d.ts +6 -0
  48. package/dist/checks/check-empty-catch.d.ts.map +1 -0
  49. package/dist/checks/check-empty-catch.js +111 -0
  50. package/dist/checks/check-empty-catch.js.map +1 -0
  51. package/dist/checks/check-function-length.d.ts +6 -0
  52. package/dist/checks/check-function-length.d.ts.map +1 -0
  53. package/dist/checks/check-function-length.js +152 -0
  54. package/dist/checks/check-function-length.js.map +1 -0
  55. package/dist/checks/check-hardcoded-ai-models.d.ts +10 -0
  56. package/dist/checks/check-hardcoded-ai-models.d.ts.map +1 -0
  57. package/dist/checks/check-hardcoded-ai-models.js +102 -0
  58. package/dist/checks/check-hardcoded-ai-models.js.map +1 -0
  59. package/dist/checks/check-hardcoded-urls.d.ts +6 -0
  60. package/dist/checks/check-hardcoded-urls.d.ts.map +1 -0
  61. package/dist/checks/check-hardcoded-urls.js +124 -0
  62. package/dist/checks/check-hardcoded-urls.js.map +1 -0
  63. package/dist/checks/check-magic-numbers.d.ts +6 -0
  64. package/dist/checks/check-magic-numbers.d.ts.map +1 -0
  65. package/dist/checks/check-magic-numbers.js +116 -0
  66. package/dist/checks/check-magic-numbers.js.map +1 -0
  67. package/dist/checks/check-secrets.d.ts +6 -0
  68. package/dist/checks/check-secrets.d.ts.map +1 -0
  69. package/dist/checks/check-secrets.js +138 -0
  70. package/dist/checks/check-secrets.js.map +1 -0
  71. package/dist/checks/check-snake-case-ts.d.ts +6 -0
  72. package/dist/checks/check-snake-case-ts.d.ts.map +1 -0
  73. package/dist/checks/check-snake-case-ts.js +78 -0
  74. package/dist/checks/check-snake-case-ts.js.map +1 -0
  75. package/dist/checks/check-todo-fixme.d.ts +6 -0
  76. package/dist/checks/check-todo-fixme.d.ts.map +1 -0
  77. package/dist/checks/check-todo-fixme.js +41 -0
  78. package/dist/checks/check-todo-fixme.js.map +1 -0
  79. package/dist/checks/check-unsafe-html.d.ts +6 -0
  80. package/dist/checks/check-unsafe-html.d.ts.map +1 -0
  81. package/dist/checks/check-unsafe-html.js +101 -0
  82. package/dist/checks/check-unsafe-html.js.map +1 -0
  83. package/dist/checks/index.d.ts +30 -0
  84. package/dist/checks/index.d.ts.map +1 -0
  85. package/dist/checks/index.js +57 -0
  86. package/dist/checks/index.js.map +1 -0
  87. package/dist/cli.d.ts +13 -0
  88. package/dist/cli.d.ts.map +1 -0
  89. package/dist/cli.js +208 -0
  90. package/dist/cli.js.map +1 -0
  91. package/dist/index.d.ts +9 -0
  92. package/dist/index.d.ts.map +1 -0
  93. package/dist/index.js +10 -0
  94. package/dist/index.js.map +1 -0
  95. package/dist/utils/file-reader.d.ts +24 -0
  96. package/dist/utils/file-reader.d.ts.map +1 -0
  97. package/dist/utils/file-reader.js +146 -0
  98. package/dist/utils/file-reader.js.map +1 -0
  99. package/dist/utils/patterns.d.ts +27 -0
  100. package/dist/utils/patterns.d.ts.map +1 -0
  101. package/dist/utils/patterns.js +84 -0
  102. package/dist/utils/patterns.js.map +1 -0
  103. package/dist/utils/reporters.d.ts +21 -0
  104. package/dist/utils/reporters.d.ts.map +1 -0
  105. package/dist/utils/reporters.js +115 -0
  106. package/dist/utils/reporters.js.map +1 -0
  107. package/dist/utils/types.d.ts +71 -0
  108. package/dist/utils/types.d.ts.map +1 -0
  109. package/dist/utils/types.js +5 -0
  110. package/dist/utils/types.js.map +1 -0
  111. package/package.json +83 -0
  112. package/ralph/api.sh +216 -0
  113. package/ralph/backup.sh +838 -0
  114. package/ralph/browser-verify/README.md +135 -0
  115. package/ralph/browser-verify/verify.ts +450 -0
  116. package/ralph/checks/check-fastapi-responses.py +155 -0
  117. package/ralph/hooks/hooks-config.json +72 -0
  118. package/ralph/hooks/inject-context.sh +44 -0
  119. package/ralph/hooks/install.sh +207 -0
  120. package/ralph/hooks/log-tools.sh +45 -0
  121. package/ralph/hooks/protect-prd.sh +27 -0
  122. package/ralph/hooks/save-learnings.sh +36 -0
  123. package/ralph/hooks/warn-debug.sh +54 -0
  124. package/ralph/hooks/warn-empty-catch.sh +63 -0
  125. package/ralph/hooks/warn-secrets.sh +89 -0
  126. package/ralph/hooks/warn-urls.sh +77 -0
  127. package/ralph/init.sh +515 -0
  128. package/ralph/loop.sh +730 -0
  129. package/ralph/playwright.sh +238 -0
  130. package/ralph/prd.sh +295 -0
  131. package/ralph/setup/feature-tour.sh +155 -0
  132. package/ralph/setup/quick-setup.sh +239 -0
  133. package/ralph/setup/tutorial.sh +159 -0
  134. package/ralph/setup/ui.sh +136 -0
  135. package/ralph/setup.sh +401 -0
  136. package/ralph/signs.sh +150 -0
  137. package/ralph/utils.sh +682 -0
  138. package/ralph/verify/browser.sh +324 -0
  139. package/ralph/verify/lint.sh +363 -0
  140. package/ralph/verify/review.sh +152 -0
  141. package/ralph/verify/tests.sh +81 -0
  142. package/ralph/verify.sh +268 -0
  143. package/templates/PROMPT.md +235 -0
  144. package/templates/config/fullstack.json +86 -0
  145. package/templates/config/go.json +81 -0
  146. package/templates/config/minimal.json +76 -0
  147. package/templates/config/node.json +81 -0
  148. package/templates/config/python.json +81 -0
  149. package/templates/config/rust.json +81 -0
  150. package/templates/examples/CLAUDE-django.md +174 -0
  151. package/templates/examples/CLAUDE-fastapi.md +270 -0
  152. package/templates/examples/CLAUDE-fastmcp.md +352 -0
  153. package/templates/examples/CLAUDE-fullstack.md +256 -0
  154. package/templates/examples/CLAUDE-node.md +246 -0
  155. package/templates/examples/CLAUDE-react.md +138 -0
  156. package/templates/optional/cursorrules.template +147 -0
  157. package/templates/optional/eslint.config.js +34 -0
  158. package/templates/optional/lint-staged.config.js +34 -0
  159. package/templates/optional/ruff.toml +125 -0
  160. package/templates/optional/vibe-check.yml +116 -0
  161. package/templates/optional/vscode-settings.json +127 -0
  162. package/templates/signs.json +46 -0
package/ralph/utils.sh ADDED
@@ -0,0 +1,682 @@
1
+ #!/usr/bin/env bash
2
+ # shellcheck shell=bash
3
+ # utils.sh - Shared utility functions for ralph
4
+
5
+ # Constants - Output limits
6
+ readonly MAX_LOG_LINES=30
7
+ readonly MAX_PROGRESS_LINES=10
8
+ readonly MAX_GIT_STATUS_LINES=10
9
+ readonly MAX_OUTPUT_PREVIEW_LINES=20
10
+ readonly MAX_ERROR_PREVIEW_LINES=40
11
+ readonly MAX_LINT_ERROR_LINES=20
12
+ readonly MAX_PROGRESS_FILE_LINES=1000
13
+
14
+ # Constants - Timeouts (centralized to avoid magic numbers)
15
+ readonly ITERATION_DELAY_SECONDS=2
16
+ readonly DEFAULT_TIMEOUT_SECONDS=600
17
+ readonly DEFAULT_MAX_ITERATIONS=20
18
+ readonly CODE_REVIEW_TIMEOUT_SECONDS=120
19
+ readonly BROWSER_TIMEOUT_SECONDS=60
20
+ readonly BROWSER_PAGE_TIMEOUT_MS=30000
21
+ readonly CURL_TIMEOUT_SECONDS=10
22
+
23
+ # Common project directories (avoid duplication across files)
24
+ readonly FRONTEND_DIRS=("apps/web" "frontend" "client" "web")
25
+ readonly BACKEND_DIRS=("apps/api" "api" "backend" "server")
26
+
27
+ # Track temp files for safe cleanup
28
+ RALPH_TEMP_FILES=()
29
+
30
+ # Colors for output
31
+ RED='\033[0;31m'
32
+ GREEN='\033[0;32m'
33
+ YELLOW='\033[1;33m'
34
+ BLUE='\033[0;34m'
35
+ NC='\033[0m' # No Color
36
+
37
+ # Get existing frontend directories in this project
38
+ get_frontend_dirs() {
39
+ local dirs=()
40
+ for d in "${FRONTEND_DIRS[@]}"; do
41
+ [[ -d "$d" ]] && dirs+=("$d")
42
+ done
43
+ [[ ${#dirs[@]} -gt 0 ]] && printf '%s\n' "${dirs[@]}"
44
+ }
45
+
46
+ # Get existing backend directories in this project
47
+ get_backend_dirs() {
48
+ local dirs=()
49
+ for d in "${BACKEND_DIRS[@]}"; do
50
+ [[ -d "$d" ]] && dirs+=("$d")
51
+ done
52
+ [[ ${#dirs[@]} -gt 0 ]] && printf '%s\n' "${dirs[@]}"
53
+ }
54
+
55
+ # Progress bar for story display
56
+ progress_bar() {
57
+ local current=$1 total=$2 width=${3:-6}
58
+ local filled=$((current * width / total))
59
+ local empty=$((width - filled))
60
+ printf '%*s' "$filled" '' | tr ' ' '█'
61
+ printf '%*s' "$empty" '' | tr ' ' '░'
62
+ }
63
+
64
+ # Emoji for story type
65
+ type_emoji() {
66
+ case "$1" in
67
+ frontend) echo "📦" ;;
68
+ backend) echo "⚙️" ;;
69
+ testing) echo "🧪" ;;
70
+ *) echo "📝" ;;
71
+ esac
72
+ }
73
+
74
+ # Print colored output
75
+ print_error() { echo -e "${RED}Error: $1${NC}" >&2; }
76
+ print_success() { echo -e "${GREEN}$1${NC}"; }
77
+ print_warning() { echo -e "${YELLOW}$1${NC}"; }
78
+ print_info() { echo -e "${BLUE}$1${NC}"; }
79
+
80
+ # Require a file to exist
81
+ require_file() {
82
+ local file="$1"
83
+ local msg="${2:-File not found: $file}"
84
+ if [[ ! -f "$file" ]]; then
85
+ print_error "$msg"
86
+ exit 1
87
+ fi
88
+ }
89
+
90
+ # Require a directory to exist
91
+ require_dir() {
92
+ local dir="$1"
93
+ local msg="${2:-Directory not found: $dir}"
94
+ if [[ ! -d "$dir" ]]; then
95
+ print_error "$msg"
96
+ exit 1
97
+ fi
98
+ }
99
+
100
+ # Require a command to be available
101
+ require_command() {
102
+ local cmd="$1"
103
+ local msg="${2:-Command not found: $cmd}"
104
+ if ! command -v "$cmd" &>/dev/null; then
105
+ print_error "$msg"
106
+ exit 1
107
+ fi
108
+ }
109
+
110
+ # Check all required dependencies
111
+ check_dependencies() {
112
+ local missing=()
113
+
114
+ # Required
115
+ command -v jq &>/dev/null || missing+=("jq (brew install jq)")
116
+ command -v claude &>/dev/null || missing+=("claude CLI (npm install -g @anthropic-ai/claude-code)")
117
+
118
+ # Optional but recommended
119
+ if ! command -v git &>/dev/null; then
120
+ print_warning "Warning: git not found, auto-commit disabled"
121
+ fi
122
+
123
+ if [[ ${#missing[@]} -gt 0 ]]; then
124
+ print_error "Missing required dependencies:"
125
+ printf ' - %s\n' "${missing[@]}"
126
+ exit 1
127
+ fi
128
+ }
129
+
130
+ # Log progress to progress.txt (with rotation to prevent unbounded growth)
131
+ log_progress() {
132
+ local story="$1"
133
+ local status="$2"
134
+ local msg="${3:-}"
135
+ local timestamp
136
+ local progress_file="$RALPH_DIR/progress.txt"
137
+
138
+ timestamp=$(date -Iseconds 2>/dev/null || date +%Y-%m-%dT%H:%M:%S)
139
+ echo "[$timestamp] $status $story $msg" >> "$progress_file"
140
+
141
+ # Rotate if file exceeds max lines (keep last half)
142
+ if [[ -f "$progress_file" ]]; then
143
+ local line_count
144
+ line_count=$(wc -l < "$progress_file" 2>/dev/null || echo "0")
145
+ if [[ "$line_count" -gt "$MAX_PROGRESS_FILE_LINES" ]]; then
146
+ local keep_lines=$((MAX_PROGRESS_FILE_LINES / 2))
147
+ tail -"$keep_lines" "$progress_file" > "$progress_file.tmp" && mv "$progress_file.tmp" "$progress_file"
148
+ fi
149
+ fi
150
+ }
151
+
152
+ # Get a value from config.json with a default
153
+ get_config() {
154
+ local key="$1"
155
+ local default="$2"
156
+ local config="$RALPH_DIR/config.json"
157
+
158
+ if [[ -f "$config" ]]; then
159
+ local value
160
+ value=$(jq -r "$key // empty" "$config" 2>/dev/null)
161
+ if [[ -n "$value" && "$value" != "null" ]]; then
162
+ echo "$value"
163
+ return
164
+ fi
165
+ fi
166
+ echo "$default"
167
+ }
168
+
169
+ # Cross-platform timeout (macOS needs gtimeout from coreutils)
170
+ run_with_timeout() {
171
+ local seconds="$1"
172
+ shift
173
+
174
+ if command -v timeout &>/dev/null; then
175
+ timeout "$seconds" "$@"
176
+ elif command -v gtimeout &>/dev/null; then
177
+ gtimeout "$seconds" "$@"
178
+ else
179
+ # Fallback: just run without timeout (safe - Claude sessions complete on their own)
180
+ "$@"
181
+ fi
182
+ }
183
+
184
+ # Safely update JSON file atomically
185
+ # Usage: update_json <file> [jq args...] <filter>
186
+ # Example: update_json file.json --arg id "TASK-001" '.stories[] | select(.id==$id)'
187
+ update_json() {
188
+ local file="$1"
189
+ shift
190
+ local tmpfile lockdir
191
+ tmpfile=$(mktemp)
192
+ lockdir="${file}.lock"
193
+
194
+ # Acquire lock (mkdir is atomic)
195
+ local attempts=0
196
+ while ! mkdir "$lockdir" 2>/dev/null; do
197
+ ((attempts++))
198
+ if [[ $attempts -gt 50 ]]; then
199
+ print_error "Could not acquire lock on $file"
200
+ rm -f "$tmpfile"
201
+ return 1
202
+ fi
203
+ sleep 0.1
204
+ done
205
+
206
+ # All remaining args go to jq (supports --arg, --argjson, etc.)
207
+ local result=0
208
+ if jq "$@" "$file" > "$tmpfile" 2>/dev/null; then
209
+ mv "$tmpfile" "$file"
210
+ else
211
+ rm -f "$tmpfile"
212
+ result=1
213
+ fi
214
+
215
+ # Release lock
216
+ rmdir "$lockdir" 2>/dev/null
217
+ return $result
218
+ }
219
+
220
+ # Create a temp file and track it for cleanup
221
+ create_temp_file() {
222
+ local suffix="${1:-.tmp}"
223
+ local tmpfile
224
+ # macOS mktemp doesn't support suffixes, so create then rename
225
+ tmpfile=$(mktemp 2>&1) || {
226
+ print_error "mktemp failed: $tmpfile"
227
+ return 1
228
+ }
229
+ if [[ "$suffix" != ".tmp" && -n "$suffix" ]]; then
230
+ if ! mv "$tmpfile" "${tmpfile}${suffix}" 2>/dev/null; then
231
+ print_error "Failed to rename temp file"
232
+ rm -f "$tmpfile"
233
+ return 1
234
+ fi
235
+ tmpfile="${tmpfile}${suffix}"
236
+ fi
237
+ RALPH_TEMP_FILES+=("$tmpfile")
238
+ echo "$tmpfile"
239
+ }
240
+
241
+ # Clean up only tracked temp files on exit
242
+ cleanup() {
243
+ if [[ ${#RALPH_TEMP_FILES[@]} -gt 0 ]]; then
244
+ for f in "${RALPH_TEMP_FILES[@]}"; do
245
+ rm -f "$f" 2>/dev/null
246
+ done
247
+ fi
248
+ }
249
+
250
+ # Set up trap for cleanup
251
+ trap cleanup EXIT
252
+
253
+ # Validate a command doesn't contain dangerous patterns
254
+ # Returns 0 if safe, 1 if dangerous
255
+ # Note: This is defense-in-depth. Commands come from user config, so user must trust their own config.
256
+ validate_command() {
257
+ local cmd="$1"
258
+
259
+ # Block obviously dangerous patterns (defense-in-depth, not security boundary)
260
+ local dangerous_patterns=(
261
+ # Destructive file operations
262
+ 'rm[[:space:]]+-rf[[:space:]]+/' # rm -rf /
263
+ 'rm[[:space:]]+-rf[[:space:]]+~' # rm -rf ~ (home dir)
264
+ 'rm[[:space:]]+-rf[[:space:]]+\*' # rm -rf *
265
+ 'rm[[:space:]]+-rf[[:space:]]+\.\.' # rm -rf ..
266
+ 'rm[[:space:]].*--no-preserve-root' # rm with --no-preserve-root
267
+ # Remote code execution
268
+ 'curl.*\|.*bash' # curl | bash
269
+ 'curl.*\|.*sh[[:space:]]*$' # curl | sh
270
+ 'wget.*\|.*bash' # wget | bash
271
+ 'wget.*\|.*sh[[:space:]]*$' # wget | sh
272
+ 'curl.*>[[:space:]]*/tmp/.*&&.*bash' # curl > /tmp/x && bash
273
+ # Code injection
274
+ '\$\([^)]*eval' # $(eval ...)
275
+ 'eval[[:space:]]+\$' # eval $var
276
+ 'eval[[:space:]]+["\x27]' # eval "..." or eval '...'
277
+ # System damage
278
+ '>[[:space:]]*/dev/sd' # write to disk devices
279
+ '>[[:space:]]*/dev/nvme' # write to nvme devices
280
+ 'mkfs\.' # format filesystems
281
+ 'dd[[:space:]]+if=' # dd commands
282
+ ':(){:|:&};:' # fork bomb
283
+ # Credential theft
284
+ 'cat.*\.ssh/id_' # read SSH keys
285
+ 'cat.*/etc/shadow' # read shadow file
286
+ 'cat.*\.aws/credentials' # read AWS creds
287
+ 'cat.*\.env' # read env files (often has secrets)
288
+ )
289
+
290
+ for pattern in "${dangerous_patterns[@]}"; do
291
+ if [[ "$cmd" =~ $pattern ]]; then
292
+ print_error "Command blocked (dangerous pattern): $cmd"
293
+ log_progress "BLOCKED dangerous command: $cmd"
294
+ return 1
295
+ fi
296
+ done
297
+
298
+ return 0
299
+ }
300
+
301
+ # Validate a URL is safe (http/https only, no internal IPs in production)
302
+ validate_url() {
303
+ local url="$1"
304
+
305
+ # Must start with http:// or https://
306
+ if [[ ! "$url" =~ ^https?:// ]]; then
307
+ print_error "Invalid URL scheme (must be http or https): $url"
308
+ return 1
309
+ fi
310
+
311
+ # Block file:// and other dangerous schemes
312
+ if [[ "$url" =~ ^(file|ftp|data|javascript): ]]; then
313
+ print_error "Dangerous URL scheme: $url"
314
+ return 1
315
+ fi
316
+
317
+ return 0
318
+ }
319
+
320
+ # Safely execute a command (validates first, uses bash -c instead of eval)
321
+ safe_exec() {
322
+ local cmd="$1"
323
+ local log_file="${2:-/dev/null}"
324
+
325
+ # Validate command first
326
+ if ! validate_command "$cmd"; then
327
+ return 1
328
+ fi
329
+
330
+ # Execute with bash -c instead of eval
331
+ bash -c "$cmd" > "$log_file" 2>&1
332
+ }
333
+
334
+ # Set up or show notification config
335
+ ralph_notify() {
336
+ local config_dir="$HOME/.config/ralph"
337
+ local config_file="$config_dir/notify"
338
+
339
+ if [[ $# -eq 0 ]]; then
340
+ # Show current config
341
+ if [[ -f "$config_file" ]]; then
342
+ echo "Notification config (~/.config/ralph/notify):"
343
+ cat "$config_file"
344
+ else
345
+ echo "No notification configured."
346
+ echo ""
347
+ echo "To set up iMessage notifications (macOS):"
348
+ echo " npx ralph notify +15551234567"
349
+ echo ""
350
+ echo "Ralph will text you when the loop finishes."
351
+ fi
352
+ return 0
353
+ fi
354
+
355
+ local phone="$1"
356
+
357
+ # Validate phone format (basic check)
358
+ if [[ ! "$phone" =~ ^\+?[0-9]{10,15}$ ]]; then
359
+ print_error "Invalid phone number format. Use: +15551234567"
360
+ return 1
361
+ fi
362
+
363
+ # Create config directory and file
364
+ mkdir -p "$config_dir"
365
+ echo "phone=$phone" > "$config_file"
366
+
367
+ print_success "Notification configured!"
368
+ echo "Phone: $phone"
369
+ echo ""
370
+ echo "Ralph will send iMessage when the loop finishes."
371
+ echo "(Requires macOS with Messages signed into your Apple ID)"
372
+
373
+ # Test notification
374
+ echo ""
375
+ read -p "Send a test message? [y/N] " -n 1 -r
376
+ echo ""
377
+ if [[ $REPLY =~ ^[Yy]$ ]]; then
378
+ send_notification "🧪 Test from Ralph - notifications are working!"
379
+ fi
380
+ }
381
+
382
+ # Send notification via iMessage (macOS only)
383
+ # Reads phone from ~/.config/ralph/notify (global, one-time setup)
384
+ send_notification() {
385
+ local message="$1"
386
+ local config_file="$HOME/.config/ralph/notify"
387
+
388
+ # No config file, skip silently
389
+ if [[ ! -f "$config_file" ]]; then
390
+ return 0
391
+ fi
392
+
393
+ local phone=""
394
+ phone=$(grep -E '^phone=' "$config_file" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" | xargs)
395
+
396
+ # No phone configured, skip silently
397
+ if [[ -z "$phone" ]]; then
398
+ return 0
399
+ fi
400
+
401
+ # macOS only - use iMessage
402
+ if [[ "$(uname)" == "Darwin" ]]; then
403
+ # Escape message for AppleScript (replace backslashes and quotes)
404
+ local escaped_message="${message//\\/\\\\}"
405
+ escaped_message="${escaped_message//\"/\\\"}"
406
+ osascript -e "tell application \"Messages\" to send \"$escaped_message\" to buddy \"$phone\"" 2>/dev/null || {
407
+ print_warning "Failed to send iMessage notification (is Messages app signed in?)"
408
+ return 1
409
+ }
410
+ print_info "Notification sent to $phone"
411
+ else
412
+ print_warning "Notifications only supported on macOS"
413
+ fi
414
+
415
+ return 0
416
+ }
417
+
418
+ # Validate PRD structure
419
+ # Returns 0 if valid, 1 if invalid with helpful error messages
420
+ validate_prd() {
421
+ local prd_file="$1"
422
+
423
+ # Check file exists
424
+ if [[ ! -f "$prd_file" ]]; then
425
+ print_error "PRD file not found: $prd_file"
426
+ return 1
427
+ fi
428
+
429
+ # Check valid JSON
430
+ if ! jq -e . "$prd_file" >/dev/null 2>&1; then
431
+ print_error "prd.json is not valid JSON."
432
+ echo ""
433
+ echo "Fix it manually or regenerate with:"
434
+ echo " /idea 'your feature'"
435
+ echo ""
436
+ return 1
437
+ fi
438
+
439
+ # Check feature.name is set
440
+ local feature_name
441
+ feature_name=$(jq -r '.feature.name // empty' "$prd_file" 2>/dev/null)
442
+ if [[ -z "$feature_name" || "$feature_name" == "null" ]]; then
443
+ print_error "prd.json is missing .feature.name"
444
+ echo ""
445
+ echo "Add a feature name to your PRD or regenerate with:"
446
+ echo " /idea 'your feature'"
447
+ echo ""
448
+ return 1
449
+ fi
450
+
451
+ # Check for stories array
452
+ if ! jq -e '.stories' "$prd_file" >/dev/null 2>&1; then
453
+ print_error "prd.json is missing 'stories' array."
454
+ echo ""
455
+ echo "Regenerate with: /idea 'your feature'"
456
+ echo ""
457
+ return 1
458
+ fi
459
+
460
+ # Check stories is not empty
461
+ local story_count
462
+ story_count=$(jq '.stories | length' "$prd_file" 2>/dev/null || echo "0")
463
+ if [[ "$story_count" == "0" ]]; then
464
+ print_error "prd.json has no stories."
465
+ echo ""
466
+ echo "Regenerate with: /idea 'your feature'"
467
+ echo ""
468
+ return 1
469
+ fi
470
+
471
+ # Check each story has required fields
472
+ local invalid_stories
473
+ invalid_stories=$(jq -r '.stories[] | select(.id == null or .id == "" or .title == null or .title == "") | .id // "unnamed"' "$prd_file" 2>/dev/null)
474
+ if [[ -n "$invalid_stories" ]]; then
475
+ print_error "Some stories are missing required fields (id, title):"
476
+ echo "$invalid_stories" | head -5
477
+ echo ""
478
+ echo "Fix the PRD or regenerate with: /idea 'your feature'"
479
+ echo ""
480
+ return 1
481
+ fi
482
+
483
+ # Check stories have passes field (initialize if missing)
484
+ local missing_passes
485
+ missing_passes=$(jq '[.stories[] | select(.passes == null)] | length' "$prd_file" 2>/dev/null || echo "0")
486
+ if [[ "$missing_passes" != "0" ]]; then
487
+ print_info "Initializing $missing_passes stories with passes=false..."
488
+ update_json "$prd_file" '(.stories[] | select(.passes == null) | .passes) = false'
489
+ fi
490
+
491
+ # Check feature name exists
492
+ local feature_name
493
+ feature_name=$(jq -r '.feature.name // empty' "$prd_file" 2>/dev/null)
494
+ if [[ -z "$feature_name" ]]; then
495
+ print_warning "PRD is missing feature name (will show as 'unnamed')"
496
+ fi
497
+
498
+ return 0
499
+ }
500
+
501
+ # Detect Python runner (uv, poetry, pipenv, or plain python)
502
+ detect_python_runner() {
503
+ local search_dir="${1:-.}"
504
+
505
+ # Check for uv (uv.lock or pyproject.toml with uv)
506
+ if [[ -f "$search_dir/uv.lock" ]]; then
507
+ echo "uv run"
508
+ return 0
509
+ fi
510
+
511
+ # Check for poetry
512
+ if [[ -f "$search_dir/poetry.lock" ]]; then
513
+ echo "poetry run"
514
+ return 0
515
+ fi
516
+
517
+ # Check for pipenv
518
+ if [[ -f "$search_dir/Pipfile.lock" ]]; then
519
+ echo "pipenv run"
520
+ return 0
521
+ fi
522
+
523
+ # Default to plain command (assumes activated venv or global)
524
+ echo ""
525
+ return 0
526
+ }
527
+
528
+ # Auto-detect migration tool and return the command
529
+ detect_migration_tool() {
530
+ local search_dir="${1:-.}"
531
+ local py_runner
532
+ py_runner=$(detect_python_runner "$search_dir")
533
+
534
+ # Alembic (Python/FastAPI/SQLAlchemy)
535
+ if [[ -f "$search_dir/alembic.ini" ]] || [[ -d "$search_dir/alembic" ]]; then
536
+ echo "cd $search_dir && ${py_runner}${py_runner:+ }alembic upgrade head"
537
+ return 0
538
+ fi
539
+
540
+ # Prisma (Node.js)
541
+ if [[ -d "$search_dir/prisma/migrations" ]] || [[ -f "$search_dir/prisma/schema.prisma" ]]; then
542
+ echo "cd $search_dir && npx prisma migrate deploy"
543
+ return 0
544
+ fi
545
+
546
+ # Django
547
+ if [[ -f "$search_dir/manage.py" ]] && [[ -d "$search_dir" ]] && find "$search_dir" -type d -name "migrations" -print -quit | grep -q .; then
548
+ echo "cd $search_dir && ${py_runner}${py_runner:+ }python manage.py migrate"
549
+ return 0
550
+ fi
551
+
552
+ # Sequelize (Node.js)
553
+ if [[ -f "$search_dir/.sequelizerc" ]]; then
554
+ echo "cd $search_dir && npx sequelize-cli db:migrate"
555
+ return 0
556
+ fi
557
+
558
+ # TypeORM (Node.js)
559
+ if [[ -f "$search_dir/ormconfig.json" ]] || grep -q '"typeorm"' "$search_dir/package.json" 2>/dev/null; then
560
+ echo "cd $search_dir && npx typeorm migration:run"
561
+ return 0
562
+ fi
563
+
564
+ # Knex (Node.js)
565
+ if [[ -f "$search_dir/knexfile.js" ]] || [[ -f "$search_dir/knexfile.ts" ]]; then
566
+ echo "cd $search_dir && npx knex migrate:latest"
567
+ return 0
568
+ fi
569
+
570
+ return 1
571
+ }
572
+
573
+ # Find all migration tools in project (searches common app directories)
574
+ find_all_migration_tools() {
575
+ local tools=()
576
+
577
+ # Check root
578
+ local root_tool
579
+ if root_tool=$(detect_migration_tool "."); then
580
+ tools+=("$root_tool")
581
+ fi
582
+
583
+ # Check common app directories
584
+ for dir in apps/* packages/* services/* api backend server; do
585
+ if [[ -d "$dir" ]]; then
586
+ local tool
587
+ if tool=$(detect_migration_tool "$dir"); then
588
+ tools+=("$tool")
589
+ fi
590
+ fi
591
+ done
592
+
593
+ # Return unique tools
594
+ printf '%s\n' "${tools[@]}" | sort -u
595
+ }
596
+
597
+ # Ensure database migrations are applied before verification
598
+ # Migration commands are idempotent - they no-op if nothing pending
599
+ run_migrations_if_needed() {
600
+ local pre_sha="$1" # unused now, kept for API compatibility
601
+ local config="$RALPH_DIR/config.json"
602
+
603
+ local migrate_cmd=""
604
+
605
+ # Try config first
606
+ if [[ -f "$config" ]]; then
607
+ migrate_cmd=$(jq -r '.migrations.command // empty' "$config" 2>/dev/null)
608
+ fi
609
+
610
+ # Auto-detect if not configured
611
+ if [[ -z "$migrate_cmd" ]]; then
612
+ local detected_tools
613
+ detected_tools=$(find_all_migration_tools)
614
+
615
+ if [[ -z "$detected_tools" ]]; then
616
+ return 0 # No migrations to run
617
+ fi
618
+
619
+ # Run all detected migration tools
620
+ local failed=0
621
+ while IFS= read -r tool_cmd; do
622
+ [[ -z "$tool_cmd" ]] && continue
623
+ echo -n " Migrations (auto-detected)... "
624
+
625
+ local log_file
626
+ log_file=$(mktemp)
627
+
628
+ if safe_exec "$tool_cmd" "$log_file"; then
629
+ if grep -qiE "applying|migrating|running|upgrade" "$log_file" 2>/dev/null; then
630
+ print_success "applied"
631
+ else
632
+ echo "up to date"
633
+ fi
634
+ else
635
+ print_error "failed"
636
+ echo " Command: $tool_cmd"
637
+ tail -10 "$log_file" | sed 's/^/ /'
638
+ # Save failure context for Claude
639
+ {
640
+ echo "Migration command: $tool_cmd"
641
+ echo ""
642
+ cat "$log_file"
643
+ } > "$RALPH_DIR/last_migration_failure.log"
644
+ failed=1
645
+ fi
646
+ rm -f "$log_file"
647
+ done <<< "$detected_tools"
648
+
649
+ return $failed
650
+ fi
651
+
652
+ # Always run migrations - commands are idempotent (no-op if nothing pending)
653
+ # This ensures DB schema is always in sync before tests run
654
+ echo -n " Ensuring migrations applied... "
655
+
656
+ local log_file
657
+ log_file=$(mktemp)
658
+
659
+ if safe_exec "$migrate_cmd" "$log_file"; then
660
+ # Check if any migrations were actually applied
661
+ if grep -qiE "applying|migrating|running|upgrade" "$log_file" 2>/dev/null; then
662
+ print_success "applied"
663
+ else
664
+ echo "up to date"
665
+ fi
666
+ rm -f "$log_file"
667
+ return 0
668
+ else
669
+ print_error "failed"
670
+ echo ""
671
+ echo " Migration error:"
672
+ tail -20 "$log_file" | sed 's/^/ /'
673
+ # Save failure context for Claude
674
+ {
675
+ echo "Migration command: $migrate_cmd"
676
+ echo ""
677
+ cat "$log_file"
678
+ } > "$RALPH_DIR/last_migration_failure.log"
679
+ rm -f "$log_file"
680
+ return 1
681
+ fi
682
+ }