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.
@@ -153,24 +153,20 @@ def now_iso():
153
153
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
154
154
 
155
155
 
156
- def load_feature_status(state_dir, feature_id, feature_list_status=None):
157
- """Load the status.json for a feature.
156
+ def load_feature_status(state_dir, feature_id):
157
+ """Load the runtime state from status.json for a feature.
158
158
 
159
- If the file does not exist, return a default pending status.
160
-
161
- If feature_list_status is a terminal status (completed, failed, skipped),
162
- it overrides the status field from status.json. This makes .prizmkit/plans/feature-list.json
163
- the single source of truth for terminal statuses, while all other fields
164
- (retry_count, sessions, etc.) still come from status.json.
159
+ Returns runtime fields only (retry_count, sessions, etc.).
160
+ The 'status' field is NOT included — status lives exclusively
161
+ in feature-list.json.
165
162
  """
166
163
  status_path = os.path.join(
167
164
  state_dir, "features", feature_id, "status.json"
168
165
  )
169
166
  if not os.path.isfile(status_path):
170
167
  now = now_iso()
171
- data = {
168
+ return {
172
169
  "feature_id": feature_id,
173
- "status": feature_list_status if feature_list_status else "pending",
174
170
  "retry_count": 0,
175
171
  "max_retries": 3,
176
172
  "sessions": [],
@@ -179,14 +175,11 @@ def load_feature_status(state_dir, feature_id, feature_list_status=None):
179
175
  "created_at": now,
180
176
  "updated_at": now,
181
177
  }
182
- return data
183
178
  data, err = load_json_file(status_path)
184
179
  if err:
185
- # If we can't read it, treat as pending
186
180
  now = now_iso()
187
- data = {
181
+ return {
188
182
  "feature_id": feature_id,
189
- "status": feature_list_status if feature_list_status else "pending",
190
183
  "retry_count": 0,
191
184
  "max_retries": 3,
192
185
  "sessions": [],
@@ -195,20 +188,32 @@ def load_feature_status(state_dir, feature_id, feature_list_status=None):
195
188
  "created_at": now,
196
189
  "updated_at": now,
197
190
  }
198
- # .prizmkit/plans/feature-list.json wins for terminal statuses
199
- if feature_list_status in TERMINAL_STATUSES:
200
- data["status"] = feature_list_status
191
+ # Defensively remove status if present (legacy data)
192
+ data.pop("status", None)
201
193
  return data
202
194
 
203
195
 
204
196
  def save_feature_status(state_dir, feature_id, status_data):
205
- """Write the status.json for a feature."""
197
+ """Write the status.json for a feature (runtime fields only)."""
198
+ # Defensively strip status — it belongs in feature-list.json
199
+ status_data.pop("status", None)
206
200
  status_path = os.path.join(
207
201
  state_dir, "features", feature_id, "status.json"
208
202
  )
209
203
  return write_json_file(status_path, status_data)
210
204
 
211
205
 
206
+ def get_feature_status_from_list(feature_list_path, feature_id):
207
+ """Read a single feature's status from feature-list.json."""
208
+ data, err = load_json_file(feature_list_path)
209
+ if err:
210
+ return "pending"
211
+ for f in data.get("features", []):
212
+ if isinstance(f, dict) and f.get("id") == feature_id:
213
+ return f.get("status", "pending")
214
+ return "pending"
215
+
216
+
212
217
  def update_feature_in_list(feature_list_path, feature_id, new_status):
