prizmkit 1.1.20 → 1.1.23

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.
@@ -262,8 +262,15 @@ if incomplete:
262
262
  sys.exit(1)
263
263
  print('ALL_COMPLETE')
264
264
  sys.exit(0)
265
- " "$checkpoint_file" 2>&1)
265
+ " "$checkpoint_file" 2>&1) || checkpoint_result="CHECK_FAILED"
266
266
  local check_exit=$?
267
+ if [[ "$checkpoint_result" == "CHECK_FAILED" ]]; then
268
+ check_exit=2
269
+ elif [[ "$checkpoint_result" == *"INCOMPLETE"* ]]; then
270
+ check_exit=1
271
+ else
272
+ check_exit=0
273
+ fi
267
274
  if [[ $check_exit -eq 2 ]]; then
268
275
  log_warn "CHECKPOINT_CORRUPTED: workflow-checkpoint.json is not valid JSON"
269
276
  elif [[ $check_exit -eq 1 ]]; then
@@ -297,7 +304,7 @@ sys.exit(0)
297
304
  fi
298
305
  fi
299
306
 
300
- # Update refactor status
307
+ # Update refactor status (do NOT commit on dev branch — commit happens after merge)
301
308
  python3 "$SCRIPTS_DIR/update-refactor-status.py" \
302
309
  --refactor-list "$refactor_list" \
303
310
  --state-dir "$STATE_DIR" \
@@ -307,12 +314,6 @@ sys.exit(0)
307
314
  --max-retries "$max_retries" \
308
315
  --action update >/dev/null 2>&1 || true
309
316
 
310
- # Commit .prizmkit/plans/refactor-list.json status update (pipeline management commit)
311
- if ! git -C "$project_root" diff --quiet "$refactor_list" 2>/dev/null; then
312
- git -C "$project_root" add "$refactor_list"
313
- git -C "$project_root" commit --no-verify -m "chore($refactor_id): update refactor status" 2>/dev/null || true
314
- fi
315
-
316
317
  _SPAWN_RESULT="$session_status"
317
318
  }
318
319
 
@@ -333,13 +334,40 @@ cleanup() {
333
334
  log_info "Original branch was: $_ORIGINAL_BRANCH"
334
335
  fi
335
336
 
337
+ # Update status of currently in-progress refactor to interrupted
336
338
  if [[ -n "$REFACTOR_LIST" && -f "$REFACTOR_LIST" ]]; then
339
+ # Find any in-progress refactor and mark it as failed
340
+ local _interrupted_id
341
+ _interrupted_id=$(python3 -c "
342
+ import json, sys
343
+ with open(sys.argv[1]) as f:
344
+ data = json.load(f)
345
+ for item in data.get('refactors', []):
346
+ if item.get('status') == 'in_progress':
347
+ print(item['id'])
348
+ break
349
+ " "$REFACTOR_LIST" 2>/dev/null || echo "")
350
+
351
+ if [[ -n "$_interrupted_id" ]]; then
352
+ python3 "$SCRIPTS_DIR/update-refactor-status.py" \
353
+ --refactor-list "$REFACTOR_LIST" \
354
+ --state-dir "$STATE_DIR" \
355
+ --refactor-id "$_interrupted_id" \
356
+ --session-status "failed" \
357
+ --action update 2>/dev/null || true
358
+ log_info "Refactor $_interrupted_id marked as failed due to interrupt"
359
+ fi
360
+
361
+ # Pause the pipeline
337
362
  python3 "$SCRIPTS_DIR/update-refactor-status.py" \
338
363
  --refactor-list "$REFACTOR_LIST" \
339
364
  --state-dir "$STATE_DIR" \
340
365
  --action pause 2>/dev/null || true
341
366
  fi
342
367
 
368
+ # GUARANTEED: always return to original branch (save WIP on dev branch first)
369
+ branch_ensure_return "$(cd "$SCRIPT_DIR/.." && pwd)" "$_ORIGINAL_BRANCH" "$_DEV_BRANCH_NAME"
370
+
343
371
  log_info "Refactor pipeline paused. Run './run-refactor.sh run' to resume."
344
372
  exit 130
345
373
  }
@@ -669,6 +697,20 @@ else:
669
697
  log_info "Development was on branch: $_DEV_BRANCH_NAME"
670
698
  fi
671
699
  log_info "Session log: $session_dir/logs/session.log"
700
+
701
+ # Update refactor status to failed on interrupt
702
+ if [[ -n "$refactor_list" && -f "$refactor_list" ]]; then
703
+ python3 "$SCRIPTS_DIR/update-refactor-status.py" \
704
+ --refactor-list "$refactor_list" \
705
+ --state-dir "$STATE_DIR" \
706
+ --refactor-id "$refactor_id" \
707
+ --session-status "failed" \
708
+ --action update 2>/dev/null || true
709
+ log_info "Refactor $refactor_id marked as failed due to interrupt"
710
+ fi
711
+
712
+ # GUARANTEED: always return to original branch (save WIP on dev branch first)
713
+ branch_ensure_return "$(cd "$SCRIPT_DIR/.." && pwd)" "$_ORIGINAL_BRANCH" "$_DEV_BRANCH_NAME"
672
714
  exit 130
673
715
  }
