prizmkit 1.1.67 → 1.1.69

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 (36) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/lib/common.sh +40 -0
  3. package/bundled/dev-pipeline/lib/heartbeat.sh +5 -5
  4. package/bundled/dev-pipeline/run-bugfix.sh +26 -5
  5. package/bundled/dev-pipeline/run-feature.sh +20 -3
  6. package/bundled/dev-pipeline/run-refactor.sh +26 -5
  7. package/bundled/dev-pipeline/scripts/parse-stream-progress.py +217 -18
  8. package/bundled/dev-pipeline/scripts/update-bug-status.py +15 -0
  9. package/bundled/dev-pipeline/scripts/update-feature-status.py +18 -0
  10. package/bundled/dev-pipeline/scripts/update-refactor-status.py +15 -0
  11. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +19 -1
  12. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +19 -1
  13. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +22 -1
  14. package/bundled/dev-pipeline/templates/sections/phase-critic-plan-full.md +10 -0
  15. package/bundled/dev-pipeline/templates/sections/phase-critic-plan.md +10 -0
  16. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +12 -0
  17. package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +12 -0
  18. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +5 -1
  19. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +5 -1
  20. package/bundled/dev-pipeline/tests/test_auto_skip.py +39 -0
  21. package/bundled/dev-pipeline-windows/SCHEMA_ANALYSIS.md +1 -1
  22. package/bundled/dev-pipeline-windows/lib/common.ps1 +19 -0
  23. package/bundled/dev-pipeline-windows/lib/pipeline.ps1 +19 -3
  24. package/bundled/dev-pipeline-windows/scripts/parse-stream-progress.py +217 -18
  25. package/bundled/dev-pipeline-windows/scripts/update-bug-status.py +15 -0
  26. package/bundled/dev-pipeline-windows/scripts/update-feature-status.py +18 -0
  27. package/bundled/dev-pipeline-windows/scripts/update-refactor-status.py +15 -0
  28. package/bundled/dev-pipeline-windows/templates/refactor-bootstrap-prompt.md +22 -1
  29. package/bundled/dev-pipeline-windows/templates/sections/phase-critic-plan-full.md +10 -0
  30. package/bundled/dev-pipeline-windows/templates/sections/phase-critic-plan.md +10 -0
  31. package/bundled/dev-pipeline-windows/templates/sections/phase-implement-agent.md +12 -0
  32. package/bundled/dev-pipeline-windows/templates/sections/phase-implement-full.md +12 -0
  33. package/bundled/dev-pipeline-windows/templates/sections/phase-review-agent.md +5 -1
  34. package/bundled/dev-pipeline-windows/templates/sections/phase-review-full.md +5 -1
  35. package/bundled/skills/_metadata.json +1 -1
  36. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  {
2
- "frameworkVersion": "1.1.67",
3
- "bundledAt": "2026-06-09T02:37:28.761Z",
4
- "bundledFrom": "d4b8c30"
2
+ "frameworkVersion": "1.1.69",
3
+ "bundledAt": "2026-06-09T23:54:02.566Z",
4
+ "bundledFrom": "7453dba"
5
5
  }
@@ -418,6 +418,46 @@ prizm_start_ai_session() {
418
418
  PRIZM_AI_PID=$!
419
419
  }
420
420
 
421
+ # Detect AI CLI/provider infrastructure failures that are outside the
422
+ # generated code's control. These should be retried without consuming the
423
+ # item's code retry budget.
424
+ prizm_detect_infra_error() {
425
+ local session_log="${1:-}"
426
+ local progress_json="${2:-}"
427
+
428
+ local haystack=""
429
+ if [[ -n "$session_log" && -f "$session_log" ]]; then
430
+ haystack="$(tail -c 65536 "$session_log" 2>/dev/null || true)"
431
+ fi
432
+ if [[ -n "$progress_json" && -f "$progress_json" ]]; then
433
+ haystack+=$'\n'
434
+ haystack+="$(cat "$progress_json" 2>/dev/null || true)"
435
+ fi
436
+
437
+ [[ -n "$haystack" ]] || return 1
438
+
439
+ if printf '%s' "$haystack" | grep -Eiq \
440
+ 'auth_unavailable|no auth available|502 Bad Gateway|503 Service Unavailable|504 Gateway Timeout|gateway timeout|upstream (connect )?error|connection reset|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|rate limit|rate_limit|temporarily unavailable|overloaded'; then
441
+ return 0
442
+ fi
443
+
444
+ return 1
445
+ }
446
+
447
+ prizm_extract_update_new_status() {
448
+ python3 -c "
449
+ import json, sys
450
+ raw = sys.stdin.read()
451
+ try:
452
+ data = json.loads(raw)
453
+ except Exception:
454
+ sys.exit(0)
455
+ value = data.get('new_status')
456
+ if value:
457
+ print(value)
458
+ "
459
+ }
460
+
421
461
  # Run an AI CLI session synchronously.