213
218
  """Update a feature's status field in .prizmkit/plans/feature-list.json.
214
219
 
@@ -389,10 +394,9 @@ def auto_skip_blocked_features(feature_list_path, state_dir, failed_feature_id):
389
394
  f["status"] = "auto_skipped"
390
395
  write_json_file(feature_list_path, data)
391
396
 
392
- # Sync status.json for each auto-skipped feature
397
+ # Update timestamps in status.json for each auto-skipped feature
393
398
  for fid in to_skip:
394
399
  fs = load_feature_status(state_dir, fid)
395
- fs["status"] = "auto_skipped"
396
400
  fs["updated_at"] = now_iso()
397
401
  save_feature_status(state_dir, fid, fs)
398
402
 
@@ -454,18 +458,19 @@ def action_get_next(feature_list_data, state_dir, feature_filter=None):
454
458
  return
455
459
 
456
460
  # Build status map from ALL features (for dependency checking).
461
+ # Status comes from feature-list.json (the single source of truth).
457
462
  # This must happen BEFORE the feature filter is applied, because
458
463
  # filtered features may depend on features outside the filter.
459
464
  status_map = {} # feature_id -> status string
460
- status_data_map = {} # feature_id -> full status data
465
+ status_data_map = {} # feature_id -> runtime status data (retry_count, etc.)
461
466
  for feature in features:
462
467
  if not isinstance(feature, dict):
463
468
  continue
464
469
  fid = feature.get("id")
465
470
  if not fid:
466
471
  continue
467
- fs = load_feature_status(state_dir, fid, feature.get("status"))
468
- status_map[fid] = fs.get("status", "pending")
472
+ status_map[fid] = feature.get("status", "pending")
473
+ fs = load_feature_status(state_dir, fid)
469
474
  status_data_map[fid] = fs
470
475
 
471
476
  # Apply feature filter: only consider these features as candidates
@@ -576,12 +581,16 @@ def action_update(args, feature_list_path, state_dir):
576
581
  return
577
582
 
578
583
  fs = load_feature_status(state_dir, feature_id)
584
+ current_list_status = get_feature_status_from_list(feature_list_path, feature_id)
585
+
586
+ # Track what status we write to feature-list.json
587
+ new_status = current_list_status
579
588
 
580
589
  if session_status == "success":
581
590
  # No-op guard: if this exact successful session was already recorded,
582
591
  # avoid rewriting state files again (prevents post-commit dirty changes).
583
592
  existing_sessions = fs.get("sessions", [])
584
- already_completed = fs.get("status") == "completed" and fs.get("resume_from_phase") is None
593
+ already_completed = current_list_status == "completed" and fs.get("resume_from_phase") is None
585
594
  same_session_already_recorded = (
586
595
  session_id
587
596
  and session_id in existing_sessions
@@ -592,7 +601,7 @@ def action_update(args, feature_list_path, state_dir):
592
601
  "action": "update",
593
602
  "feature_id": feature_id,
594
603
  "session_status": session_status,
595
- "new_status": fs.get("status", "completed"),
604
+ "new_status": "completed",
596
605
  "retry_count": fs.get("retry_count", 0),
597
606
  "resume_from_phase": fs.get("resume_from_phase"),
598
607
  "updated_at": fs.get("updated_at"),
@@ -601,7 +610,7 @@ def action_update(args, feature_list_path, state_dir):
601
610
  print(json.dumps(summary, indent=2, ensure_ascii=False))
602
611
  return
603
612
 
604
- fs["status"] = "completed"
613
+ new_status = "completed"
605
614
  fs["resume_from_phase"] = None
606
615
  err = update_feature_in_list(feature_list_path, feature_id, "completed")
607
616
  if err:
@@ -614,42 +623,33 @@ def action_update(args, feature_list_path, state_dir):
614
623
  fs["retry_count"] = fs.get("retry_count", 0) + 1
615
624
 
616
625
  if fs["retry_count"] >= max_retries:
617
- fs["status"] = "failed"
618
- target_status = "failed"
626
+ new_status = "failed"
619
627
  else:
620
- # status.json keeps the granular degraded reason for diagnostics
621
- fs["status"] = session_status
622
628
  # feature-list.json gets schema-valid "pending" (will be retried)
623
- target_status = "pending"
629
+ new_status = "pending"
624
630
 
625
631
  fs["degraded_reason"] = session_status
626
632
  fs["resume_from_phase"] = None
627
633
  fs["sessions"] = []
628
634
  fs["last_session_id"] = None
629
635
 
630
- err = update_feature_in_list(feature_list_path, feature_id, target_status)
636
+ err = update_feature_in_list(feature_list_path, feature_id, new_status)
631
637
  if err:
632
638
  error_out("Failed to update .prizmkit/plans/feature-list.json: {}".format(err))
633
639
  return
634
640
  else:
635
641
  # crashed / failed / timed_out — preserve all artifacts for debugging.
636
- # Previous behavior deleted everything via cleanup_feature_artifacts(),
637
- # making it impossible to diagnose why the session failed.
638
642
  fs["retry_count"] = fs.get("retry_count", 0) + 1
639
643
 
640
644
  if fs["retry_count"] >= max_retries:
641
- fs["status"] = "failed"
642
- target_status = "failed"
645
+ new_status = "failed"
643
646
  else:
644
- fs["status"] = "pending"
645
- target_status = "pending"
647
+ new_status = "pending"
646
648
 
647
649
  fs["resume_from_phase"] = None
648
650
  # Keep sessions list and last_session_id for debugging
649
- # fs["sessions"] = []
650
- # fs["last_session_id"] = None
651
651
 
652
- err = update_feature_in_list(feature_list_path, feature_id, target_status)
652
+ err = update_feature_in_list(feature_list_path, feature_id, new_status)
653
653
  if err:
654
654
  error_out("Failed to update .prizmkit/plans/feature-list.json: {}".format(err))
655
655
  return
@@ -670,7 +670,7 @@ def action_update(args, feature_list_path, state_dir):
670
670
 
671
671
  # Auto-skip downstream features when this feature is marked as failed or skipped
672
672
  auto_skipped_features = []
673
- if fs["status"] in ("failed", "skipped"):
673
+ if new_status in ("failed", "skipped"):
674
674
  auto_skipped_features = auto_skip_blocked_features(
675
675
  feature_list_path, state_dir, feature_id
676
676
  )
@@ -679,7 +679,7 @@ def action_update(args, feature_list_path, state_dir):
679
679
  "action": "update",
680
680
  "feature_id": feature_id,
681
681
  "session_status": session_status,
682
- "new_status": fs["status"],
682
+ "new_status": new_status,
683
683
  "retry_count": fs["retry_count"],
684
684
  "resume_from_phase": fs.get("resume_from_phase"),
685
685
  "updated_at": fs["updated_at"],
@@ -776,12 +776,12 @@ def _estimate_remaining_time(features, state_dir, counts, feature_list_data=None
776
776
  # Complexity weights (used for estimation when no historical data is available)
777
777
  COMPLEXITY_WEIGHT = {"low": 1.0, "medium": 2.0, "high": 4.0}
778
778
 
779
- # Build feature-list status map for terminal status override
779
+ # Build feature-list status map (status lives in feature-list.json)
780
780
  fl_status_map = {}
781
781
  if feature_list_data:
782
782
  for f in feature_list_data.get("features", []):
783
783
  if isinstance(f, dict) and f.get("id"):
784
- fl_status_map[f["id"]] = f.get("status")
784
+ fl_status_map[f["id"]] = f.get("status", "pending")
785
785
 
786
786
  # Collect completed feature durations grouped by complexity
787
787
  duration_by_complexity = {} # complexity -> [duration_seconds]
@@ -803,8 +803,7 @@ def _estimate_remaining_time(features, state_dir, counts, feature_list_data=None
803
803
  fid = feature.get("id")
804
804
  if not fid:
805
805
  continue
806
- fs = load_feature_status(state_dir, fid, fl_status_map.get(fid))
807
- if fs.get("status") != "completed":
806
+ if fl_status_map.get(fid) != "completed":
808
807
  continue
809
808
  duration = _calc_feature_duration(state_dir, fid)
810
809
  if duration is None:
@@ -833,8 +832,7 @@ def _estimate_remaining_time(features, state_dir, counts, feature_list_data=None
833
832
  fid = feature.get("id")
834
833
  if not fid:
835
834
  continue
836
- fs = load_feature_status(state_dir, fid, fl_status_map.get(fid))
837
- fstatus = fs.get("status", "pending")
835
+ fstatus = fl_status_map.get(fid, "pending")
838
836
  if fstatus in TERMINAL_STATUSES:
839
837
  continue
840
838
  remaining_count += 1
@@ -910,7 +908,7 @@ def action_status(feature_list_data, state_dir, feature_filter=None):
910
908
  fid = feature.get("id")
911
909
  if not fid:
912
910
  continue
913
- fs = load_feature_status(state_dir, fid, feature.get("status"))
911
+ fs = load_feature_status(state_dir, fid)
914
912
  dr = fs.get("degraded_reason")
915
913
  if dr:
916
914
  degraded_reason_map[fid] = dr
@@ -1089,9 +1087,8 @@ def action_start(args, feature_list_path, state_dir):
1089
1087
  return
1090
1088
 
1091
1089
  fs = load_feature_status(state_dir, feature_id)
1092
- old_status = fs.get("status", "pending")
1090
+ old_status = get_feature_status_from_list(feature_list_path, feature_id)
1093
1091
 
1094
- fs["status"] = "in_progress"
1095
1092
  fs["updated_at"] = now_iso()
1096
1093
 
1097
1094
  err = save_feature_status(state_dir, feature_id, fs)
@@ -1121,7 +1118,7 @@ def action_start(args, feature_list_path, state_dir):
1121
1118
  def action_reset(args, feature_list_path, state_dir):
1122
1119
  """Reset a feature to pending state.