674
716
  trap cleanup_single_refactor SIGINT SIGTERM
@@ -682,6 +724,20 @@ else:
682
724
  _source_branch=$(git -C "$_proj_root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "main")
683
725
  _ORIGINAL_BRANCH="$_source_branch"
684
726
 
727
+ # Mark refactor as in-progress BEFORE creating dev branch
728
+ # This ensures the in_progress status commit lands on the original branch,
729
+ # not the dev branch — preventing rebase conflicts in branch_merge later.
730
+ python3 "$SCRIPTS_DIR/update-refactor-status.py" \
731
+ --refactor-list "$refactor_list" \
732
+ --state-dir "$STATE_DIR" \
733
+ --refactor-id "$refactor_id" \
734
+ --action start >/dev/null 2>&1 || true
735
+ # Commit the in_progress status on the original branch
736
+ if ! git -C "$_proj_root" diff --quiet "$refactor_list" 2>/dev/null; then
737
+ git -C "$_proj_root" add "$refactor_list" 2>/dev/null || true
738
+ git -C "$_proj_root" commit --no-verify -m "chore($refactor_id): mark in_progress" 2>/dev/null || true
739
+ fi
740
+
685
741
  local _branch_name="${DEV_BRANCH:-refactor/${refactor_id}-$(date +%s)}"
686
742
  if branch_create "$_proj_root" "$_branch_name" "$_source_branch"; then
687
743
  _DEV_BRANCH_NAME="$_branch_name"
@@ -701,18 +757,23 @@ else:
701
757
  else
702
758
  log_warn "Auto-merge failed — dev branch preserved: $_DEV_BRANCH_NAME"
703
759
  log_warn "Merge manually: git checkout $_ORIGINAL_BRANCH && git rebase $_DEV_BRANCH_NAME"
704
- git -C "$_proj_root" checkout "$_ORIGINAL_BRANCH" 2>/dev/null || true
705
760
  _DEV_BRANCH_NAME=""
706
761
  fi
707
762
  elif [[ -n "$_DEV_BRANCH_NAME" ]]; then
708
- # Session failed — return to original branch, preserve dev branch for inspection
709
- if ! git -C "$_proj_root" checkout "$_ORIGINAL_BRANCH" 2>/dev/null; then
710
- log_warn "Failed to checkout $_ORIGINAL_BRANCH after session failure — staying on dev branch"
711
- fi
763
+ # Session failed — preserve dev branch for inspection
712
764
  log_warn "Session failed — dev branch preserved for inspection: $_DEV_BRANCH_NAME"
713
765
  _DEV_BRANCH_NAME=""
714
766
  fi
715
767
 
768
+ # GUARANTEED: always return to original branch regardless of success/failure/merge outcome
769
+ branch_ensure_return "$_proj_root" "$_ORIGINAL_BRANCH"
770
+
771
+ # Commit refactor status update on the original branch (after guaranteed return)
772
+ if ! git -C "$_proj_root" diff --quiet "$refactor_list" 2>/dev/null; then
773
+ git -C "$_proj_root" add "$refactor_list"
774
+ git -C "$_proj_root" commit --no-verify -m "chore($refactor_id): update refactor status" 2>/dev/null || true
775
+ fi
776
+
716
777
  echo ""
717
778
  if [[ "$session_status" == "success" ]]; then
718
779
  log_success "════════════════════════════════════════════════════"
@@ -820,6 +881,18 @@ main() {
820
881
  local total_subagent_calls=0
821
882
 
822
883
  while true; do
884
+ # Safety net: ensure we're on the original branch at the start of each iteration.
885
+ # If a previous iteration's `continue` skipped branch_ensure_return, we could
886
+ # still be on a dev branch. This prevents cascading branch confusion.
887
+ if [[ -n "$_ORIGINAL_BRANCH" ]]; then
888
+ local _cur_branch
889
+ _cur_branch=$(git -C "$_proj_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
890
+ if [[ -n "$_cur_branch" && "$_cur_branch" != "$_ORIGINAL_BRANCH" ]]; then
891
+ log_warn "Still on branch $_cur_branch at loop start — returning to $_ORIGINAL_BRANCH"
892
+ branch_ensure_return "$_proj_root" "$_ORIGINAL_BRANCH"
893
+ fi
894
+ fi
895
+
823
896
  # Find next refactor to process (dependency-topological order)
824
897
  local next_refactor
825
898
  if ! next_refactor=$(python3 "$SCRIPTS_DIR/update-refactor-status.py" \
@@ -874,7 +947,21 @@ main() {
874
947
  git -C "$_proj_root" commit --no-verify -m "chore: capture artifacts before $refactor_id session" 2>/dev/null || true
875
948
  fi
876
949
 
877
- # Create per-refactor dev branch
950
+ # Mark refactor as in-progress BEFORE creating dev branch
951
+ # This ensures the in_progress status commit lands on the original branch,
952
+ # not the dev branch — preventing rebase conflicts in branch_merge later.
953
+ python3 "$SCRIPTS_DIR/update-refactor-status.py" \
954
+ --refactor-list "$refactor_list" \
955
+ --state-dir "$STATE_DIR" \
956
+ --refactor-id "$refactor_id" \
957
+ --action start >/dev/null 2>&1 || true
958
+ # Commit the in_progress status on the original branch
959
+ if ! git -C "$_proj_root" diff --quiet "$refactor_list" 2>/dev/null; then
960
+ git -C "$_proj_root" add "$refactor_list" 2>/dev/null || true
961
+ git -C "$_proj_root" commit --no-verify -m "chore($refactor_id): mark in_progress" 2>/dev/null || true
962
+ fi
963
+
964
+ # Create per-refactor dev branch (from the now-updated original branch)
878
965
  local _refactor_branch="${DEV_BRANCH:-refactor/${refactor_id}-$(date +%Y%m%d%H%M)}"
879
966
  if branch_create "$_proj_root" "$_refactor_branch" "$_ORIGINAL_BRANCH"; then
880
967
  _DEV_BRANCH_NAME="$_refactor_branch"
@@ -920,6 +1007,8 @@ main() {
920
1007
  local gen_output
921
1008
  gen_output=$(python3 "$SCRIPTS_DIR/generate-refactor-prompt.py" "${main_prompt_args[@]}" 2>/dev/null) || {
922
1009
  log_error "Failed to generate bootstrap prompt for $refactor_id"
1010
+ branch_ensure_return "$_proj_root" "$_ORIGINAL_BRANCH"
1011
+ _DEV_BRANCH_NAME=""
923
1012
  continue
924
1013
  }
925
1014
  local refactor_model pipeline_mode agent_count critic_enabled
@@ -943,13 +1032,6 @@ main() {
943
1032
  log_info "Refactor model: $refactor_model"
944
1033
  fi
945
1034
 
946
- # Mark refactor as in-progress before spawning session
947
- python3 "$SCRIPTS_DIR/update-refactor-status.py" \
948
- --refactor-list "$refactor_list" \
949
- --state-dir "$STATE_DIR" \
950
- --refactor-id "$refactor_id" \
951
- --action start >/dev/null 2>&1 || true
952
-
953
1035
  # Spawn session
954
1036
  log_info "Spawning AI CLI session: $session_id"
955
1037
  _SPAWN_RESULT=""
@@ -979,18 +1061,23 @@ main() {
979
1061
  else
980
1062
  log_warn "Auto-merge failed — dev branch preserved: $_DEV_BRANCH_NAME"
981
1063
  log_warn "Merge manually: git checkout $_ORIGINAL_BRANCH && git rebase $_DEV_BRANCH_NAME"
982
- git -C "$_proj_root" checkout "$_ORIGINAL_BRANCH" 2>/dev/null || true
983
1064
  _DEV_BRANCH_NAME=""
984
1065
  fi
985
1066
  elif [[ -n "$_DEV_BRANCH_NAME" ]]; then
986
- # Session failed — return to original branch, preserve dev branch for inspection
987
- if ! git -C "$_proj_root" checkout "$_ORIGINAL_BRANCH" 2>/dev/null; then
988
- log_warn "Failed to checkout $_ORIGINAL_BRANCH after session failure — staying on dev branch"
989
- fi
1067
+ # Session failed — preserve dev branch for inspection
990
1068
  log_warn "Session failed — dev branch preserved for inspection: $_DEV_BRANCH_NAME"
991
1069
  _DEV_BRANCH_NAME=""
992
1070
  fi
993
1071
 
1072
+ # GUARANTEED: always return to original branch regardless of success/failure/merge outcome
1073
+ branch_ensure_return "$_proj_root" "$_ORIGINAL_BRANCH"
1074
+
1075
+ # Commit refactor status update on the original branch (after guaranteed return)
1076
+ if ! git -C "$_proj_root" diff --quiet "$refactor_list" 2>/dev/null; then
1077
+ git -C "$_proj_root" add "$refactor_list"
1078
+ git -C "$_proj_root" commit --no-verify -m "chore($refactor_id): update refactor status" 2>/dev/null || true
1079
+ fi
1080
+
994
1081
  # Stuck detection
995
1082
  if python3 "$SCRIPTS_DIR/detect-stuck.py" \
996
1083
  --state-dir "$STATE_DIR" \
@@ -226,15 +226,16 @@ def check_stuck_checkpoint(item_dir):
226
226
  return None
227
227
 
228
228
 
229
- def check_stale_heartbeat(item_id, item_status, state_dir, items_subdir, stale_threshold):
229
+ def check_stale_heartbeat(item_id, item_status, state_dir, items_subdir, stale_threshold, task_list_status=None):
230
230
  """Check 3: Is the heartbeat stale or missing for an in_progress item?
231
231
 
232
232
  Only applies to items whose status indicates active work.
233
- Uses last_session_id from the item's own status to find the active session.
233
+ Status is read from task_list_status (task list JSON, single source of truth).
234
+ Uses last_session_id from the item's own status.json to find the active session.
234
235
 
235
236
  Returns a stuck-report dict or None.
236
237
  """
237
- status = item_status.get("status")
238
+ status = task_list_status
238
239
  # All pipelines now use "in_progress" as the active status
239
240
  in_progress_statuses = {"in_progress"}
240
241
  if status not in in_progress_statuses:
@@ -287,6 +288,8 @@ def check_stale_heartbeat(item_id, item_status, state_dir, items_subdir, stale_t
287
288
  def check_dependency_deadlock(item_id, task_list_data, state_dir, items_subdir, items_key):
288
289
  """Check 4: Does this item depend on a failed item?
289
290
 
291
+ Reads dependency status from task list JSON (single source of truth).
292
+
290
293
  Returns a stuck-report dict or None.
291
294
  """
292
295
  if task_list_data is None:
@@ -296,6 +299,12 @@ def check_dependency_deadlock(item_id, task_list_data, state_dir, items_subdir,
296
299
  if not isinstance(items, list):
297
300
  return None
298
301
 
302
+ # Build status map from task list
303
+ status_map = {}
304
+ for item in items:
305
+ if isinstance(item, dict) and item.get("id"):
306
+ status_map[item["id"]] = item.get("status", "pending")
307
+
299
308
  # Find this item in the list to get its dependencies
300
309
  deps = None
301
310
  for item in items:
@@ -308,15 +317,9 @@ def check_dependency_deadlock(item_id, task_list_data, state_dir, items_subdir,
308
317
  if not deps or not isinstance(deps, list):
309
318
  return None
310
319
 
311
- # Check each dependency's status in state
320
+ # Check each dependency's status from the task list
312
321
  for dep_id in deps:
313
- dep_status_path = os.path.join(
314
- state_dir, items_subdir, dep_id, "status.json"
315
- )
316
- dep_status = load_json(dep_status_path)
317
- if dep_status is None:
318
- continue
319
- dep_state = dep_status.get("status")
322
+ dep_state = status_map.get(dep_id)
320
323
  if dep_state == "failed":
321
324
  return {
322
325
  "reason": "dependency_failed",
@@ -376,8 +379,16 @@ def check_item(item_id, state_dir, items_subdir, items_key, task_list_data, max_
376
379
  item_status = load_json(status_path)
377
380
 
378
381
  if item_status is None:
379
- # Cannot read status skip silently
380
- return []
382
+ # Create a minimal runtime dict so checks can proceed
383
+ item_status = {}
384
+
385
+ # Look up item status from task list (single source of truth)
386
+ task_list_status = None
387
+ if task_list_data:
388
+ for item in task_list_data.get(items_key, []):
389
+ if isinstance(item, dict) and item.get("id") == item_id:
390
+ task_list_status = item.get("status", "pending")
391
+ break
381
392
 
382
393
  reports = []
383
394
 
@@ -392,7 +403,7 @@ def check_item(item_id, state_dir, items_subdir, items_key, task_list_data, max_
392
403
  reports.append(result)
393
404
 
394
405
  # Check 3: Stale heartbeat
395
- result = check_stale_heartbeat(item_id, item_status, state_dir, items_subdir, stale_threshold)
406
+ result = check_stale_heartbeat(item_id, item_status, state_dir, items_subdir, stale_threshold, task_list_status)
396
407
  if result is not None:
397
408
  reports.append(result)
398
409
 
@@ -1314,6 +1314,36 @@ def build_replacements(args, feature, features, global_context, script_dir):
1314
1314
  if isinstance(testing_config, dict):
1315
1315
  coverage_target = str(testing_config.get("coverage_target", 80))
1316
1316
 
1317
+ # Detect dev server port from package.json
1318
+ dev_port = "3000" # Default fallback
1319
+ try:
1320
+ pkg_path = os.path.join(project_root, "package.json")
1321
+ if os.path.isfile(pkg_path):
1322
+ with open(pkg_path, "r", encoding="utf-8") as f:
1323
+ pkg = json.load(f)
1324
+ dev_script = pkg.get("scripts", {}).get("dev", "")
1325
+ # Extract -p <port> from dev script
1326
+ port_match = re.search(r"-p\s+(\d+)", dev_script)
1327
+ if port_match:
1328
+ dev_port = port_match.group(1)
1329
+ else:
1330
+ # Fallback: try NEXT_PUBLIC_SITE_URL from .env files
1331
+ for env_file in [".env.local", ".env"]:
1332
+ env_path = os.path.join(project_root, env_file)
1333
+ if os.path.isfile(env_path):
1334
+ with open(env_path, "r", encoding="utf-8") as ef:
1335
+ for line in ef:
1336
+ m = re.match(
1337
+ r"NEXT_PUBLIC_SITE_URL\s*=\s*.*?:([0-9]+)", line.strip()
1338
+ )
1339
+ if m:
1340
+ dev_port = m.group(1)
1341
+ break
1342
+ if dev_port != "3000":
1343
+ break
1344
+ except Exception:
1345
+ pass # Keep default 3000 on any error
1346
+ dev_url = f"http://localhost:{dev_port}"
1317
1347
 
1318
1348
  replacements = {
1319
1349
  "{{RUN_ID}}": args.run_id,
@@ -1356,6 +1386,8 @@ def build_replacements(args, feature, features, global_context, script_dir):
1356
1386
  "{{TEST_CMD}}": test_cmd,
1357
1387
  "{{BASELINE_FAILURES}}": baseline_failures,
1358
1388
  "{{COVERAGE_TARGET}}": coverage_target,
1389
+ "{{DEV_PORT}}": dev_port,
1390
+ "{{DEV_URL}}": dev_url,
1359
1391
  }
1360
1392
 
1361
1393
  return replacements, effective_resume, browser_enabled
@@ -249,13 +249,8 @@ def create_state_directory(state_dir, bug_list_path, bugs):
249
249
  sessions_dir = os.path.join(bug_dir, "sessions")
250
250
  os.makedirs(sessions_dir, exist_ok=True)
251
251
 
252
- # Respect existing terminal status from bug-fix-list.json
253
- bl_status = bug.get("status", "pending")
254
- init_status = bl_status if bl_status in TERMINAL_STATUSES else "pending"
255
-
256
252
  bug_status = {
257
253
  "bug_id": bid,
258
- "status": init_status,
259
254
  "retry_count": 0,
260
255
  "max_retries": 3,
261
256
  "sessions": [],
@@ -283,13 +283,8 @@ def create_state_directory(state_dir, feature_list_path, features):
283
283
  sessions_dir = os.path.join(feature_dir, "sessions")
284
284
  os.makedirs(sessions_dir, exist_ok=True)
285
285
 
286
- # Respect existing terminal status from .prizmkit/plans/feature-list.json
287
- fl_status = feature.get("status", "pending")
288
- init_status = fl_status if fl_status in TERMINAL_STATUSES else "pending"
289
-
290
286
  feature_status = {
291
287
  "feature_id": fid,
292
- "status": init_status,
293
288
  "retry_count": 0,
294
289
  "max_retries": 3,
295
290
  "sessions": [],
@@ -325,13 +325,8 @@ def create_state_directory(state_dir, refactor_list_path, refactors):
325
325
  sessions_dir = os.path.join(refactor_dir, "sessions")
326
326
  os.makedirs(sessions_dir, exist_ok=True)
327
327
 
328
- # Respect existing terminal status from refactor-list.json
329
- rl_status = refactor.get("status", "pending")
330
- init_status = rl_status if rl_status in TERMINAL_STATUSES else "pending"
331
-
332
328
  refactor_status = {
333
329
  "refactor_id": rid,
334
- "status": init_status,
335
330
  "retry_count": 0,
336
331
  "max_retries": 3,
337
332
  "sessions": [],
@@ -84,12 +84,17 @@ def now_iso():
84
84
 
85
85
 
86
86
  def load_bug_status(state_dir, bug_id):
87
+ """Load runtime state from status.json for a bug.
88
+
89
+ Returns runtime fields only (retry_count, sessions, etc.).
90
+ The 'status' field is NOT included — status lives exclusively
91
+ in bug-fix-list.json.
92
+ """
87
93
  status_path = os.path.join(state_dir, "bugs", bug_id, "status.json")
88
94
  if not os.path.isfile(status_path):
89
95
  now = now_iso()
90
96
  return {
91
97
  "bug_id": bug_id,
92
- "status": "pending",
93
98
  "retry_count": 0,
94
99
  "max_retries": 3,
95
100
  "sessions": [],
@@ -103,7 +108,6 @@ def load_bug_status(state_dir, bug_id):
103
108
  now = now_iso()
104
109
  return {
105
110
  "bug_id": bug_id,
106
- "status": "pending",
107
111
  "retry_count": 0,
108
112
  "max_retries": 3,
109
113
  "sessions": [],
@@ -112,14 +116,30 @@ def load_bug_status(state_dir, bug_id):
112
116
  "created_at": now,
113
117
  "updated_at": now,
114
118
  }
119
+ # Defensively remove status if present (legacy data)
120
+ data.pop("status", None)
115
121
  return data
116
122
 
117
123
 
118
124
  def save_bug_status(state_dir, bug_id, status_data):
125
+ """Write the status.json for a bug (runtime fields only)."""
126
+ # Defensively strip status — it belongs in bug-fix-list.json
127
+ status_data.pop("status", None)
119
128
  status_path = os.path.join(state_dir, "bugs", bug_id, "status.json")
120
129
  return write_json_file(status_path, status_data)
121
130
 
122
131
 
132
+ def get_bug_status_from_list(bug_list_path, bug_id):
133
+ """Read a single bug's status from bug-fix-list.json."""
134
+ data, err = load_json_file(bug_list_path)
135
+ if err:
136
+ return "pending"
137
+ for b in data.get("bugs", []):
138
+ if isinstance(b, dict) and b.get("id") == bug_id:
139
+ return b.get("status", "pending")
140
+ return "pending"
141
+
142
+
123
143
  def update_bug_in_list(bug_list_path, bug_id, new_status):
124
144
  data, err = load_json_file(bug_list_path)
125
145
  if err:
@@ -153,7 +173,7 @@ def action_get_next(bug_list_data, state_dir):
153
173
  print("PIPELINE_COMPLETE")
154
174
  return
155
175
 
156
- # Build status map
176
+ # Build status map from bug-fix-list.json (single source of truth)
157
177
  status_map = {}
158
178
  status_data_map = {}
159
179
  for bug in bugs:
@@ -162,8 +182,8 @@ def action_get_next(bug_list_data, state_dir):
162
182
  bid = bug.get("id")
163
183
  if not bid:
164
184
  continue
185
+ status_map[bid] = bug.get("status", "pending")
165
186
  bs = load_bug_status(state_dir, bid)
166
- status_map[bid] = bs.get("status", "pending")
167
187
  status_data_map[bid] = bs
168
188
 
169
189
  # Check if all bugs are terminal
@@ -237,35 +257,30 @@ def action_update(args, bug_list_path, state_dir):
237
257
 
238
258
  bs = load_bug_status(state_dir, bug_id)
239
259
 
260
+ # Track what status we write to bug-fix-list.json
261
+ new_status = get_bug_status_from_list(bug_list_path, bug_id)
262
+
240
263
  if session_status == "success":
241
- bs["status"] = "completed"
264
+ new_status = "completed"
242
265
  bs["resume_from_phase"] = None
243
266
  err = update_bug_in_list(bug_list_path, bug_id, "completed")
244
267
  if err:
245
268
  error_out("Failed to update .prizmkit/plans/bug-fix-list.json: {}".format(err))
246
269
  return
247
270
  elif session_status in ("commit_missing", "docs_missing", "merge_conflict"):
248
- # Degraded outcome: keep artifacts for retry (branch preserves work).
249
- # Write schema-valid status to bug-fix-list.json ("pending" for retry,
250
- # "failed" if max retries exceeded). Store the granular degraded reason
251
- # in status.json only (internal pipeline state, not schema-bound).
252
271
  bs["retry_count"] = bs.get("retry_count", 0) + 1
253
272
 
254
273
  if bs["retry_count"] >= max_retries:
255
- bs["status"] = "failed"
256
- target_status = "failed"
274
+ new_status = "failed"
257
275
  else:
258
- # status.json keeps the granular degraded reason for diagnostics
259
- bs["status"] = session_status
260
- # bug-fix-list.json gets schema-valid "pending" (will be retried)
261
- target_status = "pending"
276
+ new_status = "pending"
262
277
 
263
278
  bs["degraded_reason"] = session_status
264
279
  bs["resume_from_phase"] = None
265
280
  bs["sessions"] = []
266
281
  bs["last_session_id"] = None
267
282
 
268
- err = update_bug_in_list(bug_list_path, bug_id, target_status)
283
+ err = update_bug_in_list(bug_list_path, bug_id, new_status)
269
284
  if err:
270
285
  error_out("Failed to update .prizmkit/plans/bug-fix-list.json: {}".format(err))
271
286
  return
@@ -279,17 +294,15 @@ def action_update(args, bug_list_path, state_dir):
279
294
  )
280
295
 
281
296
  if bs["retry_count"] >= max_retries:
282
- bs["status"] = "failed"
283
- target_status = "failed"
297
+ new_status = "failed"
284
298
  else:
285
- bs["status"] = "pending"
286
- target_status = "pending"
299
+ new_status = "pending"
287
300
 
288
301
  bs["resume_from_phase"] = None
289
302
  bs["sessions"] = []
290
303
  bs["last_session_id"] = None
291
304
 
292
- err = update_bug_in_list(bug_list_path, bug_id, target_status)
305
+ err = update_bug_in_list(bug_list_path, bug_id, new_status)
293
306
  if err:
294
307
  error_out("Failed to update .prizmkit/plans/bug-fix-list.json: {}".format(err))
295
308
  return
@@ -312,7 +325,7 @@ def action_update(args, bug_list_path, state_dir):
312
325
  "action": "update",
313
326
  "bug_id": bug_id,
314
327
  "session_status": session_status,
315
- "new_status": bs["status"],
328
+ "new_status": new_status,
316
329
  "retry_count": bs["retry_count"],
317
330
  "resume_from_phase": bs.get("resume_from_phase"),
318
331
  "updated_at": bs["updated_at"],
@@ -432,8 +445,8 @@ def action_status(bug_list_data, state_dir):
432
445
  if not bid:
433
446
  continue
434
447
 
448
+ bstatus = bug.get("status", "pending")
435
449
  bs = load_bug_status(state_dir, bid)
436
- bstatus = bs.get("status", "pending")
437
450
  retry_count = bs.get("retry_count", 0)
438
451
  max_retries_val = bs.get("max_retries", 3)
439
452
  resume_phase = bs.get("resume_from_phase")
@@ -537,10 +550,9 @@ def action_reset(args, bug_list_path, state_dir):
537
550
  return
538
551
 
539
552
  bs = load_bug_status(state_dir, bug_id)
540
- old_status = bs.get("status", "unknown")
553
+ old_status = get_bug_status_from_list(bug_list_path, bug_id)
541
554
  old_retry = bs.get("retry_count", 0)
542
555
 
543
- bs["status"] = "pending"
544
556
  bs["retry_count"] = 0
545
557
  bs["sessions"] = []
546
558
  bs["last_session_id"] = None
@@ -613,10 +625,9 @@ def action_clean(args, bug_list_path, state_dir):
613
625
 
614
626
  # 5. Reset status
615
627
  bs = load_bug_status(state_dir, bug_id)
616
- old_status = bs.get("status", "unknown")
628
+ old_status = get_bug_status_from_list(bug_list_path, bug_id)
617
629
  old_retry = bs.get("retry_count", 0)
618
630
 
619
- bs["status"] = "pending"
620
631
  bs["retry_count"] = 0
621
632
  bs["sessions"] = []
622
633
  bs["last_session_id"] = None
@@ -687,9 +698,8 @@ def action_start(args, bug_list_path, state_dir):
687
698
  return
688
699
 
689
700
  bs = load_bug_status(state_dir, bug_id)
690
- old_status = bs.get("status", "pending")
701
+ old_status = get_bug_status_from_list(bug_list_path, bug_id)
691
702
 
692
- bs["status"] = "in_progress"
693
703
  bs["updated_at"] = now_iso()
694
704
 
695
705
  err = save_bug_status(state_dir, bug_id, bs)
@@ -779,10 +789,9 @@ def action_unskip(args, bug_list_path, state_dir):
779
789
  error_out("Failed to write .prizmkit/plans/bug-fix-list.json: {}".format(err))
780
790
  return
781
791
 
782
- # Reset status.json for each bug
792
+ # Reset runtime fields in status.json for each bug
783
793
  for bid in to_reset:
784
794
  bs = load_bug_status(state_dir, bid)
785
- bs["status"] = "pending"
786
795
  bs["retry_count"] = 0
787
796
  bs["sessions"] = []
788
797
  bs["last_session_id"] = None