@the-bearded-bear/claude-craft 8.26.0-next.2a54921 → 8.26.0-next.defc34b

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.
@@ -51,6 +51,12 @@ Führt Claude in einer kontinuierlichen Schleife aus, bis die Aufgabe abgeschlos
51
51
  - Maximale Iterationen, Timeouts, DoD-Kriterien festlegen
52
52
  - Hooks initialisieren, falls aktiviert
53
53
 
54
+ #### Isolierung von Sessions und parallelen Workern
55
+
56
+ - **Explizite Claude-Session**: Jede Ralph-Session legt ihre eigene Claude-Konversations-ID fest — `claude --session-id <uuid>` in der ersten Iteration, danach `claude --resume <uuid>`. Die UUID wird in `.ralph/sessions/<session>/claude_session_id` gespeichert, sodass `--continue=<session>` die richtige Konversation fortsetzt. Ralph verwendet `claude --continue` nicht mehr, das „die neueste Konversation im Verzeichnis“ fortsetzte (geteilt zwischen Workern oder sogar mit der interaktiven Session des Benutzers).
57
+ - **Ein Git-Worktree pro Worker** (paralleler Modus, `lib/parallel-manager.sh`): Jede Story läuft in einem eigenen Worktree `.ralph/parallel/worktrees/<session>` auf dem Branch `ralph/<session>`, mit ihrem Ralph-Zustand unter `.ralph/parallel/sessions/<session>/ralph/`. Am Ende des Laufs werden verbleibende Änderungen als `wip(ralph): <story>` auf diesem Branch committet, danach wird der Worktree entfernt (`git worktree remove`). Schlägt dieser Commit fehl, bleibt der Worktree erhalten (kein Datenverlust) und wird in `output.log` gemeldet. Die Arbeit wird durch Mergen der `ralph/*`-Branches übernommen.
58
+ - **Außerhalb eines Git-Repositorys**: kein Worktree; der Worker läuft im aktuellen Verzeichnis (eine Warnung wird ausgegeben), die Isolierung der Claude-Sessions bleibt bestehen.
59
+
54
60
  ### 2. Hauptschleife mit Dashboard
55
61
 