1123
1120
 
1124
- Resets status.json (status -> pending, retry_count -> 0, clear sessions,
1121
+ Resets status.json runtime fields (retry_count -> 0, clear sessions,
1125
1122
  clear resume_from_phase) and updates .prizmkit/plans/feature-list.json status to pending.
1126
1123
  Does NOT delete any files on disk.
1127
1124
  """
@@ -1132,11 +1129,10 @@ def action_reset(args, feature_list_path, state_dir):
1132
1129
 
1133
1130
  # Load current status to preserve created_at
1134
1131
  fs = load_feature_status(state_dir, feature_id)
1135
- old_status = fs.get("status", "unknown")
1132
+ old_status = get_feature_status_from_list(feature_list_path, feature_id)
1136
1133
  old_retry = fs.get("retry_count", 0)
1137
1134
 
1138
- # Reset fields
1139
- fs["status"] = "pending"
1135
+ # Reset runtime fields
1140
1136
  fs["retry_count"] = 0
1141
1137
  fs["sessions"] = []
1142
1138
  fs["last_session_id"] = None
@@ -1227,10 +1223,9 @@ def action_clean(args, feature_list_path, state_dir):
1227
1223
 
1228
1224
  # 5. Reset status (reuse reset logic)
1229
1225
  fs = load_feature_status(state_dir, feature_id)
1230
- old_status = fs.get("status", "unknown")
1226
+ old_status = get_feature_status_from_list(feature_list_path, feature_id)
1231
1227
  old_retry = fs.get("retry_count", 0)
1232
1228
 
1233
- fs["status"] = "pending"
1234
1229
  fs["retry_count"] = 0
1235
1230
  fs["sessions"] = []
1236
1231
  fs["last_session_id"] = None
@@ -1387,10 +1382,9 @@ def action_unskip(args, feature_list_path, state_dir):
1387
1382
  error_out("Failed to write .prizmkit/plans/feature-list.json: {}".format(err))
1388
1383
  return
1389
1384
 
1390
- # Reset status.json for each feature
1385
+ # Reset runtime fields in status.json for each feature
1391
1386
  for fid in to_reset:
1392
1387
  fs = load_feature_status(state_dir, fid)
1393
- fs["status"] = "pending"
1394
1388
  fs["retry_count"] = 0
1395
1389
  fs["sessions"] = []
1396
1390
  fs["last_session_id"] = None
@@ -96,11 +96,10 @@ def now_iso():
96
96
 
97
97
 
98
98
  def _default_status(refactor_id):
99
- """Create a default refactor status object."""
99
+ """Create a default refactor runtime status object (no status field)."""
100
100
  now = now_iso()
101
101
  return {
102
102
  "refactor_id": refactor_id,
103
- "status": "pending",
104
103
  "retry_count": 0,
105
104
  "max_retries": 3,
106
105
  "sessions": [],
@@ -112,20 +111,42 @@ def _default_status(refactor_id):
112
111
 
113
112
 
114
113
  def load_refactor_status(state_dir, refactor_id):
114
+ """Load runtime state from status.json for a refactor.
115
+
116
+ Returns runtime fields only (retry_count, sessions, etc.).
117
+ The 'status' field is NOT included — status lives exclusively
118
+ in refactor-list.json.
119
+ """
115
120
  status_path = os.path.join(state_dir, "refactors", refactor_id, "status.json")
116
121
  if not os.path.isfile(status_path):
117
122
  return _default_status(refactor_id)
118
123
  data, err = load_json_file(status_path)
119
124
  if err:
120
125
  return _default_status(refactor_id)
126
+ # Defensively remove status if present (legacy data)
127
+ data.pop("status", None)
121
128
  return data
122
129
 
123
130
 
124
131
  def save_refactor_status(state_dir, refactor_id, status_data):
132
+ """Write the status.json for a refactor (runtime fields only)."""
133
+ # Defensively strip status — it belongs in refactor-list.json
134
+ status_data.pop("status", None)
125
135
  status_path = os.path.join(state_dir, "refactors", refactor_id, "status.json")
126
136
  return write_json_file(status_path, status_data)
127
137
 
128
138
 
139
+ def get_refactor_status_from_list(refactor_list_path, refactor_id):
140
+ """Read a single refactor's status from refactor-list.json."""
141
+ data, err = load_json_file(refactor_list_path)
142
+ if err:
143
+ return "pending"
144
+ for r in data.get("refactors", []):
145
+ if isinstance(r, dict) and r.get("id") == refactor_id:
146
+ return r.get("status", "pending")
147
+ return "pending"
148
+
149
+
129
150
  def update_refactor_in_list(refactor_list_path, refactor_id, new_status):
130
151
  data, err = load_json_file(refactor_list_path)
131
152
  if err:
@@ -179,7 +200,7 @@ def action_get_next(refactor_list_data, state_dir):
179
200
  print("PIPELINE_COMPLETE")
180
201
  return
181
202
 
182
- # Build status map and completed set
203
+ # Build status map from refactor-list.json (single source of truth)
183
204
  status_map = {}
184
205
  status_data_map = {}
185
206
  for r in refactors:
@@ -188,8 +209,8 @@ def action_get_next(refactor_list_data, state_dir):
188
209
  rid = r.get("id")
189
210
  if not rid:
190
211
  continue
212
+ status_map[rid] = r.get("status", "pending")
191
213
  rs = load_refactor_status(state_dir, rid)
192
- status_map[rid] = rs.get("status", "pending")
193
214
  status_data_map[rid] = rs
194
215
 
195
216
  completed_set = {rid for rid, st in status_map.items() if st in TERMINAL_STATUSES}
@@ -270,35 +291,30 @@ def action_update(args, refactor_list_path, state_dir):
270
291
 
271
292
  rs = load_refactor_status(state_dir, refactor_id)
272
293
 
294
+ # Track what status we write to refactor-list.json
295
+ new_status = get_refactor_status_from_list(refactor_list_path, refactor_id)
296
+
273
297
  if session_status == "success":
274
- rs["status"] = "completed"
298
+ new_status = "completed"
275
299
  rs["resume_from_phase"] = None
276
300
  err = update_refactor_in_list(refactor_list_path, refactor_id, "completed")
277
301
  if err:
278
302
  error_out("Failed to update .prizmkit/plans/refactor-list.json: {}".format(err))
279
303
  return
280
304
  elif session_status in ("commit_missing", "docs_missing", "merge_conflict"):
281
- # Degraded outcome: keep artifacts for retry.
282
- # Write schema-valid status to refactor-list.json ("pending" for retry,
283
- # "failed" if max retries exceeded). Store the granular degraded reason
284
- # in status.json only (internal pipeline state, not schema-bound).
285
305
  rs["retry_count"] = rs.get("retry_count", 0) + 1
286
306
 
287
307
  if rs["retry_count"] >= max_retries:
288
- rs["status"] = "failed"
289
- target_status = "failed"
308
+ new_status = "failed"
290
309
  else:
291
- # status.json keeps the granular degraded reason for diagnostics
292
- rs["status"] = session_status
293
- # refactor-list.json gets schema-valid "pending" (will be retried)
294
- target_status = "pending"
310
+ new_status = "pending"
295
311
 
296
312
  rs["degraded_reason"] = session_status
297
313
  rs["resume_from_phase"] = None
298
314
  rs["sessions"] = []
299
315
  rs["last_session_id"] = None
300
316
 
301
- err = update_refactor_in_list(refactor_list_path, refactor_id, target_status)
317
+ err = update_refactor_in_list(refactor_list_path, refactor_id, new_status)
302
318
  if err:
303
319
  error_out("Failed to update .prizmkit/plans/refactor-list.json: {}".format(err))
304
320
  return
@@ -312,17 +328,15 @@ def action_update(args, refactor_list_path, state_dir):
312
328
  )