422
462
  # Usage: prizm_run_ai_session <prompt_path> <log_path> <model>
423
463
  prizm_run_ai_session() {
@@ -90,8 +90,8 @@ PY
90
90
  fi
91
91
  prev_child_activity_signature="$child_activity_signature"
92
92
 
93
- # Track progress staleness. A Codex parent can sit in `wait`
94
- # while child transcripts keep growing, so child activity counts.
93
+ # Track progress staleness. Parent sessions can sit in a wait/polling
94
+ # tool while child transcripts keep growing, so child activity counts.
95
95
  if [[ $growth -eq 0 && $child_growth -eq 0 ]]; then
96
96
  stale_seconds=$((stale_seconds + heartbeat_interval))
97
97
  else
@@ -174,9 +174,9 @@ PY
174
174
  fi
175
175
 
176
176
  # Stale-kill: auto-terminate process if no progress for too long.
177
- # Codex parent sessions can sit on the `wait` tool while a spawned
178
- # subagent is still doing useful work. Give that valid wait a longer
179
- # stale window; normal single-agent stalls still use the base limit.
177
+ # Parent sessions can wait on spawned work; child transcript growth
178
+ # counts as progress above, while silent waits still use the active
179
+ # stale window to surface stuck agents promptly.
180
180
  if [[ $effective_stale_kill_threshold -gt 0 && $stale_seconds -ge $effective_stale_kill_threshold ]]; then
181
181
  local stale_mins=$((stale_seconds / 60))
182
182
  echo -e " ${RED}[HEARTBEAT]${NC} ${mins}m${secs}s | log: ${size_display} | ${RED}STALE-KILL: no progress for ${stale_mins}m (threshold: ${effective_stale_kill_threshold}s)${NC}"
@@ -145,6 +145,11 @@ spawn_and_wait_session() {
145
145
  log_warn "Session was stale-killed by heartbeat monitor (no progress for too long)"
146
146
  fi
147
147
 
148
+ local was_infra_error=false
149
+ if [[ $exit_code -ne 0 ]] && prizm_detect_infra_error "$session_log" "$progress_json"; then
150
+ was_infra_error=true
151
+ fi
152
+
148
153
  # Session summary
149
154
  if [[ -f "$session_log" ]]; then
150
155
  local final_size=$(wc -c < "$session_log" 2>/dev/null | tr -d ' ')
@@ -162,6 +167,10 @@ spawn_and_wait_session() {
162
167
  if [[ $exit_code -eq 124 ]]; then
163
168
  log_warn "Session timed out after ${SESSION_TIMEOUT}s"
164
169
  session_status="timed_out"
170
+ elif [[ "$was_infra_error" == true ]]; then
171
+ log_warn "Session failed due to AI CLI/provider infrastructure error"
172
+ log_warn "Infrastructure errors are retried without consuming code retry budget"
173
+ session_status="infra_error"
165
174
  elif [[ "$was_stale_killed" == true ]]; then
166
175
  log_warn "Session stale-killed (no progress for ${STALE_KILL_THRESHOLD}s)"
167
176
  log_warn "Stale-killed sessions are treated as failed; dev branch is preserved for inspection"
@@ -259,14 +268,20 @@ sys.exit(0)
259
268
  prizm_detect_subagents "$session_log"
260
269
 
261
270
  # Update bug status (do NOT commit on dev branch — commit happens after merge)
262
- python3 "$SCRIPTS_DIR/update-bug-status.py" \
271
+ local update_output
272
+ update_output=$(python3 "$SCRIPTS_DIR/update-bug-status.py" \
263
273
  --bug-list "$bug_list" \
264
274
  --state-dir "$STATE_DIR" \
265
275
  --bug-id "$bug_id" \
266
276
  --session-status "$session_status" \
267
277
  --session-id "$session_id" \
268
278
  --max-retries "$max_retries" \
269
- --action update >/dev/null 2>&1 || true
279
+ --action update 2>&1) || {
280
+ log_error "Failed to update bug status: $update_output"
281
+ update_output=""
282
+ }
283
+
284
+ _SPAWN_ITEM_STATUS="$(printf '%s' "$update_output" | prizm_extract_update_new_status)"
270
285
 
271
286
  _SPAWN_RESULT="$session_status"
272
287
  }
@@ -693,6 +708,7 @@ else:
693
708
  trap cleanup_single_bug SIGINT SIGTERM
694
709
 
695
710
  _SPAWN_RESULT=""
711
+ _SPAWN_ITEM_STATUS=""
696
712
 
697
713
  # Branch lifecycle: create and checkout bugfix branch
698
714
  local _proj_root
@@ -1078,12 +1094,14 @@ DEPLOY_PROMPT_EOF
1078
1094
  # Spawn session
1079
1095
  log_info "Spawning AI CLI session: $session_id"
1080
1096
  _SPAWN_RESULT=""
1097
+ _SPAWN_ITEM_STATUS=""
1081
1098
 
1082
1099
  spawn_and_wait_session \
1083
1100
  "$bug_id" "$bug_list" "$session_id" \
1084
1101
  "$bootstrap_prompt" "$session_dir" "$MAX_RETRIES" "$bug_model" "$_ORIGINAL_BRANCH"
1085
1102
 
1086
1103
  local session_status="$_SPAWN_RESULT"
1104
+ local item_status_after_session="${_SPAWN_ITEM_STATUS:-}"
1087
1105
 
1088
1106
  # Merge per-bug dev branch back to original on success
1089
1107
  if [[ "$session_status" == "success" && -n "$_DEV_BRANCH_NAME" ]]; then
@@ -1112,15 +1130,18 @@ DEPLOY_PROMPT_EOF
1112
1130
  session_count=$((session_count + 1))
1113
1131
  total_subagent_calls=$((total_subagent_calls + _SUBAGENT_COUNT))
1114
1132
 
1115
- # Stop-on-failure: abort pipeline if task failed and STOP_ON_FAILURE is enabled
1116
- if [[ "$session_status" != "success" && "$STOP_ON_FAILURE" == "1" ]]; then
1133
+ # Stop-on-failure: abort only after the task is actually marked failed.
1134
+ # Pending retry outcomes, including infrastructure errors, keep running.
1135
+ if [[ "$session_status" != "success" && "$STOP_ON_FAILURE" == "1" && "$item_status_after_session" == "failed" ]]; then
1117
1136
  echo ""
1118
1137
  log_error "════════════════════════════════════════════════════"
1119
- log_error " STOP_ON_FAILURE: Pipeline halted after $bug_id failed."
1138
+ log_error " STOP_ON_FAILURE: Pipeline halted after $bug_id exhausted retries."
1120
1139
  log_error " Total sessions completed: $session_count"
1121
1140
  log_error " Set STOP_ON_FAILURE=0 to continue past failures."
1122
1141
  log_error "════════════════════════════════════════════════════"
1123
1142
  break
1143
+ elif [[ "$session_status" != "success" && "$STOP_ON_FAILURE" == "1" ]]; then
1144
+ log_info "STOP_ON_FAILURE: $bug_id is ${item_status_after_session:-unknown}; retry budget not exhausted, continuing."
1124
1145
  fi
1125
1146
 
1126
1147
  # Stuck detection
@@ -153,6 +153,11 @@ spawn_and_wait_session() {
153
153
  log_warn "Session was stale-killed by heartbeat monitor (no progress for too long)"
154
154
  fi
155
155
 
156
+ local was_infra_error=false
157
+ if [[ $exit_code -ne 0 ]] && prizm_detect_infra_error "$session_log" "$progress_json"; then
158
+ was_infra_error=true
159
+ fi
160
+
156
161
  # Show final session summary
157
162
  if [[ -f "$session_log" ]]; then
158
163
  local final_size=$(wc -c < "$session_log" 2>/dev/null | tr -d ' ')
@@ -172,6 +177,10 @@ spawn_and_wait_session() {
172
177
  if [[ $exit_code -eq 124 ]]; then
173
178
  log_warn "Session timed out after ${SESSION_TIMEOUT}s"
174
179
  session_status="timed_out"
180
+ elif [[ "$was_infra_error" == true ]]; then
181
+ log_warn "Session failed due to AI CLI/provider infrastructure error"
182
+ log_warn "Infrastructure errors are retried without consuming code retry budget"
183
+ session_status="infra_error"
175
184
  elif [[ "$was_stale_killed" == true ]]; then
176
185
  log_warn "Session stale-killed (no progress for ${STALE_KILL_THRESHOLD}s)"
177
186
  log_warn "Stale-killed sessions are treated as failed; dev branch is preserved for inspection"
@@ -347,6 +356,8 @@ sys.exit(0)
347
356
  log_error ".prizmkit/plans/feature-list.json may be out of sync. Manual intervention needed."
348
357
  }
349
358
 
359
+ _SPAWN_ITEM_STATUS="$(printf '%s' "$update_output" | prizm_extract_update_new_status)"
360
+
350
361
  # Return status via global variable (avoids $() swallowing stdout)
351
362
  _SPAWN_RESULT="$session_status"
352
363
  }
@@ -848,6 +859,7 @@ else:
848
859
  trap cleanup_single_feature SIGINT SIGTERM
849
860
 
850
861
  _SPAWN_RESULT=""
862
+ _SPAWN_ITEM_STATUS=""
851
863
 
852
864
  # Branch lifecycle: create and checkout feature branch
853
865
  local _proj_root
@@ -1300,11 +1312,13 @@ DEPLOY_PROMPT_EOF
1300
1312
  log_info "Feature model: $feature_model"
1301
1313
  fi
1302
1314
  _SPAWN_RESULT=""
1315
+ _SPAWN_ITEM_STATUS=""
1303
1316
 
1304
1317
  spawn_and_wait_session \
1305
1318
  "$feature_id" "$feature_list" "$session_id" \
1306
1319
  "$bootstrap_prompt" "$session_dir" "$MAX_RETRIES" "$feature_model" "$_ORIGINAL_BRANCH"
1307
1320
  local session_status="$_SPAWN_RESULT"
1321
+ local item_status_after_session="${_SPAWN_ITEM_STATUS:-}"
1308
1322
 
1309
1323
  # Merge per-feature dev branch back to original on success
1310
1324
  if [[ "$session_status" == "success" && -n "$_DEV_BRANCH_NAME" ]]; then
@@ -1333,15 +1347,18 @@ DEPLOY_PROMPT_EOF
1333
1347
  session_count=$((session_count + 1))
1334
1348
  total_subagent_calls=$((total_subagent_calls + _SUBAGENT_COUNT))
1335
1349
 
1336
- # Stop-on-failure: abort pipeline if task failed and STOP_ON_FAILURE is enabled
1337
- if [[ "$session_status" != "success" && "$STOP_ON_FAILURE" == "1" ]]; then
1350
+ # Stop-on-failure: abort only after the task is actually marked failed.
1351
+ # Pending retry outcomes, including infrastructure errors, keep running.
1352
+ if [[ "$session_status" != "success" && "$STOP_ON_FAILURE" == "1" && "$item_status_after_session" == "failed" ]]; then
1338
1353
  echo ""
1339
1354
  log_error "════════════════════════════════════════════════════"
1340
- log_error " STOP_ON_FAILURE: Pipeline halted after $feature_id failed."
1355
+ log_error " STOP_ON_FAILURE: Pipeline halted after $feature_id exhausted retries."
1341
1356
  log_error " Total sessions completed: $session_count"
1342
1357
  log_error " Set STOP_ON_FAILURE=0 to continue past failures."
1343
1358
  log_error "════════════════════════════════════════════════════"
1344
1359
  break
1360
+ elif [[ "$session_status" != "success" && "$STOP_ON_FAILURE" == "1" ]]; then
1361
+ log_info "STOP_ON_FAILURE: $feature_id is ${item_status_after_session:-unknown}; retry budget not exhausted, continuing."
1345
1362
  fi
1346
1363
 
1347
1364
  # Brief pause before next iteration
@@ -147,6 +147,11 @@ spawn_and_wait_session() {
147
147
  log_warn "Session was stale-killed by heartbeat monitor (no progress for too long)"
148
148
  fi
149
149
 
150
+ local was_infra_error=false
151
+ if [[ $exit_code -ne 0 ]] && prizm_detect_infra_error "$session_log" "$progress_json"; then
152
+ was_infra_error=true
153
+ fi
154
+
150
155
  # Session summary
151
156
  if [[ -f "$session_log" ]]; then
152
157
  local final_size=$(wc -c < "$session_log" 2>/dev/null | tr -d ' ')
@@ -164,6 +169,10 @@ spawn_and_wait_session() {
164
169
  if [[ $exit_code -eq 124 ]]; then
165
170
  log_warn "Session timed out after ${SESSION_TIMEOUT}s"
166
171
  session_status="timed_out"
172
+ elif [[ "$was_infra_error" == true ]]; then
173
+ log_warn "Session failed due to AI CLI/provider infrastructure error"
174
+ log_warn "Infrastructure errors are retried without consuming code retry budget"
175
+ session_status="infra_error"
167
176
  elif [[ "$was_stale_killed" == true ]]; then
168
177
  log_warn "Session stale-killed (no progress for ${STALE_KILL_THRESHOLD}s)"
169
178
  log_warn "Stale-killed sessions are treated as failed; dev branch is preserved for inspection"
@@ -286,14 +295,20 @@ sys.exit(0)
286
295
  fi
287
296
 
288
297
  # Update refactor status (do NOT commit on dev branch — commit happens after merge)
289
- python3 "$SCRIPTS_DIR/update-refactor-status.py" \
298
+ local update_output
299
+ update_output=$(python3 "$SCRIPTS_DIR/update-refactor-status.py" \
290
300
  --refactor-list "$refactor_list" \
291
301
  --state-dir "$STATE_DIR" \
292
302
  --refactor-id "$refactor_id" \
293
303
  --session-status "$session_status" \
294
304
  --session-id "$session_id" \
295
305
  --max-retries "$max_retries" \
296
- --action update >/dev/null 2>&1 || true
306
+ --action update 2>&1) || {
307
+ log_error "Failed to update refactor status: $update_output"
308
+ update_output=""
309
+ }
310
+
311
+ _SPAWN_ITEM_STATUS="$(printf '%s' "$update_output" | prizm_extract_update_new_status)"
297
312
 
298
313
  _SPAWN_RESULT="$session_status"
299
314
  }
@@ -723,6 +738,7 @@ else:
723
738
  trap cleanup_single_refactor SIGINT SIGTERM
724
739
 
725
740
  _SPAWN_RESULT=""
741
+ _SPAWN_ITEM_STATUS=""
726
742
 
727
743
  # Branch lifecycle: create and checkout refactor branch
728
744
  local _proj_root
@@ -1114,6 +1130,7 @@ DEPLOY_PROMPT_EOF
1114
1130
  # Spawn session
1115
1131
  log_info "Spawning AI CLI session: $session_id"
1116
1132
  _SPAWN_RESULT=""
1133
+ _SPAWN_ITEM_STATUS=""
1117
1134
 
1118
1135
  spawn_and_wait_session \
1119
1136
  "$refactor_id" "$refactor_list" "$session_id" \
@@ -1130,6 +1147,7 @@ DEPLOY_PROMPT_EOF
1130
1147
  fi
1131
1148
 
1132
1149
  local session_status="$_SPAWN_RESULT"
1150
+ local item_status_after_session="${_SPAWN_ITEM_STATUS:-}"
1133
1151
 
1134
1152
  # Merge per-refactor dev branch back to original on success
1135
1153
  if [[ "$session_status" == "success" && -n "$_DEV_BRANCH_NAME" ]]; then
@@ -1168,15 +1186,18 @@ DEPLOY_PROMPT_EOF
1168
1186
  session_count=$((session_count + 1))
1169
1187
  total_subagent_calls=$((total_subagent_calls + _SUBAGENT_COUNT))
1170
1188
 
1171
- # Stop-on-failure: abort pipeline if task failed and STOP_ON_FAILURE is enabled
1172
- if [[ "$session_status" != "success" && "$STOP_ON_FAILURE" == "1" ]]; then
1189
+ # Stop-on-failure: abort only after the task is actually marked failed.
1190
+ # Pending retry outcomes, including infrastructure errors, keep running.
1191
+ if [[ "$session_status" != "success" && "$STOP_ON_FAILURE" == "1" && "$item_status_after_session" == "failed" ]]; then
1173
1192
  echo ""
1174
1193
  log_error "════════════════════════════════════════════════════"
1175
- log_error " STOP_ON_FAILURE: Pipeline halted after $refactor_id failed."
1194
+ log_error " STOP_ON_FAILURE: Pipeline halted after $refactor_id exhausted retries."
1176
1195
  log_error " Total sessions completed: $session_count"
1177
1196
  log_error " Set STOP_ON_FAILURE=0 to continue past failures."
1178
1197
  log_error "════════════════════════════════════════════════════"
1179
1198
  break
1199
+ elif [[ "$session_status" != "success" && "$STOP_ON_FAILURE" == "1" ]]; then
1200
+ log_info "STOP_ON_FAILURE: $refactor_id is ${item_status_after_session:-unknown}; retry budget not exhausted, continuing."
1180
1201
  fi
1181
1202
 
1182
1203
  log_info "Pausing 5s before next refactor..."
@@ -63,7 +63,8 @@ PHASE_KEYWORDS = {
63
63
  class ProgressTracker:
64
64
  """Tracks progress state from stream-json events."""
65
65
 
66
- def __init__(self):
66
+ def __init__(self, session_log=None):
67
+ self.session_log_path = Path(session_log).expanduser() if session_log else None
67
68
  self.message_count = 0
68
69
  self.current_tool = None
69
70
  self.current_tool_input_summary = ""
@@ -78,12 +79,19 @@ class ProgressTracker:
78
79
  self.active_subagent_count = 0
79
80
  self.subagent_status_counts = Counter()
80
81
  self.codex_child_thread_ids = set()
82
+ self.claude_session_id = ""
83
+ self.claude_cwd = ""
84
+ self.claude_task_states = {}
81
85
  self.child_session_files = []
82
86
  self.child_total_bytes = 0
83
87
  self.child_activity_signature = ""
84
88
  self.last_child_activity_at = ""
85
89
  self._codex_child_session_paths = {}
90
+ self._claude_child_session_files = []
86
91
  self._last_child_scan_at = 0.0
92
+ self._last_claude_fallback_scan_at = 0.0
93
+ self._last_claude_fallback_scan_key = ""
94
+ self._claude_fallback_scan_interval_seconds = 10.0
87
95
  self._text_buffer = ""
88
96
  self._in_tool_use = False
89
97
  self._current_tool_input_parts = []
@@ -195,11 +203,76 @@ class ProgressTracker:
195
203
  self.is_active = True
196
204
 
197
205
  elif event_type == "system":
198
- # System events (hooks, init, etc.) — track but don't count as messages
206
+ # System events (hooks, init, task notifications, etc.) — track but don't count as messages.
199
207
  self.event_format = self.event_format or "stream-json"
200
208
  subtype = event.get("subtype", "")
201
209
  if subtype == "init":
202
210
  self.is_active = True
211
+ session_id = event.get("session_id")
212
+ if isinstance(session_id, str) and session_id.strip():
213
+ self.claude_session_id = session_id.strip()
214
+ cwd = event.get("cwd")
215
+ if isinstance(cwd, str) and cwd.strip():
216
+ self.claude_cwd = cwd.strip()
217
+ elif subtype == "task_started":
218
+ task_id = event.get("task_id")
219
+ if isinstance(task_id, str) and task_id.strip():
220
+ self.claude_task_states[task_id.strip()] = {
221
+ "status": "running",
222
+ "summary": str(event.get("description") or "")[:120],
223
+ "tool_use_id": str(event.get("tool_use_id") or ""),
224
+ "task_type": str(event.get("task_type") or ""),
225
+ "subagent_type": str(event.get("subagent_type") or ""),
226
+ }
227
+ self._update_claude_subagent_status_counts()
228
+ elif subtype in ("task_updated", "task_progress"):
229
+ task_id = event.get("task_id")
230
+ if isinstance(task_id, str) and task_id.strip():
231
+ state = self.claude_task_states.setdefault(task_id.strip(), {})
232
+ patch = event.get("patch") if isinstance(event.get("patch"), dict) else {}
233
+ status = patch.get("status") or event.get("status")
234
+ if status:
235
+ state["status"] = str(status)
236
+ summary = patch.get("summary") or patch.get("description") or event.get("summary") or event.get("description")
237
+ if summary:
238
+ state["summary"] = str(summary)[:120]
239
+ else:
240
+ state.setdefault("summary", "")
241
+ tool_use_id = patch.get("tool_use_id") or event.get("tool_use_id")
242
+ if tool_use_id:
243
+ state["tool_use_id"] = str(tool_use_id)
244
+ else:
245
+ state.setdefault("tool_use_id", "")
246
+ task_type = patch.get("task_type") or event.get("task_type")
247
+ if task_type:
248
+ state["task_type"] = str(task_type)
249
+ else:
250
+ state.setdefault("task_type", "")
251
+ subagent_type = patch.get("subagent_type") or event.get("subagent_type")
252
+ if subagent_type:
253
+ state["subagent_type"] = str(subagent_type)
254
+ else:
255
+ state.setdefault("subagent_type", "")
256
+ self._update_claude_subagent_status_counts()
257
+ elif subtype == "task_notification":
258
+ task_id = event.get("task_id")
259
+ if isinstance(task_id, str) and task_id.strip():
260
+ state = self.claude_task_states.setdefault(task_id.strip(), {})
261
+ status = event.get("status") or "completed"
262
+ state["status"] = str(status)
263
+ state["summary"] = str(event.get("summary") or state.get("summary") or "")[:120]
264
+ state.setdefault("tool_use_id", str(event.get("tool_use_id") or ""))
265
+ task_type = event.get("task_type")
266
+ if task_type:
267
+ state["task_type"] = str(task_type)
268
+ else:
269
+ state.setdefault("task_type", "")
270
+ subagent_type = event.get("subagent_type")
271
+ if subagent_type:
272
+ state["subagent_type"] = str(subagent_type)
273
+ else:
274
+ state.setdefault("subagent_type", "")
275
+ self._update_claude_subagent_status_counts()
203
276
 
204
277
  # ── Claude API raw stream format ────────────────────────────
205
278
  elif event_type == "message_start":
@@ -391,16 +464,135 @@ class ProgressTracker:
391
464
  pass
392
465
  return str(matches[0])
393
466
 
467
+ def _is_tracked_claude_subagent_state(self, state):
468
+ """Return true for Claude Code task events representing in-process agents."""
469
+ if not isinstance(state, dict):
470
+ return False
471
+ task_type = str(state.get("task_type") or "")
472
+ task_type_lower = task_type.lower()
473
+ subagent_type = str(state.get("subagent_type") or "")
474
+ if task_type_lower == "local_bash":
475
+ return False
476
+ tracked_types = {"in_process_teammate", "subagent", "agent", "teammate"}
477
+ if task_type_lower in tracked_types:
478
+ return True
479
+ if task_type_lower == "local_agent" and subagent_type:
480
+ return True
481
+ summary = str(state.get("summary") or "")
482
+ return bool(
483
+ not task_type
484
+ and summary.lower().startswith(("dev:", "critic:", "reviewer:", "agent:"))
485
+ )
486
+
487
+ def _has_tracked_claude_subagent_task(self):
488
+ """Return true once a Claude Code local-agent/subagent task has been observed."""
489
+ return any(
490
+ self._is_tracked_claude_subagent_state(state)
491
+ for state in self.claude_task_states.values()
492
+ )
493
+
494
+ def _update_claude_subagent_status_counts(self):
495
+ """Track Claude Code in-process teammate task state counts."""
496
+ counts = Counter()
497
+ active = 0
498
+ inactive_statuses = {
499
+ "completed",
500
+ "failed",
501
+ "cancelled",
502
+ "canceled",
503
+ "killed",
504
+ "stopped",
505
+ "success",
506
+ "error",
507
+ }
508
+ for state in self.claude_task_states.values():
509
+ if not self._is_tracked_claude_subagent_state(state):
510
+ continue
511
+ status = str(state.get("status") or "unknown")
512
+ counts[status] += 1
513
+ if status.lower() not in inactive_statuses:
514
+ active += 1
515
+ summary = state.get("summary") or state.get("subagent_type")
516
+ if summary:
517
+ self.last_text_snippet = str(summary).strip()[:120]
518
+ self._detect_phase(str(summary))
519
+ self.subagent_status_counts = counts
520
+ self.active_subagent_count = active
521
+
522
+ def _claude_projects_dir(self):
523
+ """Return the Claude Code projects directory for transcript lookup."""
524
+ projects_dir = os.environ.get("CLAUDE_PROJECTS_DIR")
525
+ if projects_dir:
526
+ return Path(projects_dir).expanduser()
527
+ claude_config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
528
+ if claude_config_dir:
529
+ return Path(claude_config_dir).expanduser() / "projects"
530
+ claude_home = os.environ.get("CLAUDE_HOME")
531
+ if claude_home:
532
+ return Path(claude_home).expanduser() / "projects"
533
+ return Path.home() / ".claude" / "projects"
534
+
535
+ def _claude_project_key(self):
536
+ """Encode cwd the same way Claude Code stores project transcript dirs."""
537
+ cwd = self.claude_cwd
538
+ if not cwd:
539
+ return ""
540
+ return cwd.replace("\\", "-").replace("/", "-").replace(":", "")
541
+
542
+ def _find_claude_child_session_files(self):
543
+ """Find Claude Code subagent transcripts for this parent session."""
544
+ if not self.claude_session_id:
545
+ return []
546
+
547
+ projects_dir = self._claude_projects_dir()
548
+ if not projects_dir.exists():
549
+ return []
550
+
551
+ candidates = []
552
+ project_key = self._claude_project_key()
553
+ if project_key:
554
+ candidates.append(
555
+ projects_dir / project_key / self.claude_session_id / "subagents"
556
+ )
557
+
558
+ for candidate in candidates:
559
+ if candidate.exists():
560
+ try:
561
+ return sorted(candidate.glob("*.jsonl"))
562
+ except OSError:
563
+ return []
564
+
565
+ # Fallback for non-standard cwd encoding or custom Claude homes. Avoid
566
+ # repeatedly walking every stored transcript before any Agent task exists.
567
+ if not self._has_tracked_claude_subagent_task():
568
+ return []
569
+
570
+ fallback_scan_key = f"{projects_dir}:{self.claude_session_id}"
571
+ now = time.monotonic()
572
+ if (
573
+ self._last_claude_fallback_scan_key == fallback_scan_key
574
+ and now - self._last_claude_fallback_scan_at < self._claude_fallback_scan_interval_seconds
575
+ ):
576
+ return self._claude_child_session_files
577
+ self._last_claude_fallback_scan_key = fallback_scan_key
578
+ self._last_claude_fallback_scan_at = now
579
+ try:
580
+ matches = sorted(projects_dir.rglob(f"{self.claude_session_id}/subagents/*.jsonl"))
581
+ except OSError:
582
+ return []
583
+ return matches
584
+
394
585
  def refresh_child_session_activity(self, force=False):
395
- """Refresh Codex child transcript file stats.
586
+ """Refresh child transcript file stats.
396
587
 
397
588
  The heartbeat monitor uses this activity signature to treat subagent
398
- transcript growth as real progress while the parent Codex session is
399
- blocked in `wait`.
589
+ transcript growth as real progress while the parent session is blocked
590
+ waiting for a child agent/tool result. Supports Codex child threads and
591
+ Claude Code in-process teammate transcripts.
400
592
  """
401
593
  previous_signature = self.child_activity_signature
402
594
 
403
- if not self.codex_child_thread_ids:
595
+ if not self.codex_child_thread_ids and not self.claude_session_id:
404
596
  self.child_session_files = []
405
597
  self.child_total_bytes = 0
406
598
  self.child_activity_signature = ""
@@ -420,6 +612,7 @@ class ProgressTracker:
420
612
  found = self._find_codex_child_session_file(thread_id)
421
613
  if found:
422
614
  self._codex_child_session_paths[thread_id] = found
615
+ self._claude_child_session_files = self._find_claude_child_session_files()
423
616
  self._last_child_scan_at = now
424
617
 
425
618
  files = []
@@ -427,24 +620,22 @@ class ProgressTracker:
427
620
  total_bytes = 0
428
621
  max_mtime = 0.0
429
622
 
430
- for thread_id in sorted(self.codex_child_thread_ids):
431
- path = self._codex_child_session_paths.get(thread_id)
432
- if not path:
433
- continue
623
+ def add_file(kind, identifier, path):
624
+ nonlocal total_bytes, max_mtime
434
625
  try:
435
626
  stat = os.stat(path)
436
627
  except OSError:
437
- continue
438
-
628
+ return
629
+ path_str = str(path)
439
630
  total_bytes += stat.st_size
440
631
  max_mtime = max(max_mtime, stat.st_mtime)
441
- signature_parts.append(
442
- f"{thread_id}:{stat.st_size}:{getattr(stat, 'st_mtime_ns', int(stat.st_mtime * 1_000_000_000))}"
443
- )
632
+ mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))
633
+ signature_parts.append(f"{kind}:{identifier}:{stat.st_size}:{mtime_ns}")
444
634
  files.append(
445
635
  {
446
- "thread_id": thread_id,
447
- "path": path,
636
+ "kind": kind,
637
+ "thread_id": identifier,
638
+ "path": path_str,
448
639
  "size": stat.st_size,
449
640
  "mtime": datetime.fromtimestamp(
450
641
  stat.st_mtime, timezone.utc
@@ -452,6 +643,14 @@ class ProgressTracker:
452
643
  }
453
644
  )
454
645
 
646
+ for thread_id in sorted(self.codex_child_thread_ids):
647
+ path = self._codex_child_session_paths.get(thread_id)
648
+ if path:
649
+ add_file("codex", thread_id, path)
650
+
651
+ for path in self._claude_child_session_files:
652
+ add_file("claude", path.stem, path)
653
+
455
654
  self.child_session_files = files
456
655
  self.child_total_bytes = total_bytes
457
656
  self.child_activity_signature = "|".join(signature_parts)
@@ -519,7 +718,7 @@ def atomic_write_json(data, filepath):
519
718
 
520
719
  def tail_and_parse(session_log, progress_file, poll_interval=0.5):
521
720
  """Tail session log and parse stream-json events."""
522
- tracker = ProgressTracker()
721
+ tracker = ProgressTracker(session_log)
523
722
  last_write_state = None
524
723
 
525
724
  def state_key(state):