56
62
  ```
@@ -51,6 +51,12 @@ Execute Claude in a continuous loop until the task is complete or the Definition
51
51
  - Set max iterations, timeouts, DoD criteria
52
52
  - Initialize hooks if enabled
53
53
 
54
+ #### Session and parallel worker isolation
55
+
56
+ - **Explicit Claude session**: each Ralph session pins its own Claude conversation ID — `claude --session-id <uuid>` on the first iteration, then `claude --resume <uuid>`. The UUID is stored in `.ralph/sessions/<session>/claude_session_id`, so `--continue=<session>` resumes the right conversation. Ralph no longer uses `claude --continue`, which resumed "the most recent conversation in the directory" (shared between workers, or even with the user's interactive session).
57
+ - **One git worktree per worker** (parallel mode, `lib/parallel-manager.sh`): each story runs in a dedicated worktree `.ralph/parallel/worktrees/<session>` on branch `ralph/<session>`, with its Ralph state under `.ralph/parallel/sessions/<session>/ralph/`. At the end of the run, leftover changes are committed as `wip(ralph): <story>` on that branch, then the worktree is removed (`git worktree remove`). If that commit fails, the worktree is kept (no data loss) and reported in `output.log`. Collect the work by merging the `ralph/*` branches.
58
+ - **Outside a git repository**: no worktree; the worker runs in the current directory (a warning is printed), Claude session isolation still applies.
59
+
54
60
  ### 2. Main Loop with Dashboard
55
61
 
56
62
  ```
@@ -51,6 +51,12 @@ Ejecuta Claude en un bucle continuo hasta que la tarea esté completa o se cumpl
51
51
  - Establecer máximo de iteraciones, timeouts y criterios DoD
52
52
  - Inicializar hooks si están habilitados
53
53
 
54
+ #### Aislamiento de sesiones y workers paralelos
55
+
56
+ - **Sesión de Claude explícita**: cada sesión de Ralph fija su propio identificador de conversación de Claude — `claude --session-id <uuid>` en la primera iteración y luego `claude --resume <uuid>`. El UUID se guarda en `.ralph/sessions/<session>/claude_session_id`, de modo que `--continue=<session>` reanuda la conversación correcta. Ralph ya no usa `claude --continue`, que reanudaba «la conversación más reciente del directorio» (compartida entre workers, o incluso con la sesión interactiva del usuario).
57
+ - **Un worktree de git por worker** (modo paralelo, `lib/parallel-manager.sh`): cada historia se ejecuta en un worktree dedicado `.ralph/parallel/worktrees/<session>` sobre la rama `ralph/<session>`, con su estado de Ralph en `.ralph/parallel/sessions/<session>/ralph/`. Al final de la ejecución, los cambios pendientes se confirman como `wip(ralph): <story>` en esa rama y después se elimina el worktree (`git worktree remove`). Si ese commit falla, el worktree se conserva (sin pérdida de datos) y se indica en `output.log`. El trabajo se recupera fusionando las ramas `ralph/*`.
58
+ - **Fuera de un repositorio git**: sin worktree; el worker se ejecuta en el directorio actual (se muestra un aviso) y el aislamiento de sesiones de Claude se mantiene.
59
+
54
60
  ### 2. Bucle Principal con Dashboard
55
61
 
56
62
  ```
@@ -51,6 +51,12 @@ Exécuter Claude en boucle continue jusqu'à ce que la tâche soit terminée ou
51
51
  - Définir les itérations max, les timeouts, les critères DoD
52
52
  - Initialiser les hooks si activés
53
53
 
54
+ #### Isolation des sessions et des workers parallèles
55
+
56
+ - **Session Claude explicite** : chaque session Ralph fixe son propre identifiant de conversation Claude — `claude --session-id <uuid>` à la première itération, puis `claude --resume <uuid>`. L'UUID est stocké dans `.ralph/sessions/<session>/claude_session_id`, ce qui permet à `--continue=<session>` de reprendre la bonne conversation. Ralph n'utilise plus `claude --continue`, qui reprenait « la conversation la plus récente du répertoire » (partagée entre workers, voire avec une session interactive de l'utilisateur).
57
+ - **Worktree git par worker** (mode parallèle, `lib/parallel-manager.sh`) : chaque story s'exécute dans un worktree dédié `.ralph/parallel/worktrees/<session>` sur la branche `ralph/<session>`, avec son état Ralph sous `.ralph/parallel/sessions/<session>/ralph/`. En fin de run, les modifications restantes sont commitées en `wip(ralph): <story>` sur cette branche, puis le worktree est retiré (`git worktree remove`). Si ce commit échoue, le worktree est conservé (aucune perte) et signalé dans `output.log`. Le travail se récupère en fusionnant les branches `ralph/*`.
58
+ - **Hors dépôt git** : pas de worktree ; le worker s'exécute dans le répertoire courant (avertissement affiché), l'isolation des sessions Claude restant assurée.
59
+
54
60
  ### 2. Boucle principale avec Dashboard
55
61
 
56
62
  ```
@@ -51,6 +51,12 @@ Executa o Claude em loop contínuo até que a tarefa seja concluída ou os crit
51
51
  - Definir máximo de iterações, timeouts e critérios de DoD
52
52
  - Inicializar hooks se habilitados
53
53
 
54
+ #### Isolamento de sessões e workers paralelos
55
+
56
+ - **Sessão Claude explícita**: cada sessão Ralph fixa seu próprio identificador de conversa Claude — `claude --session-id <uuid>` na primeira iteração e depois `claude --resume <uuid>`. O UUID é armazenado em `.ralph/sessions/<session>/claude_session_id`, para que `--continue=<session>` retome a conversa correta. O Ralph não usa mais `claude --continue`, que retomava "a conversa mais recente do diretório" (compartilhada entre workers, ou até com a sessão interativa do usuário).
57
+ - **Um worktree git por worker** (modo paralelo, `lib/parallel-manager.sh`): cada história é executada em um worktree dedicado `.ralph/parallel/worktrees/<session>` na branch `ralph/<session>`, com seu estado Ralph em `.ralph/parallel/sessions/<session>/ralph/`. No fim da execução, as alterações restantes são commitadas como `wip(ralph): <story>` nessa branch e o worktree é removido (`git worktree remove`). Se esse commit falhar, o worktree é mantido (sem perda de dados) e sinalizado em `output.log`. O trabalho é recuperado fazendo merge das branches `ralph/*`.
58
+ - **Fora de um repositório git**: sem worktree; o worker é executado no diretório atual (um aviso é exibido) e o isolamento das sessões Claude continua garantido.
59
+
54
60
  ### 2. Loop Principal com Painel
55
61
 
56
62
  ```
@@ -207,12 +207,12 @@ list_checkpoints() {
207
207
 
208
208
  # Find commits matching the checkpoint pattern
209
209
  local commits
210
- commits=$(git log --oneline --grep="checkpoint: Ralph" 2>/dev/null | head -20)
210
+ commits=$(git log --oneline --grep="checkpoint: Ralph" 2>/dev/null | head -20) || true
211
211
 
212
212
  if [[ -n "$commits" ]]; then
213
213
  echo "$commits" | while read -r line; do
214
- local hash=$(echo "$line" | cut -d' ' -f1)
215
- local msg=$(echo "$line" | cut -d' ' -f2-)
214
+ local hash=$(echo "$line" | cut -d' ' -f1) || true
215
+ local msg=$(echo "$line" | cut -d' ' -f2-) || true
216
216
  echo "{\"hash\":\"$hash\",\"message\":\"$msg\"}"
217
217
  done | jq -s '.'
218
218
  else
@@ -30,35 +30,35 @@ CB_LEARNING_MIN_SAMPLES=5
30
30
  # =============================================================================
31
31
  # Each profile defines thresholds optimized for different task types
32
32
 
33
- declare -A CB_PROFILE_QUICK_FIX=(
33
+ declare -gA CB_PROFILE_QUICK_FIX=(
34
34
  ["no_changes"]=2
35
35
  ["errors"]=3
36
36
  ["max_iterations"]=10
37
37
  ["keywords"]="fix bug typo hotfix patch"
38
38
  )
39
39
 
40
- declare -A CB_PROFILE_SMALL_FEATURE=(
40
+ declare -gA CB_PROFILE_SMALL_FEATURE=(
41
41
  ["no_changes"]=3
42
42
  ["errors"]=4
43
43
  ["max_iterations"]=15
44
44
  ["keywords"]="add implement small simple"
45
45
  )
46
46
 
47
- declare -A CB_PROFILE_MEDIUM_FEATURE=(
47
+ declare -gA CB_PROFILE_MEDIUM_FEATURE=(
48
48
  ["no_changes"]=4
49
49
  ["errors"]=6
50
50
  ["max_iterations"]=25
51
51
  ["keywords"]="feature create with tests"
52
52
  )
53
53
 
54
- declare -A CB_PROFILE_LARGE_FEATURE=(
54
+ declare -gA CB_PROFILE_LARGE_FEATURE=(
55
55
  ["no_changes"]=5
56
56
  ["errors"]=8
57
57
  ["max_iterations"]=50
58
58
  ["keywords"]="refactor migrate complex large"
59
59
  )
60
60
 
61
- declare -A CB_PROFILE_EXPLORATION=(
61
+ declare -gA CB_PROFILE_EXPLORATION=(
62
62
  ["no_changes"]=10
63
63
  ["errors"]=15
64
64
  ["max_iterations"]=100
@@ -66,7 +66,7 @@ declare -A CB_PROFILE_EXPLORATION=(
66
66
  )
67
67
 
68
68
  # Autonomous profile for overnight/unattended runs
69
- declare -A CB_PROFILE_AUTONOMOUS=(
69
+ declare -gA CB_PROFILE_AUTONOMOUS=(
70
70
  ["no_changes"]=5
71
71
  ["errors"]=8
72
72
  ["max_iterations"]=50
@@ -162,7 +162,7 @@ init_circuit_breaker() {
162
162
  detect_task_profile() {
163
163
  local prompt="$1"
164
164
  local prompt_lower
165
- prompt_lower=$(echo "$prompt" | tr '[:upper:]' '[:lower:]')
165
+ prompt_lower=$(echo "$prompt" | tr '[:upper:]' '[:lower:]') || true
166
166
 
167
167
  # Check each profile's keywords
168
168
  local profiles=("quick_fix" "small_feature" "medium_feature" "large_feature" "exploration" "autonomous")
@@ -265,7 +265,7 @@ apply_learned_adjustments() {
265
265
  ' "$history_file" 2>/dev/null)
266
266
 
267
267
  local samples
268
- samples=$(echo "$profile_data" | jq -r '.samples // 0')
268
+ samples=$(echo "$profile_data" | jq -r '.samples // 0') || samples=0
269
269
 
270
270
  if [[ $samples -lt $CB_LEARNING_MIN_SAMPLES ]]; then
271
271
  print_verbose "Not enough samples for learning ($samples < $CB_LEARNING_MIN_SAMPLES)"
@@ -274,10 +274,10 @@ apply_learned_adjustments() {
274
274
 
275
275
  # Apply adjustments based on historical success rate
276
276
  local success_rate
277
- success_rate=$(echo "$profile_data" | jq -r '.success_rate // 0')
277
+ success_rate=$(echo "$profile_data" | jq -r '.success_rate // 0') || success_rate=0
278
278
 
279
279
  local avg_iterations
280
- avg_iterations=$(echo "$profile_data" | jq -r '.avg_iterations // 0')
280
+ avg_iterations=$(echo "$profile_data" | jq -r '.avg_iterations // 0') || avg_iterations=0
281
281
 
282
282
  # If success rate is low, increase thresholds
283
283
  if [[ $success_rate -lt 50 ]]; then
@@ -106,6 +106,17 @@ init_context_manager() {
106
106
  print_verbose " - Smart reconstruction: $CONTEXT_SMART_RECONSTRUCTION"
107
107
  }
108
108
 
109
+ # Print the first lines of `/context` for a Claude session UUID.
110
+ # Best effort: prints nothing on failure, always succeeds (set -e safe).
111
+ probe_context_usage() {
112
+ local claude_uuid="$1"
113
+ if command -v timeout &> /dev/null; then
114
+ timeout 10s "${CLAUDE_CMD[@]}" --resume "$claude_uuid" -p "/context" 2>/dev/null | head -5 || true
115
+ else
116
+ "${CLAUDE_CMD[@]}" --resume "$claude_uuid" -p "/context" 2>/dev/null | head -5 || true
117
+ fi
118
+ }
119
+
109
120
  # =============================================================================
110
121
  # Auto-Compact
111
122
  # =============================================================================
@@ -120,16 +131,21 @@ run_auto_compact() {
120
131
  return 1
121
132
  fi
122
133
 
134
+ # /context and /compact must target this Ralph session's Claude session,
135
+ # never "the most recent conversation in this directory" (--continue).
136
+ local claude_uuid
137
+ claude_uuid=$(get_claude_session_uuid "$session_id")
138
+ if [[ -z "$claude_uuid" ]]; then
139
+ log_session "$session_id" "WARN" "No Claude session yet, nothing to compact"
140
+ return 1
141
+ fi
142
+
123
143
  # Check if context is above minimum threshold before compacting
124
144
  if [[ "$CONTEXT_MIN_THRESHOLD" -gt 0 ]]; then
125
145
  local context_info
126
- if command -v timeout &> /dev/null; then
127
- context_info=$(timeout 10s "${CLAUDE_CMD[@]}" -p "/context" 2>/dev/null | head -5)
128
- else
129
- context_info=$("${CLAUDE_CMD[@]}" -p "/context" 2>/dev/null | head -5)
130
- fi
146
+ context_info=$(probe_context_usage "$claude_uuid")
131
147
  local current_usage
132
- current_usage=$(echo "$context_info" | grep -oP '\d+(?=%)' | head -1)
148
+ current_usage=$(echo "$context_info" | grep -oP '\d+(?=%)' | head -1 || true)
133
149
 
134
150
  if [[ -n "$current_usage" && "$current_usage" -lt "$CONTEXT_MIN_THRESHOLD" ]]; then
135
151
  log_session "$session_id" "INFO" "Context at ${current_usage}% - below minimum threshold (${CONTEXT_MIN_THRESHOLD}%), skipping compact"
@@ -145,8 +161,8 @@ run_auto_compact() {
145
161
 
146
162
  # Handle overflow strategy
147
163
  local overflow_result
148
- overflow_result=$(handle_max_compacts_reached "$session_id")
149
- local overflow_status=$?
164
+ local overflow_status=0
165
+ overflow_result=$(handle_max_compacts_reached "$session_id") || overflow_status=$?
150
166
 
151
167
  if [[ $overflow_status -eq 0 && -n "$overflow_result" ]]; then
152
168
  # New session ID returned - signal continuation
@@ -185,11 +201,10 @@ run_auto_compact() {
185
201
 
186
202
  # Execute /compact via Claude CLI
187
203
  local compact_output
188
- local compact_status
204
+ local compact_status=0
189
205
 
190
206
  if command -v timeout &> /dev/null; then
191
- compact_output=$(timeout 60s "${CLAUDE_CMD[@]}" --continue -p "/compact" 2>&1)
192
- compact_status=$?
207
+ compact_output=$(timeout 60s "${CLAUDE_CMD[@]}" --resume "$claude_uuid" -p "/compact" 2>&1) || compact_status=$?
193
208
 
194
209
  # Handle timeout specifically (exit code 124)
195
210
  if [[ $compact_status -eq 124 ]]; then
@@ -198,8 +213,7 @@ run_auto_compact() {
198
213
  return 1
199
214
  fi
200
215
  else
201
- compact_output=$("${CLAUDE_CMD[@]}" --continue -p "/compact" 2>&1)
202
- compact_status=$?
216
+ compact_output=$("${CLAUDE_CMD[@]}" --resume "$claude_uuid" -p "/compact" 2>&1) || compact_status=$?
203
217
  fi
204
218
 
205
219
  if [[ $compact_status -eq 0 ]]; then
@@ -359,10 +373,10 @@ create_continuation_session() {
359
373
  elif command -v xxd &>/dev/null; then
360
374
  random_suffix=$(head -c 4 /dev/urandom | xxd -p)
361
375
  elif command -v od &>/dev/null; then
362
- random_suffix=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n')
376
+ random_suffix=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n') || true
363
377
  else
364
378
  # Fallback: use $RANDOM (less entropy but portable)
365
- random_suffix=$(printf '%04x%04x' $RANDOM $RANDOM | head -c 8)
379
+ random_suffix=$(printf '%04x%04x' $RANDOM $RANDOM | head -c 8) || true
366
380
  fi
367
381
 
368
382
  local new_session_id="ralph-cont-${timestamp}-${random_suffix}"
@@ -404,18 +418,19 @@ check_preventive_compact() {
404
418
  return 1
405
419
  fi
406
420
 
421
+ # Nothing to measure before the first Claude call of this session.
422
+ local claude_uuid
423
+ claude_uuid=$(get_claude_session_uuid "$session_id")
424
+ [[ -z "$claude_uuid" ]] && return 1
425
+
407
426
  # Try to get context usage from Claude CLI
408
427
  # Note: This is a best-effort check, Claude may not always report usage
409
428
  local context_info
410
- if command -v timeout &> /dev/null; then
411
- context_info=$(timeout 10s "${CLAUDE_CMD[@]}" -p "/context" 2>/dev/null | head -5)
412
- else
413
- context_info=$("${CLAUDE_CMD[@]}" -p "/context" 2>/dev/null | head -5)
414
- fi
429
+ context_info=$(probe_context_usage "$claude_uuid")
415
430
 
416
431
  # Try to extract percentage from output
417
432
  local usage
418
- usage=$(echo "$context_info" | grep -oP '\d+(?=%)' | head -1)
433
+ usage=$(echo "$context_info" | grep -oP '\d+(?=%)' | head -1 || true)
419
434
 
420
435
  if [[ -n "$usage" && "$usage" -ge "$CONTEXT_PREVENTIVE_THRESHOLD" ]]; then
421
436
  print_warning "${MSG_CONTEXT_PREVENTIVE:-Context at ${usage}% - running preventive compact}"
@@ -423,7 +438,7 @@ check_preventive_compact() {
423
438
 
424
439
  # Save progress before preventive compact
425
440
  if type save_sprint_progress &>/dev/null; then
426
- save_sprint_progress "$session_id"
441
+ save_sprint_progress "$session_id" || print_verbose "No sprint progress file to save"
427
442
  fi
428
443
 
429
444
  run_auto_compact "$session_id"
@@ -109,7 +109,7 @@ build_git_section() {
109
109
  echo ""
110
110
  echo "### Uncommitted Changes"
111
111
  local status
112
- status=$(git status --porcelain 2>/dev/null | head -10)
112
+ status=$(git status --porcelain 2>/dev/null | head -10) || true
113
113
  if [[ -n "$status" ]]; then
114
114
  echo "\`\`\`"
115
115
  echo "$status"
@@ -18,7 +18,7 @@ get_dod_template() {
18
18
 
19
19
  # Normalize project type (remove version suffixes)
20
20
  local base_type
21
- base_type=$(echo "$project_type" | sed 's/-[0-9.]*$//' | sed 's/-typescript$//')
21
+ base_type=$(echo "$project_type" | sed 's/-[0-9.]*$//' | sed 's/-typescript$//') || true
22
22
 
23
23
  # Map to template file
24
24
  local template_file=""
@@ -69,7 +69,7 @@ get_dod_template_path() {
69
69
  local templates_dir="${2:-$DOD_TEMPLATES_DIR}"
70
70
 
71
71
  local base_type
72
- base_type=$(echo "$project_type" | sed 's/-[0-9.]*$//' | sed 's/-typescript$//')
72
+ base_type=$(echo "$project_type" | sed 's/-[0-9.]*$//' | sed 's/-typescript$//') || true
73
73
 
74
74
  case "$base_type" in
75
75
  symfony|laravel|php)
@@ -9,7 +9,7 @@ set -euo pipefail
9
9
  DOD_COMPLETION_MARKER="${DOD_COMPLETION_MARKER:-<promise>COMPLETE</promise>}"
10
10
 
11
11
  # DoD validator types
12
- declare -A DOD_VALIDATORS=(
12
+ declare -gA DOD_VALIDATORS=(
13
13
  ["command"]="validate_command"
14
14
  ["output_contains"]="validate_output_contains"
15
15
  ["file_changed"]="validate_file_changed"
@@ -27,12 +27,12 @@ validate_dod() {
27
27
 
28
28
  # If no config, use simple completion marker check
29
29
  if [[ -z "$config_file" || ! -f "$config_file" ]]; then
30
- return $(validate_simple_completion "$output")
30
+ validate_simple_completion "$output"; return
31
31
  fi
32
32
 
33
33
  # Check if yq is available for YAML parsing
34
34
  if ! command -v yq &> /dev/null; then
35
- return $(validate_simple_completion "$output")
35
+ validate_simple_completion "$output"; return
36
36
  fi
37
37
 
38
38
  # Get DoD checklist from config
@@ -44,7 +44,7 @@ validate_dod() {
44
44
  if [[ -n "$marker" ]]; then
45
45
  DOD_COMPLETION_MARKER="$marker"
46
46
  fi
47
- return $(validate_simple_completion "$output")
47
+ validate_simple_completion "$output"; return
48
48
  fi
49
49
 
50
50
  # Process checklist items
@@ -52,14 +52,14 @@ validate_dod() {
52
52
  local results=()
53
53
 
54
54
  # Get count of checklist items
55
- local count=$(echo "$checklist" | yq e 'length' -)
55
+ local count=$(echo "$checklist" | yq e 'length' -) || true
56
56
 
57
57
  for ((i=0; i<count; i++)); do
58
58
  local item=$(yq e ".definition_of_done.checklist[$i]" "$config_file")
59
- local id=$(echo "$item" | yq e '.id' -)
60
- local name=$(echo "$item" | yq e '.name' -)
61
- local type=$(echo "$item" | yq e '.type' -)
62
- local required=$(echo "$item" | yq e '.required // true' -)
59
+ local id=$(echo "$item" | yq e '.id' -) || true
60
+ local name=$(echo "$item" | yq e '.name' -) || true
61
+ local type=$(echo "$item" | yq e '.type' -) || true
62
+ local required=$(echo "$item" | yq e '.required // true' -) || true
63
63
 
64
64
  # Validate based on type
65
65
  local passed=false
@@ -67,7 +67,7 @@ validate_dod() {
67
67
 
68
68
  case "$type" in
69
69
  command)
70
- local cmd=$(echo "$item" | yq e '.command' -)
70
+ local cmd=$(echo "$item" | yq e '.command' -) || true
71
71
  if validate_command "$cmd"; then
72
72
  passed=true
73
73
  message="Command passed"
@@ -76,7 +76,7 @@ validate_dod() {
76
76
  fi
77
77
  ;;
78
78
  output_contains)
79
- local pattern=$(echo "$item" | yq e '.pattern' -)
79
+ local pattern=$(echo "$item" | yq e '.pattern' -) || true
80
80
  if validate_output_contains "$output" "$pattern"; then
81
81
  passed=true
82
82
  message="Pattern found"
@@ -85,7 +85,7 @@ validate_dod() {
85
85
  fi
86
86
  ;;
87
87
  file_changed)
88
- local pattern=$(echo "$item" | yq e '.pattern' -)
88
+ local pattern=$(echo "$item" | yq e '.pattern' -) || true
89
89
  if validate_file_changed "$pattern"; then
90
90
  passed=true
91
91
  message="Files changed"
@@ -94,7 +94,7 @@ validate_dod() {
94
94
  fi
95
95
  ;;
96
96
  hook)
97
- local script=$(echo "$item" | yq e '.script' -)
97
+ local script=$(echo "$item" | yq e '.script' -) || true
98
98
  if validate_hook "$script" "$output"; then
99
99
  passed=true
100
100
  message="Hook passed"
@@ -103,7 +103,7 @@ validate_dod() {
103
103
  fi
104
104
  ;;
105
105
  human)
106
- local prompt=$(echo "$item" | yq e '.prompt // ""' -)
106
+ local prompt=$(echo "$item" | yq e '.prompt // ""' -) || true
107
107
  if validate_human "$name" "$prompt"; then
108
108
  passed=true
109
109
  message="Human approved"
@@ -209,13 +209,13 @@ validate_file_changed() {
209
209
 
210
210
  # Use git to check for changes matching pattern
211
211
  if command -v git &> /dev/null && git rev-parse --git-dir &> /dev/null; then
212
- local changes=$(git status --porcelain 2>/dev/null | grep -E "$pattern" | wc -l)
212
+ local changes=$(git status --porcelain 2>/dev/null | grep -E "$pattern" | wc -l) || true
213
213
  if [[ $changes -gt 0 ]]; then
214
214
  return 0
215
215
  fi
216
216
  else
217
217
  # Fallback: check for recently modified files
218
- local changes=$(find . -name "$pattern" -mmin -5 2>/dev/null | wc -l)
218
+ local changes=$(find . -name "$pattern" -mmin -5 2>/dev/null | wc -l) || true
219
219
  if [[ $changes -gt 0 ]]; then
220
220
  return 0
221
221
  fi
@@ -381,7 +381,7 @@ export_dod_status() {
381
381
 
382
382
  # Count checks
383
383
  local total
384
- total=$(echo "$checklist" | yq e 'length' -)
384
+ total=$(echo "$checklist" | yq e 'length' -) || total=0
385
385
 
386
386
  local required=0
387
387
  for ((i=0; i<total; i++)); do
@@ -424,7 +424,7 @@ get_dod_summary() {
424
424
 
425
425
  # Build summary
426
426
  local count
427
- count=$(echo "$checklist" | yq e 'length' -)
427
+ count=$(echo "$checklist" | yq e 'length' -) || count=0
428
428
 
429
429
  local checks="["
430
430
  for ((i=0; i<count; i++)); do
@@ -21,7 +21,7 @@ ESCALATION_RESOLVED_DIR=""
21
21
  ESCALATION_AUDIT_FILE=""
22
22
 
23
23
  # Escalation priorities
24
- declare -A ESCALATION_PRIORITIES=(
24
+ declare -gA ESCALATION_PRIORITIES=(
25
25
  ["critical"]=1
26
26
  ["high"]=2
27
27
  ["medium"]=3
@@ -421,7 +421,7 @@ build_slack_payload() {
421
421
 
422
422
  local title="Ralph Escalation: $event"
423
423
  local truncated_details
424
- truncated_details=$(echo "$details" | head -c 500 | tr '\n' ' ')
424
+ truncated_details=$(echo "$details" | head -c 500 | tr '\n' ' ') || true
425
425
 
426
426
  jq -n \
427
427
  --arg color "$color" \
@@ -441,7 +441,7 @@ build_teams_payload() {
441
441
  [[ "$event" == "resolved" ]] && color="good"
442
442
 
443
443
  local truncated_details
444
- truncated_details=$(echo "$details" | head -c 500 | tr '\n' ' ')
444
+ truncated_details=$(echo "$details" | head -c 500 | tr '\n' ' ') || true
445
445
 
446
446
  jq -n \
447
447
  --arg type "MessageCard" \
@@ -462,7 +462,7 @@ build_discord_payload() {
462
462
  [[ "$event" == "resolved" ]] && color=65280 # Green
463
463
 
464
464
  local truncated_details
465
- truncated_details=$(echo "$details" | head -c 500 | tr '\n' ' ')
465
+ truncated_details=$(echo "$details" | head -c 500 | tr '\n' ' ') || true
466
466
 
467
467
  jq -n \
468
468
  --arg title "Ralph Escalation: $event" \
@@ -479,7 +479,7 @@ build_generic_payload() {
479
479
  local id="$1" event="$2" level="$3" error="$4" details="$5" session="$6"
480
480
 
481
481
  local truncated_details
482
- truncated_details=$(echo "$details" | head -c 500 | tr '\n' ' ')
482
+ truncated_details=$(echo "$details" | head -c 500 | tr '\n' ' ') || true
483
483
 
484
484
  jq -n \
485
485
  --arg event "$event" \
@@ -95,7 +95,8 @@ generate_hooks_config() {
95
95
  # Merge with existing settings
96
96
  local merged
97
97
  if command -v jq &>/dev/null; then
98
- merged=$(echo "$existing_settings" | jq --argjson hooks "$hooks_config" '. + {hooks: $hooks}')
98
+ merged=$(echo "$existing_settings" | jq --argjson hooks "$hooks_config" '. + {hooks: $hooks}') \
99
+ || merged="{\"hooks\": $hooks_config}"
99
100
  else
100
101
  # Fallback: just write hooks config
101
102
  merged="{\"hooks\": $hooks_config}"
@@ -56,7 +56,7 @@ ralph_finalize_loop() {
56
56
 
57
57
  # Save sprint progress before summary
58
58
  if type save_sprint_progress &>/dev/null; then
59
- save_sprint_progress "$session_id"
59
+ save_sprint_progress "$session_id" || print_verbose "No sprint progress file to save"
60
60
  fi
61
61
 
62
62
  # Save metrics export
@@ -13,6 +13,30 @@ CLAUDE_ARGS="${CLAUDE_ARGS:-}"
13
13
  # Claude Invocation
14
14
  # =============================================================================
15
15
 
16
+ # Print a random RFC 4122 v4 UUID (`claude --session-id` requires a valid UUID).
17
+ generate_uuid() {
18
+ if [[ -r /proc/sys/kernel/random/uuid ]]; then
19
+ cat /proc/sys/kernel/random/uuid
20
+ elif command -v uuidgen &> /dev/null; then
21
+ uuidgen | tr '[:upper:]' '[:lower:]'
22
+ else
23
+ local hex
24
+ hex=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n') || true
25
+ printf '%s-%s-4%s-%x%s-%s\n' "${hex:0:8}" "${hex:8:4}" "${hex:13:3}" \
26
+ $(( (0x${hex:16:1} & 0x3) | 0x8 )) "${hex:17:3}" "${hex:20:12}"
27
+ fi
28
+ }
29
+
30
+ # Print the Claude session UUID pinned to a Ralph session, or nothing if
31
+ # Claude has not been invoked yet for it. Always succeeds (set -e safe).
32
+ get_claude_session_uuid() {
33
+ local id_file="$RALPH_SESSION_BASE/sessions/$1/claude_session_id"
34
+ if [[ -n "$1" && -s "$id_file" ]]; then
35
+ cat "$id_file"
36
+ fi
37
+ return 0
38
+ }
39
+
16
40
  invoke_claude() {
17
41
  local session_id="$1"
18
42
  local prompt="$2"
@@ -25,9 +49,21 @@ invoke_claude() {
25
49
  local -a cmd
26
50
  IFS=' ' read -r -a cmd <<< "${CLAUDE_COMMAND:-claude}"
27
51
 
28
- # Add continue flag if we have a session
52
+ # Pin an explicit Claude session per Ralph session. `--continue` resumes
53
+ # "the most recent conversation in this directory", which parallel workers
54
+ # (and unrelated user sessions) would share.
29
55
  if [[ -n "$session_id" ]]; then
30
- cmd+=(--continue)
56
+ local claude_session_uuid
57
+ claude_session_uuid=$(get_claude_session_uuid "$session_id")
58
+ if [[ -n "$claude_session_uuid" ]]; then
59
+ cmd+=(--resume "$claude_session_uuid")
60
+ else
61
+ local id_file="$RALPH_SESSION_BASE/sessions/$session_id/claude_session_id"
62
+ claude_session_uuid=$(generate_uuid)
63
+ mkdir -p "$(dirname "$id_file")"
64
+ echo "$claude_session_uuid" > "$id_file"
65
+ cmd+=(--session-id "$claude_session_uuid")
66
+ fi
31
67
  fi
32
68
 
33
69
  # Add prompt
@@ -39,12 +75,11 @@ invoke_claude() {
39
75
  # Execute with timeout
40
76
  local timeout_sec=$((timeout / 1000))
41
77
  local output
42
- local exit_code
78
+ local exit_code=0
43
79
 
44
80
  # Use timeout command if available
45
81
  if command -v timeout &> /dev/null; then
46
- output=$(timeout "${timeout_sec}s" "${cmd[@]}" "$prompt" 2>&1)
47
- exit_code=$?
82
+ output=$(timeout "${timeout_sec}s" "${cmd[@]}" "$prompt" 2>&1) || exit_code=$?
48
83
 
49
84
  # Check for timeout (exit code 124)
50
85
  if [[ $exit_code -eq 124 ]]; then
@@ -53,8 +88,7 @@ invoke_claude() {
53
88
  fi
54
89
  else
55
90
  # Fallback without timeout
56
- output=$("${cmd[@]}" "$prompt" 2>&1)
57
- exit_code=$?
91
+ output=$("${cmd[@]}" "$prompt" 2>&1) || exit_code=$?
58
92
  fi
59
93
 
60
94
  # Log result
@@ -104,7 +138,7 @@ detect_file_changes() {
104
138
 
105
139
  # Use git to detect changes if available
106
140
  if command -v git &> /dev/null && git rev-parse --git-dir &> /dev/null; then
107
- local changes=$(git status --porcelain 2>/dev/null | wc -l)
141
+ local changes=$(git status --porcelain 2>/dev/null | wc -l) || true
108
142
  log_session "$session_id" "DEBUG" "Detected $changes file changes"
109
143
  echo "$changes"
110
144
  else
@@ -112,7 +146,7 @@ detect_file_changes() {
112
146
  local changes=0
113
147
  for dir in src lib app tests; do
114
148
  if [[ -d "$dir" ]]; then
115
- local recent=$(find "$dir" -type f -mmin -1 2>/dev/null | wc -l)
149
+ local recent=$(find "$dir" -type f -mmin -1 2>/dev/null | wc -l) || true
116
150
  changes=$((changes + recent))
117
151
  fi
118
152
  done
@@ -219,7 +253,7 @@ get_loop_state() {
219
253
  status: (.status // "unknown"),
220
254
  circuit_breaker: (.circuit_breaker // {}),
221
255
  dod_results: (.dod_results // [])
222
- }' 2>/dev/null)
256
+ }' 2>/dev/null) || result=""
223
257
 
224
258
  if [[ -n "$result" ]]; then
225
259
  echo "$result"
@@ -12,7 +12,7 @@ METRICS_REALTIME_ENABLED="${METRICS_REALTIME_ENABLED:-true}"
12
12
  METRICS_REALTIME_INTERVAL="${METRICS_REALTIME_INTERVAL:-1000}"
13
13
 
14
14
  # Metrics data
15
- declare -A METRICS_DATA
15
+ declare -gA METRICS_DATA
16
16
  METRICS_START_TIME=0
17
17
  METRICS_ITERATION_TIMES=()
18
18
  METRICS_CONTEXT_SAMPLES=()
@@ -17,8 +17,9 @@ PARALLEL_RESOURCE_LIMIT_CPU="${PARALLEL_RESOURCE_LIMIT_CPU:-80}"
17
17
  PARALLEL_RESOURCE_LIMIT_MEM="${PARALLEL_RESOURCE_LIMIT_MEM:-80}"
18
18
 
19
19
  # Session tracking
20
- declare -A PARALLEL_SESSIONS
21
- declare -A PARALLEL_PIDS
20
+ # `=()`: a declared-but-unassigned array is "unbound" for `${#arr[@]}` under set -u
21
+ declare -gA PARALLEL_SESSIONS=()
22
+ declare -gA PARALLEL_PIDS=()
22
23
  PARALLEL_SESSION_COUNT=0
23
24
  PARALLEL_COMPLETED_COUNT=0
24
25
  PARALLEL_FAILED_COUNT=0
@@ -36,6 +37,8 @@ PARALLEL_WORKER_DIR=""
36
37
  init_parallel_manager() {
37
38
  PARALLEL_BASE_DIR="${RALPH_SESSION_BASE:-.ralph}/parallel"
38
39
  mkdir -p "$PARALLEL_BASE_DIR"
40
+ # Absolute: workers cd into their own worktree
41
+ PARALLEL_BASE_DIR="$(cd "$PARALLEL_BASE_DIR" && pwd)"
39
42
 
40
43
  PARALLEL_STATE_FILE="$PARALLEL_BASE_DIR/state.yaml"
41
44
  PARALLEL_DEPENDENCY_GRAPH="$PARALLEL_BASE_DIR/dependencies.json"
@@ -102,10 +105,11 @@ build_dependency_graph() {
102
105
  local bmad_dir="${BMAD_DIR:-.bmad}"
103
106
  local status_file="$bmad_dir/sprint-status.yaml"
104
107
 
108
+ # Without a status file (or yq), all stories are independent nodes: the
109
+ # graph must still list them, get_ready_stories reads it.
110
+ local with_deps=true
105
111
  if [[ ! -f "$status_file" ]] || ! command -v yq &>/dev/null; then
106
- # No dependencies, all stories are independent
107
- echo '{"nodes":{},"edges":[]}'
108
- return
112
+ with_deps=false
109
113
  fi
110
114
 
111
115
  local nodes="{}"
@@ -113,17 +117,20 @@ build_dependency_graph() {
113
117
 
114
118
  for story_id in $stories; do
115
119
  # Get story dependencies
116
- local deps
117
- deps=$(yq ".stories.${story_id}.blocked_by // []" "$status_file" 2>/dev/null)
120
+ local deps="[]"
121
+ if [[ "$with_deps" == "true" ]]; then
122
+ deps=$(yq ".stories.${story_id}.blocked_by // []" "$status_file" 2>/dev/null) || deps="[]"
123
+ fi
118
124
 
119
- # Add node
120
- nodes=$(echo "$nodes" | jq --arg id "$story_id" '. + {($id): {"status": "pending", "deps": []}}')
125
+ # Add node (on jq failure, keep the previous value: `next` buffers it)
126
+ local next
127
+ next=$(echo "$nodes" | jq --arg id "$story_id" '. + {($id): {"status": "pending", "deps": []}}') && nodes="$next"
121
128
 
122
129
  # Add edges for dependencies
123
130
  if [[ "$deps" != "[]" && "$deps" != "null" ]]; then
124
- for dep in $(echo "$deps" | jq -r '.[]' 2>/dev/null); do
125
- edges=$(echo "$edges" | jq --arg from "$dep" --arg to "$story_id" '. + [{"from": $from, "to": $to}]')
126
- nodes=$(echo "$nodes" | jq --arg id "$story_id" --arg dep "$dep" '.[$id].deps += [$dep]')
131
+ for dep in $(echo "$deps" | jq -r '.[]' 2>/dev/null || true); do
132
+ next=$(echo "$edges" | jq --arg from "$dep" --arg to "$story_id" '. + [{"from": $from, "to": $to}]') && edges="$next"
133
+ next=$(echo "$nodes" | jq --arg id "$story_id" --arg dep "$dep" '.[$id].deps += [$dep]') && nodes="$next"
127
134
  done
128
135
  fi
129
136
  done
@@ -144,18 +151,20 @@ get_ready_stories() {
144
151
  fi
145
152
 
146
153
  # Find nodes with status=pending and all deps completed
154
+ # ($root: jq has no `$.` root accessor — that syntax failed to compile)
147
155
  jq -r '
156
+ . as $root |
148
157
  .nodes | to_entries[] |
149
158
  select(.value.status == "pending") |
150
159
  select(
151
160
  (.value.deps | length) == 0 or
152
161
  (
153
162
  .value.deps as $deps |
154
- ($deps | map(. as $d | $.nodes[$d].status == "completed") | all)
163
+ ($deps | map(. as $d | $root.nodes[$d].status == "completed") | all)
155
164
  )
156
165
  ) |
157
166
  .key
158
- ' "$PARALLEL_DEPENDENCY_GRAPH" 2>/dev/null
167
+ ' "$PARALLEL_DEPENDENCY_GRAPH" 2>/dev/null || true
159
168
  }
160
169
 
161
170
  # Mark story as completed in dependency graph
@@ -171,6 +180,19 @@ mark_story_completed() {
171
180
  echo "$updated" > "$PARALLEL_DEPENDENCY_GRAPH"
172
181
  }
173
182
 
183
+ # Mark story as running in dependency graph (leader only, at spawn time)
184
+ mark_story_running() {
185
+ local story_id="$1"
186
+
187
+ if [[ ! -f "$PARALLEL_DEPENDENCY_GRAPH" ]] || ! command -v jq &>/dev/null; then
188
+ return 0
189
+ fi
190
+
191
+ local updated
192
+ updated=$(jq --arg id "$story_id" '.nodes[$id].status = "running"' "$PARALLEL_DEPENDENCY_GRAPH")
193
+ echo "$updated" > "$PARALLEL_DEPENDENCY_GRAPH"
194
+ }
195
+
174
196
  # Mark story as failed in dependency graph
175
197
  mark_story_failed() {
176
198
  local story_id="$1"
@@ -211,11 +233,11 @@ check_resource_availability() {
211
233
  mem_usage=$(free | grep Mem | awk '{print int($3/$2 * 100)}' 2>/dev/null || echo "0")
212
234
  elif [[ "$(uname)" == "Darwin" ]]; then
213
235
  local pages_free pages_active pages_inactive pages_speculative pages_wired
214
- pages_free=$(vm_stat | grep "Pages free" | awk '{print $3}' | tr -d '.')
215
- pages_active=$(vm_stat | grep "Pages active" | awk '{print $3}' | tr -d '.')
216
- pages_inactive=$(vm_stat | grep "Pages inactive" | awk '{print $3}' | tr -d '.')
217
- pages_speculative=$(vm_stat | grep "Pages speculative" | awk '{print $3}' | tr -d '.')
218
- pages_wired=$(vm_stat | grep "Pages wired" | awk '{print $4}' | tr -d '.')
236
+ pages_free=$(vm_stat | grep "Pages free" | awk '{print $3}' | tr -d '.') || pages_free=0
237
+ pages_active=$(vm_stat | grep "Pages active" | awk '{print $3}' | tr -d '.') || pages_active=0
238
+ pages_inactive=$(vm_stat | grep "Pages inactive" | awk '{print $3}' | tr -d '.') || pages_inactive=0
239
+ pages_speculative=$(vm_stat | grep "Pages speculative" | awk '{print $3}' | tr -d '.') || pages_speculative=0
240
+ pages_wired=$(vm_stat | grep "Pages wired" | awk '{print $4}' | tr -d '.') || pages_wired=0
219
241
  local total=$((pages_free + pages_active + pages_inactive + pages_speculative + pages_wired))
220
242
  local used=$((pages_active + pages_wired))
221
243
  mem_usage=$((used * 100 / total))
@@ -232,9 +254,7 @@ check_resource_availability() {
232
254
  # Get number of available slots
233
255
  get_available_slots() {
234
256
  local active_count=0
235
- if [[ -v PARALLEL_PIDS ]]; then
236
- active_count=${#PARALLEL_PIDS[@]}
237
- fi
257
+ active_count=${#PARALLEL_PIDS[@]}
238
258
  local available=$((PARALLEL_MAX_CONCURRENT - active_count))
239
259
 
240
260
  # Further limit if resources are constrained
@@ -279,7 +299,7 @@ EOF
279
299
  local ralph_script=""
280
300
  for path in "${SCRIPT_DIR}/ralph.sh" "Tools/Ralph/ralph.sh" "$(dirname "${BASH_SOURCE[0]}")/../ralph.sh"; do
281
301
  if [[ -f "$path" ]]; then
282
- ralph_script="$path"
302
+ ralph_script="$(cd "$(dirname "$path")" && pwd)/$(basename "$path")"
283
303
  break
284
304
  fi
285
305
  done
@@ -290,19 +310,35 @@ EOF
290
310
  fi
291
311
 
292
312
  # Build command
293
- local cmd="$ralph_script"
294
- [[ -n "$config" ]] && cmd="$cmd --config=$config"
313
+ local -a cmd=("$ralph_script")
314
+ [[ -n "$config" ]] && cmd+=("--config=$config")
315
+
316
+ # Isolate the worker's working tree (one git worktree per session)
317
+ local worktree_dir=""
318
+ if ! worktree_dir=$(create_worker_worktree "$session_id"); then
319
+ worktree_dir=""
320
+ print_warning "No git worktree for $session_id: worker shares the current directory"
321
+ fi
295
322
 
296
323
  # Spawn in background
297
324
  # Workers write to individual temp files (single-writer pattern).
298
325
  # The leader merges results after all workers complete.
299
326
  local worker_id="${story_id}-$$-$(date +%s)"
300
327
  (
301
- cd "$(pwd)" || exit 1
328
+ if [[ -n "$worktree_dir" ]]; then
329
+ cd "$worktree_dir" || exit 1
330
+ fi
331
+ # Per-worker Ralph state (incl. the Claude session UUID) under the
332
+ # leader's .ralph/, so it survives the worktree removal.
333
+ export RALPH_SESSION_BASE="$session_dir/ralph"
302
334
 
303
335
  local output
304
- output=$($cmd "$prompt" 2>&1)
305
- local status=$?
336
+ local status=0
337
+ output=$("${cmd[@]}" "$prompt" 2>&1) || status=$?
338
+
339
+ if [[ -n "$worktree_dir" ]] && ! cleanup_worker_worktree "$worktree_dir" "$story_id"; then
340
+ output+=$'\n'"[WARN] Worktree kept: $worktree_dir"
341
+ fi
306
342
 
307
343
  # Save output (per-session dir, no conflict)
308
344
  echo "$output" > "$session_dir/output.log"
@@ -340,6 +376,30 @@ EOF
340
376
  echo "$session_id"
341
377
  }
342
378
 
379
+ # Create a dedicated git worktree on branch ralph/<session_id> so parallel
380
+ # workers never share a working tree. Prints its path; fails outside git.
381
+ create_worker_worktree() {
382
+ local session_id="$1"
383
+ local worktree_dir="$PARALLEL_BASE_DIR/worktrees/$session_id"
384
+
385
+ git rev-parse --is-inside-work-tree &> /dev/null || return 1
386
+ git worktree add -q -b "ralph/$session_id" "$worktree_dir" HEAD &> /dev/null || return 1
387
+ echo "$worktree_dir"
388
+ }
389
+
390
+ # Commit leftover changes as WIP on the worker branch, then remove the
391
+ # worktree. If the commit fails, the dirty worktree is kept (no data loss).
392
+ cleanup_worker_worktree() {
393
+ local worktree_dir="$1"
394
+ local story_id="$2"
395
+
396
+ if [[ -n "$(git -C "$worktree_dir" status --porcelain)" ]]; then
397
+ git -C "$worktree_dir" add -A
398
+ git -C "$worktree_dir" commit -q -m "wip(ralph): $story_id" || return 1
399
+ fi
400
+ git -C "$worktree_dir" worktree remove "$worktree_dir"
401
+ }
402
+
343
403
  # =============================================================================
344
404
  # Session Management
345
405
  # =============================================================================
@@ -359,9 +419,9 @@ collect_completed_sessions() {
359
419
  local session_id="${PARALLEL_PIDS[$pid]}"
360
420
  local story_id="${PARALLEL_SESSIONS[$session_id]}"
361
421
 
362
- # Check exit status
363
- wait "$pid" 2>/dev/null
364
- local exit_code=$?
422
+ # Check exit status (a failed worker must not kill the leader)
423
+ local exit_code=0
424
+ wait "$pid" 2>/dev/null || exit_code=$?
365
425
 
366
426
  if [[ $exit_code -eq 0 ]]; then
367
427
  completed+=("$story_id")
@@ -395,9 +455,7 @@ wait_all_sessions() {
395
455
  start_time=$(date +%s)
396
456
 
397
457
  local active_count=0
398
- if [[ -v PARALLEL_PIDS ]]; then
399
- active_count=${#PARALLEL_PIDS[@]}
400
- fi
458
+ active_count=${#PARALLEL_PIDS[@]}
401
459
 
402
460
  while [[ $active_count -gt 0 ]]; do
403
461
  collect_completed_sessions > /dev/null
@@ -415,9 +473,7 @@ wait_all_sessions() {
415
473
 
416
474
  # Update active count
417
475
  active_count=0
418
- if [[ -v PARALLEL_PIDS ]]; then
419
- active_count=${#PARALLEL_PIDS[@]}
420
- fi
476
+ active_count=${#PARALLEL_PIDS[@]}
421
477
  done
422
478
 
423
479
  return 0
@@ -426,9 +482,7 @@ wait_all_sessions() {
426
482
  # Get active session count
427
483
  get_active_count() {
428
484
  local count=0
429
- if [[ -v PARALLEL_PIDS ]]; then
430
- count=${#PARALLEL_PIDS[@]}
431
- fi
485
+ count=${#PARALLEL_PIDS[@]}
432
486
  echo "$count"
433
487
  }
434
488
 
@@ -455,7 +509,7 @@ process_stories_parallel() {
455
509
 
456
510
  local processed=0
457
511
  local total
458
- total=$(echo "$stories" | wc -w | tr -d ' ')
512
+ total=$(echo "$stories" | wc -w | tr -d ' ') || total=0
459
513
 
460
514
  while [[ $processed -lt $total ]]; do
461
515
  # Collect completed sessions
@@ -473,9 +527,6 @@ process_stories_parallel() {
473
527
  for story_id in $ready; do
474
528
  [[ $slots -le 0 ]] && break
475
529
 
476
- # Skip if already processed
477
- [[ -n "${PARALLEL_SESSIONS[$story_id]:-}" ]] && continue
478
-
479
530
  # Build prompt
480
531
  local prompt="$prompt_template $story_id"
481
532
 
@@ -488,10 +539,15 @@ process_stories_parallel() {
488
539
  prompt="Implement user story $story_id: $title. Follow TDD approach."
489
540
  fi
490
541
 
491
- # Spawn session
492
- spawn_session "$story_id" "$prompt"
493
- ((slots--))
494
- ((processed++))
542
+ # Spawn session. "running" keeps get_ready_stories (pending
543
+ # only) from re-spawning it before its report is merged.
544
+ if spawn_session "$story_id" "$prompt"; then
545
+ mark_story_running "$story_id"
546
+ else
547
+ mark_story_failed "$story_id"
548
+ fi
549
+ slots=$((slots - 1))
550
+ processed=$((processed + 1))
495
551
  done
496
552
  fi
497
553
 
@@ -503,6 +559,9 @@ process_stories_parallel() {
503
559
  print_info "Waiting for remaining sessions to complete..."
504
560
  wait_all_sessions
505
561
 
562
+ # Drop stale worktree metadata (e.g. a worker killed before its cleanup)
563
+ git worktree prune &> /dev/null || true
564
+
506
565
  # Print summary
507
566
  print_parallel_summary
508
567
  }
@@ -567,7 +626,7 @@ merge_worker_reports() {
567
626
  if [[ "$dry_run" == "true" ]]; then
568
627
  print_info "DRY RUN - Would merge: session=$session_id story=$story_id status=$status" 2>/dev/null \
569
628
  || echo "[INFO] DRY RUN - Would merge: session=$session_id story=$story_id status=$status"
570
- ((merged_count++))
629
+ merged_count=$((merged_count + 1))
571
630
  continue
572
631
  fi
573
632
 
@@ -591,7 +650,7 @@ merge_worker_reports() {
591
650
  fi
592
651
 
593
652
  rm -f "$report_file"
594
- ((merged_count++))
653
+ merged_count=$((merged_count + 1))
595
654
  done
596
655
 
597
656
  # Update efficiency after merge
@@ -781,6 +840,11 @@ cmd_parallel_manager() {
781
840
  echo "Single-Writer Pattern:"
782
841
  echo " Background workers write to .ralph/parallel/workers/worker-{id}.yaml"
783
842
  echo " Only the leader merges results into shared state files"
843
+ echo ""
844
+ echo "Isolation:"
845
+ echo " Each worker runs in its own git worktree (branch ralph/<session>),"
846
+ echo " removed at the end of the run (leftover changes committed as WIP)."
847
+ echo " Each Ralph session pins its own Claude session (--session-id, then --resume)."
784
848
  ;;
785
849
  *)
786
850
  echo "Usage: parallel-manager.sh <command> [args]"
@@ -72,7 +72,7 @@ detect_symfony() {
72
72
 
73
73
  # Detect Symfony version
74
74
  local version
75
- version=$(grep -o '"symfony/framework-bundle"[[:space:]]*:[[:space:]]*"[^"]*"' "$composer_file" | grep -o '[0-9]\+\.[0-9]\+' | head -1)
75
+ version=$(grep -o '"symfony/framework-bundle"[[:space:]]*:[[:space:]]*"[^"]*"' "$composer_file" | grep -o '[0-9]\+\.[0-9]\+' | head -1) || true
76
76
  if [[ -n "$version" ]]; then
77
77
  DETECTED_PROJECT_TYPE="symfony-$version"
78
78
  fi
@@ -325,7 +325,7 @@ detect_dotnet() {
325
325
 
326
326
  # Look for .csproj files
327
327
  local csproj_count
328
- csproj_count=$(find "$project_dir" -maxdepth 3 -name "*.csproj" 2>/dev/null | wc -l)
328
+ csproj_count=$(find "$project_dir" -maxdepth 3 -name "*.csproj" 2>/dev/null | wc -l) || csproj_count=0
329
329
 
330
330
  if [[ $csproj_count -gt 0 ]]; then
331
331
  DETECTED_PROJECT_TYPE="dotnet"
@@ -340,7 +340,7 @@ detect_dotnet() {
340
340
 
341
341
  # Detect specific framework
342
342
  local csproj_file
343
- csproj_file=$(find "$project_dir" -maxdepth 3 -name "*.csproj" 2>/dev/null | head -1)
343
+ csproj_file=$(find "$project_dir" -maxdepth 3 -name "*.csproj" 2>/dev/null | head -1) || true
344
344
  if [[ -f "$csproj_file" ]]; then
345
345
  if grep -q 'Microsoft.NET.Sdk.Web' "$csproj_file" 2>/dev/null; then
346
346
  DETECTED_PROJECT_TYPE="dotnet-web"
@@ -17,7 +17,7 @@ RECOVERY_AUTO_FIX_TESTS="${RECOVERY_AUTO_FIX_TESTS:-retry_tdd}"
17
17
  RECOVERY_AUTO_RETRY_TRANSIENT="${RECOVERY_AUTO_RETRY_TRANSIENT:-true}"
18
18
 
19
19
  # Error classification levels
20
- declare -A RECOVERY_LEVELS=(
20
+ declare -gA RECOVERY_LEVELS=(
21
21
  [0]="transient" # Auto-retry immediately
22
22
  [1]="recoverable" # Auto-fix possible
23
23
  [2]="degraded" # Continue with warning
@@ -25,14 +25,14 @@ declare -A RECOVERY_LEVELS=(
25
25
  )
26
26
 
27
27
  # Error patterns for classification
28
- declare -A ERROR_PATTERNS_TRANSIENT=(
28
+ declare -gA ERROR_PATTERNS_TRANSIENT=(
29
29
  ["timeout"]="timeout|timed out|ETIMEDOUT"
30
30
  ["rate_limit"]="rate limit|429|too many requests"
31
31
  ["network"]="ECONNRESET|ECONNREFUSED|network error"
32
32
  ["temporary"]="temporary failure|try again|service unavailable|503"
33
33
  )
34
34
 
35
- declare -A ERROR_PATTERNS_RECOVERABLE=(
35
+ declare -gA ERROR_PATTERNS_RECOVERABLE=(
36
36
  ["lint"]="lint error|eslint|phpcs|flake8|prettier|stylelint"
37
37
  ["test_fail"]="test failed|assertion failed|tests? (failed|failing)"
38
38
  ["deps"]="dependency|package not found|module not found|could not resolve"
@@ -40,14 +40,14 @@ declare -A ERROR_PATTERNS_RECOVERABLE=(
40
40
  ["type_error"]="type error|typescript error|type mismatch"
41
41
  )
42
42
 
43
- declare -A ERROR_PATTERNS_DEGRADED=(
43
+ declare -gA ERROR_PATTERNS_DEGRADED=(
44
44
  ["docs"]="documentation|readme|changelog"
45
45
  ["optional_gate"]="optional.*gate|non-blocking"
46
46
  ["coverage"]="coverage below|coverage threshold"
47
47
  ["warning"]="warning:|deprecated"
48
48
  )
49
49
 
50
- declare -A ERROR_PATTERNS_BLOCKED=(
50
+ declare -gA ERROR_PATTERNS_BLOCKED=(
51
51
  ["security"]="security vulnerability|CVE-|critical security|injection"
52
52
  ["architecture"]="architecture violation|circular dependency|breaking change"
53
53
  ["auth"]="authentication failed|unauthorized|permission denied"
@@ -55,7 +55,7 @@ declare -A ERROR_PATTERNS_BLOCKED=(
55
55
  )
56
56
 
57
57
  # Recovery strategies
58
- declare -A RECOVERY_STRATEGIES=(
58
+ declare -gA RECOVERY_STRATEGIES=(
59
59
  ["lint"]="run_auto_lint_fix"
60
60
  ["test_fail"]="run_tdd_retry"
61
61
  ["deps"]="run_deps_install"
@@ -124,7 +124,7 @@ init_recovery_engine() {
124
124
  classify_error() {
125
125
  local error_output="$1"
126
126
  local error_lower
127
- error_lower=$(echo "$error_output" | tr '[:upper:]' '[:lower:]')
127
+ error_lower=$(echo "$error_output" | tr '[:upper:]' '[:lower:]') || true
128
128
 
129
129
  # Check Level 3 - Blocked (security, architecture)
130
130
  for pattern_name in "${!ERROR_PATTERNS_BLOCKED[@]}"; do
@@ -415,7 +415,7 @@ run_tdd_retry() {
415
415
 
416
416
  # Try to parse test output
417
417
  if echo "$error_output" | grep -qE "FAIL|FAILED|Error:"; then
418
- failing_tests=$(echo "$error_output" | grep -E "FAIL|FAILED|Error:" | head -5)
418
+ failing_tests=$(echo "$error_output" | grep -E "FAIL|FAILED|Error:" | head -5) || true
419
419
  fi
420
420
 
421
421
  # Update sprint progress with TDD phase
@@ -23,10 +23,10 @@ generate_session_id() {
23
23
  elif command -v xxd &>/dev/null; then
24
24
  random=$(head -c 4 /dev/urandom | xxd -p)
25
25
  elif command -v od &>/dev/null; then
26
- random=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n')
26
+ random=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n') || true
27
27
  else
28
28
  # Fallback: use $RANDOM (less entropy but portable)
29
- random=$(printf '%04x%04x' $RANDOM $RANDOM | head -c 8)
29
+ random=$(printf '%04x%04x' $RANDOM $RANDOM | head -c 8) || true
30
30
  fi
31
31
 
32
32
  echo "ralph-${timestamp}-${random}"
@@ -196,7 +196,7 @@ run_sprint_conductor_parallel() {
196
196
  fi
197
197
 
198
198
  local story_count
199
- story_count=$(echo "$all_stories" | wc -w | tr -d ' ')
199
+ story_count=$(echo "$all_stories" | wc -w | tr -d ' ') || story_count=0
200
200
  log_conductor "Found $story_count stories for parallel processing"
201
201
 
202
202
  # Build dependency graph
@@ -749,7 +749,7 @@ build_story_prompt() {
749
749
  local title description acceptance_criteria
750
750
  title=$(yq ".stories.${story_id}.title" "$status_file" 2>/dev/null)
751
751
  description=$(yq ".stories.${story_id}.description" "$status_file" 2>/dev/null)
752
- acceptance_criteria=$(yq ".stories.${story_id}.acceptance_criteria[]" "$status_file" 2>/dev/null | tr '\n' '; ')
752
+ acceptance_criteria=$(yq ".stories.${story_id}.acceptance_criteria[]" "$status_file" 2>/dev/null | tr '\n' '; ') || true
753
753
 
754
754
  prompt="Implement user story: $title
755
755
 
@@ -288,7 +288,7 @@ get_completed_tasks_count() {
288
288
 
289
289
  update_sprint_progress() {
290
290
  local section="$1"
291
- local content="$2"
291
+ local content="${2:-}" # optional: "compact" is called without content
292
292
 
293
293
  if [[ "$SPRINT_PROGRESS_ENABLED" != "true" ]]; then
294
294
  return 0
@@ -343,10 +343,10 @@ update_state_section() {
343
343
  local state_json="$1"
344
344
 
345
345
  # Parse JSON and update each field
346
- local sprint=$(echo "$state_json" | grep -oP '"sprint":\s*"\K[^"]*' 2>/dev/null)
347
- local current_us=$(echo "$state_json" | grep -oP '"current_us":\s*"\K[^"]*' 2>/dev/null)
348
- local current_task=$(echo "$state_json" | grep -oP '"current_task":\s*"\K[^"]*' 2>/dev/null)
349
- local phase=$(echo "$state_json" | grep -oP '"phase":\s*"\K[^"]*' 2>/dev/null)
346
+ local sprint=$(echo "$state_json" | grep -oP '"sprint":\s*"\K[^"]*' 2>/dev/null) || true
347
+ local current_us=$(echo "$state_json" | grep -oP '"current_us":\s*"\K[^"]*' 2>/dev/null) || true
348
+ local current_task=$(echo "$state_json" | grep -oP '"current_task":\s*"\K[^"]*' 2>/dev/null) || true
349
+ local phase=$(echo "$state_json" | grep -oP '"phase":\s*"\K[^"]*' 2>/dev/null) || true
350
350
 
351
351
  # Build list of sed patterns (escape special chars)
352
352
  local patterns=()
@@ -657,7 +657,7 @@ compact_on_sprint_start() {
657
657
  print_info "${MSG_CONTEXT_SPRINT_START:-Compact at sprint start for clean context}..."
658
658
 
659
659
  # Save initial state
660
- save_sprint_progress "$session_id"
660
+ save_sprint_progress "$session_id" || print_verbose "No sprint progress file to save"
661
661
 
662
662
  # Run compact if available
663
663
  if type run_auto_compact &>/dev/null; then
@@ -688,7 +688,7 @@ compact_on_task_complete() {
688
688
  if [[ -n "$task_info" ]]; then
689
689
  update_sprint_progress "task_complete" "$task_info"
690
690
  fi
691
- save_sprint_progress "$session_id"
691
+ save_sprint_progress "$session_id" || print_verbose "No sprint progress file to save"
692
692
 
693
693
  # Run compact
694
694
  if type run_auto_compact &>/dev/null; then
@@ -710,7 +710,7 @@ compact_on_us_complete() {
710
710
 
711
711
  if [[ "$SPRINT_COMPACT_ON_US_COMPLETE" != "true" ]]; then
712
712
  # Even if compact is disabled, we should save and checkpoint
713
- save_sprint_progress "$session_id"
713
+ save_sprint_progress "$session_id" || print_verbose "No sprint progress file to save"
714
714
  if type create_checkpoint &>/dev/null; then
715
715
  create_checkpoint "$session_id" "us-complete-$us_id"
716
716
  fi
@@ -721,7 +721,7 @@ compact_on_us_complete() {
721
721
  print_info "${MSG_CONTEXT_US_COMPLETE:-US complete - running strategic compact}..."
722
722
 
723
723
  # Save and checkpoint
724
- save_sprint_progress "$session_id"
724
+ save_sprint_progress "$session_id" || print_verbose "No sprint progress file to save"
725
725
  if type create_checkpoint &>/dev/null; then
726
726
  create_checkpoint "$session_id" "us-complete-$us_id"
727
727
  fi
@@ -499,14 +499,15 @@ run_ralph() {
499
499
 
500
500
  # Check for preventive compact (before hitting the limit)
501
501
  if type check_preventive_compact &>/dev/null; then
502
- check_preventive_compact "$SESSION_ID"
502
+ check_preventive_compact "$SESSION_ID" || true
503
503
  fi
504
504
 
505
505
  # Check if we have a continuation prompt (after compact/session handoff)
506
506
  local effective_prompt="$PROMPT"
507
507
  if type get_context_continuation_prompt &>/dev/null; then
508
508
  local continuation_prompt
509
- continuation_prompt=$(get_context_continuation_prompt)
509
+ # Returns 1 when there is no continuation context (nominal).
510
+ continuation_prompt=$(get_context_continuation_prompt) || true
510
511
  if [[ -n "$continuation_prompt" ]]; then
511
512
  # Prepend continuation context to the prompt
512
513
  effective_prompt="$continuation_prompt
@@ -520,9 +521,11 @@ $PROMPT"
520
521
 
521
522
  # Invoke Claude
522
523
  print_info "${MSG_INVOKING_CLAUDE}"
524
+ # `|| invoke_status=$?`: a failed call must reach the circuit breaker,
525
+ # not kill the loop through errexit.
523
526
  local response
524
- response=$(invoke_claude "$SESSION_ID" "$effective_prompt" "$TIMEOUT")
525
- local invoke_status=$?
527
+ local invoke_status=0
528
+ response=$(invoke_claude "$SESSION_ID" "$effective_prompt" "$TIMEOUT") || invoke_status=$?
526
529
 
527
530
  if [[ $invoke_status -ne 0 ]]; then
528
531
  print_error "${MSG_ERROR}: Claude invocation failed"
@@ -536,8 +539,8 @@ $PROMPT"
536
539
 
537
540
  if type run_auto_compact &>/dev/null; then
538
541
  local compact_result
539
- compact_result=$(run_auto_compact "$SESSION_ID")
540
- local compact_status=$?
542
+ local compact_status=0
543
+ compact_result=$(run_auto_compact "$SESSION_ID") || compact_status=$?
541
544
 
542
545
  case $compact_status in
543
546
  0)
@@ -548,7 +551,7 @@ $PROMPT"
548
551
  local retry_prompt="$PROMPT"
549
552
  if type get_context_continuation_prompt &>/dev/null; then
550
553
  local cont_prompt
551
- cont_prompt=$(get_context_continuation_prompt)
554
+ cont_prompt=$(get_context_continuation_prompt) || true
552
555
  if [[ -n "$cont_prompt" ]]; then
553
556
  retry_prompt="$cont_prompt
554
557
 
@@ -558,8 +561,8 @@ $PROMPT"
558
561
  fi
559
562
  fi
560
563
 
561
- response=$(invoke_claude "$SESSION_ID" "$retry_prompt" "$TIMEOUT")
562
- invoke_status=$?
564
+ invoke_status=0
565
+ response=$(invoke_claude "$SESSION_ID" "$retry_prompt" "$TIMEOUT") || invoke_status=$?
563
566
 
564
567
  if [[ $invoke_status -ne 0 ]]; then
565
568
  print_error "${MSG_ERROR}: Claude invocation failed after compact"
@@ -581,10 +584,10 @@ $PROMPT"
581
584
  fi
582
585
 
583
586
  # Retry with new session and handoff context
587
+ invoke_status=0
584
588
  response=$(invoke_claude "$SESSION_ID" "$handoff_prompt
585
589
 
586
- $PROMPT" "$TIMEOUT")
587
- invoke_status=$?
590
+ $PROMPT" "$TIMEOUT") || invoke_status=$?
588
591
 
589
592
  if [[ $invoke_status -ne 0 ]]; then
590
593
  print_error "${MSG_ERROR}: Claude invocation failed in continuation session"
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-bearded-bear/claude-craft",
3
- "version": "8.26.0-next.2a54921",
3
+ "version": "8.26.0-next.defc34b",
4
4
  "description": "A comprehensive framework for AI-assisted development with Claude Code. Install standardized rules, agents, and commands for your projects.",
5
5
  "type": "module",
6
6
  "main": "cli/index.js",