313
329
 
314
330
  if rs["retry_count"] >= max_retries:
315
- rs["status"] = "failed"
316
- target_status = "failed"
331
+ new_status = "failed"
317
332
  else:
318
- rs["status"] = "pending"
319
- target_status = "pending"
333
+ new_status = "pending"
320
334
 
321
335
  rs["resume_from_phase"] = None
322
336
  rs["sessions"] = []
323
337
  rs["last_session_id"] = None
324
338
 
325
- err = update_refactor_in_list(refactor_list_path, refactor_id, target_status)
339
+ err = update_refactor_in_list(refactor_list_path, refactor_id, new_status)
326
340
  if err:
327
341
  error_out("Failed to update .prizmkit/plans/refactor-list.json: {}".format(err))
328
342
  return
@@ -343,7 +357,7 @@ def action_update(args, refactor_list_path, state_dir):
343
357
 
344
358
  # Auto-skip downstream refactors when this refactor is marked as failed or skipped
345
359
  auto_skipped_refactors = []
346
- if rs["status"] in ("failed", "skipped"):
360
+ if new_status in ("failed", "skipped"):
347
361
  auto_skipped_refactors = auto_skip_blocked_refactors(
348
362
  refactor_list_path, state_dir, refactor_id
349
363
  )
@@ -352,7 +366,7 @@ def action_update(args, refactor_list_path, state_dir):
352
366
  "action": "update",
353
367
  "refactor_id": refactor_id,
354
368
  "session_status": session_status,
355
- "new_status": rs["status"],
369
+ "new_status": new_status,
356
370
  "retry_count": rs["retry_count"],
357
371
  "resume_from_phase": rs.get("resume_from_phase"),
358
372
  "updated_at": rs["updated_at"],
@@ -496,10 +510,9 @@ def auto_skip_blocked_refactors(refactor_list_path, state_dir, failed_refactor_i
496
510
  r["status"] = "auto_skipped"
497
511
  write_json_file(refactor_list_path, data)
498
512
 
499
- # Sync status.json for each auto-skipped refactor
513
+ # Update timestamps in status.json for each auto-skipped refactor
500
514
  for rid in to_skip:
501
515
  rs = load_refactor_status(state_dir, rid)
502
- rs["status"] = "auto_skipped"
503
516
  rs["updated_at"] = now_iso()
504
517
  save_refactor_status(state_dir, rid, rs)
505
518
 
@@ -589,8 +602,8 @@ def action_status(refactor_list_data, state_dir):
589
602
  if not rid:
590
603
  continue
591
604
 
605
+ rstatus = r.get("status", "pending")
592
606
  rs = load_refactor_status(state_dir, rid)
593
- rstatus = rs.get("status", "pending")
594
607
  retry_count = rs.get("retry_count", 0)
595
608
  max_retries_val = rs.get("max_retries", 3)
596
609
  resume_phase = rs.get("resume_from_phase")
@@ -688,10 +701,9 @@ def action_reset(args, refactor_list_path, state_dir):
688
701
  return
689
702
 
690
703
  rs = load_refactor_status(state_dir, refactor_id)
691
- old_status = rs.get("status", "unknown")
704
+ old_status = get_refactor_status_from_list(refactor_list_path, refactor_id)
692
705
  old_retry = rs.get("retry_count", 0)
693
706
 
694
- rs["status"] = "pending"
695
707
  rs["retry_count"] = 0
696
708
  rs["sessions"] = []
697
709
  rs["last_session_id"] = None
@@ -760,10 +772,9 @@ def action_clean(args, refactor_list_path, state_dir):
760
772
 
761
773
  # 4. Reset status
762
774
  rs = load_refactor_status(state_dir, refactor_id)
763
- old_status = rs.get("status", "unknown")
775
+ old_status = get_refactor_status_from_list(refactor_list_path, refactor_id)
764
776
  old_retry = rs.get("retry_count", 0)
765
777
 
766
- rs["status"] = "pending"
767
778
  rs["retry_count"] = 0
768
779
  rs["sessions"] = []
769
780
  rs["last_session_id"] = None
@@ -834,9 +845,8 @@ def action_start(args, refactor_list_path, state_dir):
834
845
  return
835
846
 
836
847
  rs = load_refactor_status(state_dir, refactor_id)
837
- old_status = rs.get("status", "pending")
848
+ old_status = get_refactor_status_from_list(refactor_list_path, refactor_id)
838
849
 
839
- rs["status"] = "in_progress"
840
850
  rs["updated_at"] = now_iso()
841
851
 
842
852
  err = save_refactor_status(state_dir, refactor_id, rs)
@@ -988,10 +998,9 @@ def action_unskip(args, refactor_list_path, state_dir):
988
998
  error_out("Failed to write .prizmkit/plans/refactor-list.json: {}".format(err))
989
999
  return
990
1000
 
991
- # Reset status.json for each refactor
1001
+ # Reset runtime fields in status.json for each refactor
992
1002
  for rid in to_reset:
993
1003
  rs = load_refactor_status(state_dir, rid)
994
- rs["status"] = "pending"
995
1004
  rs["retry_count"] = 0
996
1005
  rs["sessions"] = []
997
1006
  rs["last_session_id"] = None
@@ -194,17 +194,32 @@ You MUST execute this phase. Do NOT skip it. Do NOT mark it as completed without
194
194
  You know this project's tech stack. Detect and start the dev server yourself:
195
195
 
196
196
  1. Identify the dev server start command from project config (`package.json` scripts, `Makefile`, `docker-compose.yml`, etc.)
197
- 2. Choose an available port — check what the project defaults to, or pick one that is free:
197
+ 2. **Detect the dev server port**use the pre-detected port from pipeline if available, otherwise extract from project config. Do NOT hardcode or guess the port:
198
198
  ```bash
199
- lsof -ti:<port> 2>/dev/null && echo "PORT_IN_USE" || echo "PORT_FREE"
199
+ # Use pipeline-injected port if available, otherwise extract from package.json
200
+ DEV_PORT={{DEV_PORT}}
201
+ # If DEV_PORT is still a placeholder, detect at runtime:
202
+ if [ "$DEV_PORT" = "{{DEV_PORT}}" ]; then
203
+ DEV_PORT=$(node -e "const s=require('./package.json').scripts.dev; const m=s.match(/-p\s+(\d+)/); console.log(m?m[1]:'')")
204
+ if [ -z "$DEV_PORT" ]; then
205
+ DEV_PORT=$(echo "$NEXT_PUBLIC_SITE_URL" | sed -nE 's|.*:([0-9]+).*|\1|p')
206
+ fi
207
+ DEV_PORT=${DEV_PORT:-3000}
208
+ fi
209
+ echo "Detected DEV_PORT=$DEV_PORT"
200
210
  ```
201
- 3. Start the dev server in background, capture PID:
211
+ 3. Verify the port is available:
212
+ ```bash
213
+ lsof -ti:$DEV_PORT 2>/dev/null && echo "PORT_IN_USE" || echo "PORT_FREE"
214
+ ```
215
+ 4. Start the dev server in background, capture PID:
202
216
  ```bash
203
217
  <start-command> &
204
218
  DEV_SERVER_PID=$!
205
219
  ```
206
- 4. Wait for server to be ready: poll the target URL with `curl -s -o /dev/null -w "%{http_code}"` until it returns 200 or 302 (max 30 seconds, 2s interval)
207
- 5. If the page requires authentication, use playwright-cli to register a test user and log in first
220
+ 5. Wait for server to be ready: poll `http://localhost:$DEV_PORT` with `curl -s -o /dev/null -w "%{http_code}"` until it returns 200 or 302 (max 30 seconds, 2s interval)
221
+ 6. Open the app in playwright-cli: `playwright-cli open http://localhost:$DEV_PORT`
222
+ 7. If the page requires authentication, use playwright-cli to register a test user and log in first
208
223
 
209
224
  **Step 2 — Verification**:
210
225
 
@@ -217,14 +232,14 @@ Take a final screenshot for evidence.
217
232
  **Step 3 — Cleanup (REQUIRED — you started it, you stop it)**:
218
233
 
219
234
  1. Kill the dev server process: `kill $DEV_SERVER_PID 2>/dev/null || true`
220
- 2. Verify port is released: `lsof -ti:<port> | xargs kill -9 2>/dev/null || true`
235
+ 2. Verify port is released: `lsof -ti:$DEV_PORT | xargs kill -9 2>/dev/null || true`
221
236
 
222
237
  **Step 4 — Reporting**:
223
238
 
224
239
  Append results to `context-snapshot.md`:
225
240
  ```
226
241
  ## Browser Verification
227
- URL: <actual URL used>
242
+ URL: http://localhost:$DEV_PORT
228
243
  Dev Server Command: <actual command used>
229
244
  Steps executed: [list]
230
245
  Screenshot: [path]
@@ -292,17 +292,32 @@ You MUST execute this phase. Do NOT skip it. Do NOT mark it as completed without
292
292
  You know this project's tech stack. Detect and start the dev server yourself:
293
293
 
294
294
  1. Identify the dev server start command from project config (`package.json` scripts, `Makefile`, `docker-compose.yml`, etc.)
295
- 2. Choose an available port — check what the project defaults to, or pick one that is free:
295
+ 2. **Detect the dev server port**use the pre-detected port from pipeline if available, otherwise extract from project config. Do NOT hardcode or guess the port:
296
296
  ```bash
297
- lsof -ti:<port> 2>/dev/null && echo "PORT_IN_USE" || echo "PORT_FREE"
297
+ # Use pipeline-injected port if available, otherwise extract from package.json
298
+ DEV_PORT={{DEV_PORT}}
299
+ # If DEV_PORT is still a placeholder, detect at runtime:
300
+ if [ "$DEV_PORT" = "{{DEV_PORT}}" ]; then
301
+ DEV_PORT=$(node -e "const s=require('./package.json').scripts.dev; const m=s.match(/-p\s+(\d+)/); console.log(m?m[1]:'')")
302
+ if [ -z "$DEV_PORT" ]; then
303
+ DEV_PORT=$(echo "$NEXT_PUBLIC_SITE_URL" | sed -nE 's|.*:([0-9]+).*|\1|p')
304
+ fi
305
+ DEV_PORT=${DEV_PORT:-3000}
306
+ fi
307
+ echo "Detected DEV_PORT=$DEV_PORT"
298
308
  ```
299
- 3. Start the dev server in background, capture PID:
309
+ 3. Verify the port is available:
310
+ ```bash
311
+ lsof -ti:$DEV_PORT 2>/dev/null && echo "PORT_IN_USE" || echo "PORT_FREE"
312
+ ```
313
+ 4. Start the dev server in background, capture PID:
300
314
  ```bash
301
315
  <start-command> &
302
316
  DEV_SERVER_PID=$!
303
317
  ```
304
- 4. Wait for server to be ready: poll the target URL with `curl -s -o /dev/null -w "%{http_code}"` until it returns 200 or 302 (max 30 seconds, 2s interval)
305
- 5. If the page requires authentication, use playwright-cli to register a test user and log in first
318
+ 5. Wait for server to be ready: poll `http://localhost:$DEV_PORT` with `curl -s -o /dev/null -w "%{http_code}"` until it returns 200 or 302 (max 30 seconds, 2s interval)
319
+ 6. Open the app in playwright-cli: `playwright-cli open http://localhost:$DEV_PORT`
320
+ 7. If the page requires authentication, use playwright-cli to register a test user and log in first
306
321
 
307
322
  **Step 2 — Verification**:
308
323
 
@@ -315,14 +330,14 @@ Take a final screenshot for evidence.
315
330
  **Step 3 — Cleanup (REQUIRED — you started it, you stop it)**:
316
331
 
317
332
  1. Kill the dev server process: `kill $DEV_SERVER_PID 2>/dev/null || true`
318
- 2. Verify port is released: `lsof -ti:<port> | xargs kill -9 2>/dev/null || true`
333
+ 2. Verify port is released: `lsof -ti:$DEV_PORT | xargs kill -9 2>/dev/null || true`
319
334
 
320
335
  **Step 4 — Reporting**:
321
336
 
322
337
  Append results to `context-snapshot.md`:
323
338
  ```
324
339
  ## Browser Verification
325
- URL: <actual URL used>
340
+ URL: http://localhost:$DEV_PORT
326
341
  Dev Server Command: <actual command used>
327
342
  Steps executed: [list]
328
343
  Screenshot: [path]