prizmkit 1.1.20 → 1.1.21

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "frameworkVersion": "1.1.20",
3
- "bundledAt": "2026-04-11T05:49:50.851Z",
4
- "bundledFrom": "13e5e58"
2
+ "frameworkVersion": "1.1.21",
3
+ "bundledAt": "2026-04-11T11:23:13.654Z",
4
+ "bundledFrom": "5137496"
5
5
  }
@@ -2,18 +2,21 @@
2
2
  # ============================================================
3
3
  # dev-pipeline/lib/branch.sh - Git Branch Lifecycle Library
4
4
  #
5
- # Shared by run-feature.sh and run-bugfix.sh for branch-based serial
6
- # development. Each pipeline run creates a dev branch and all
7
- # features/bugs commit directly on it in sequence.
5
+ # Shared by run-feature.sh, run-bugfix.sh, and run-refactor.sh
6
+ # for branch-based serial development. Each pipeline run creates
7
+ # a dev branch and all features/bugs/refactors commit on it.
8
8
  #
9
9
  # Functions:
10
- # branch_create — Create and checkout a new branch
11
- # branch_return — Checkout back to original branch
12
- # branch_merge — Merge dev branch into original and optionally push
10
+ # branch_create — Create and checkout a new branch
11
+ # branch_return — Checkout back to original branch
12
+ # branch_merge — Merge dev branch into original and optionally push
13
+ # branch_ensure_return — Guaranteed return to original branch (try/finally)
13
14
  #
14
15
  # Environment:
15
- # DEV_BRANCH — Optional custom branch name override
16
- # AUTO_PUSH — Set to 1 to auto-push after successful feature
16
+ # _ORIGINAL_BRANCH Set by caller before branch_create
17
+ # _DEV_BRANCH_NAME Set by caller after branch_create
18
+ # DEV_BRANCH Optional custom branch name override
19
+ # AUTO_PUSH Set to 1 to auto-push after successful feature
17
20
  # ============================================================
18
21
 
19
22
  # branch_create <project_root> <branch_name> <source_branch>
@@ -80,11 +83,15 @@ branch_return() {
80
83
  #
81
84
  # Merges dev_branch into original_branch, then optionally pushes.
82
85
  # Steps:
83
- # 1. Checkout original_branch
86
+ # 1. Stash tracked dirty files (NOT untracked — .prizmkit/state/ is gitignored)
84
87
  # 2. Rebase dev_branch onto original_branch (handles diverged main)
85
88
  # 3. Fast-forward merge original_branch to rebased dev tip
86
89
  # 4. Push to remote if auto_push == "1"
87
90
  # 5. Delete dev_branch (local only, it's been merged)
91
+ # 6. Restore stashed files
92
+ #
93
+ # IMPORTANT: On failure, caller MUST still call branch_ensure_return()
94
+ # to guarantee return to the original branch.
88
95
  #
89
96
  # Returns 0 on success, 1 on failure.
90
97
  branch_merge() {
@@ -93,16 +100,21 @@ branch_merge() {
93
100
  local original_branch="$3"
94
101
  local auto_push="${4:-0}"
95
102
 
96
- # Step 1: Checkout original branch
97
- # Stash any uncommitted changes (e.g. untracked state/ files) so checkout is not blocked
103
+ # Step 1: Stash any tracked uncommitted changes so checkout is not blocked.
104
+ # Only stash tracked changes (not untracked). Untracked files like
105
+ # .prizmkit/state/ are gitignored and survive checkout without issue.
106
+ # Using --include-untracked causes stash pop conflicts and can lose
107
+ # state/ files that are needed for pipeline status tracking.
98
108
  local had_stash=false
99
- local remaining_dirty
100
- remaining_dirty=$(git -C "$project_root" status --porcelain 2>/dev/null || true)
101
- if [[ -n "$remaining_dirty" ]]; then
102
- if git -C "$project_root" stash push --include-untracked -m "pipeline-merge-stash" 2>/dev/null; then
109
+ local tracked_dirty
110
+ tracked_dirty=$(git -C "$project_root" diff --name-only 2>/dev/null || true)
111
+ local staged_dirty
112
+ staged_dirty=$(git -C "$project_root" diff --cached --name-only 2>/dev/null || true)
113
+ if [[ -n "$tracked_dirty" || -n "$staged_dirty" ]]; then
114
+ if git -C "$project_root" stash push -m "pipeline-merge-stash" 2>/dev/null; then
103
115
  had_stash=true
104
116
  else
105
- log_warn "git stash failed — uncommitted changes may not be preserved during merge"
117
+ log_warn "git stash failed — uncommitted tracked changes may not be preserved during merge"
106
118
  had_stash=false
107
119
  fi
108
120
  fi
@@ -116,14 +128,21 @@ branch_merge() {
116
128
  log_error "Rebase of $dev_branch onto $original_branch failed — resolve manually:"
117
129
  log_error " git rebase --abort # then resolve conflicts and retry"
118
130
  git -C "$project_root" rebase --abort 2>/dev/null || true
119
- git -C "$project_root" checkout "$dev_branch" 2>/dev/null || true
120
- [[ "$had_stash" == true ]] && git -C "$project_root" stash pop 2>/dev/null || true
131
+ if [[ "$had_stash" == true ]]; then
132
+ if ! git -C "$project_root" stash pop 2>/dev/null; then
133
+ log_warn "git stash pop failed after rebase abort — run 'git stash list' to check"
134
+ fi
135
+ fi
121
136
  return 1
122
137
  fi
123
138
  # After the rebase we are on dev_branch — checkout original for the fast-forward
124
139
  if ! git -C "$project_root" checkout "$original_branch" 2>/dev/null; then
125
140
  log_error "Failed to checkout $original_branch for merge"
126
- [[ "$had_stash" == true ]] && git -C "$project_root" stash pop 2>/dev/null || true
141
+ if [[ "$had_stash" == true ]]; then
142
+ if ! git -C "$project_root" stash pop 2>/dev/null; then
143
+ log_warn "git stash pop failed after checkout failure — run 'git stash list' to check"
144
+ fi
145
+ fi
127
146
  return 1
128
147
  fi
129
148
 
@@ -131,8 +150,11 @@ branch_merge() {
131
150
  if ! git -C "$project_root" merge --ff-only "$dev_branch" 2>&1; then
132
151
  log_error "Merge failed after rebase — this should not happen, resolve manually:"
133
152
  log_error " git checkout $original_branch && git rebase $dev_branch"
134
- git -C "$project_root" checkout "$dev_branch" 2>/dev/null || true
135
- [[ "$had_stash" == true ]] && git -C "$project_root" stash pop 2>/dev/null || true
153
+ if [[ "$had_stash" == true ]]; then
154
+ if ! git -C "$project_root" stash pop 2>/dev/null; then
155
+ log_warn "git stash pop failed after merge failure — run 'git stash list' to check"
156
+ fi
157
+ fi
136
158
  return 1
137
159
  fi
138
160
 
@@ -152,8 +174,150 @@ branch_merge() {
152
174
  git -C "$project_root" branch -d "$dev_branch" 2>/dev/null && \
153
175
  log_info "Deleted merged branch: $dev_branch" || true
154
176
 
155
- # Step 6: Restore stashed state/ files
156
- [[ "$had_stash" == true ]] && git -C "$project_root" stash pop 2>/dev/null || true
177
+ # Step 6: Restore stashed files
178
+ if [[ "$had_stash" == true ]]; then
179
+ if ! git -C "$project_root" stash pop 2>/dev/null; then
180
+ log_warn "git stash pop failed after merge — stashed changes may be lost. Run 'git stash list' to check."
181
+ fi
182
+ fi
183
+
184
+ return 0
185
+ }
186
+
187
+ # branch_save_wip <project_root> <dev_branch>
188
+ #
189
+ # Saves any uncommitted work-in-progress on the dev branch before returning
190
+ # to the original branch. Called during interrupt/crash cleanup to preserve
191
+ # partially completed AI work that hasn't been committed yet.
192
+ #
193
+ # Commits ALL changes (tracked + untracked, excluding gitignored) with a
194
+ # "wip:" prefix message so it's easy to identify and squash later.
195
+ #
196
+ # Safe to call when the working tree is clean — it simply does nothing.
197
+ # Never fails — errors are logged but the function always returns 0.
198
+ branch_save_wip() {
199
+ local project_root="$1"
200
+ local dev_branch="$2"
201
+
202
+ # Nothing to save if dev_branch is empty
203
+ if [[ -z "$dev_branch" ]]; then
204
+ return 0
205
+ fi
206
+
207
+ # Verify we're actually on the dev branch
208
+ local current_branch
209
+ current_branch=$(git -C "$project_root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
210
+ if [[ "$current_branch" != "$dev_branch" ]]; then
211
+ return 0
212
+ fi
213
+
214
+ # Check if there are any uncommitted changes (tracked or untracked, excluding gitignored)
215
+ local has_changes
216
+ has_changes=$(git -C "$project_root" status --porcelain 2>/dev/null || true)
217
+ if [[ -z "$has_changes" ]]; then
218
+ return 0
219
+ fi
220
+
221
+ log_warn "Saving uncommitted work-in-progress on branch: $dev_branch"
222
+
223
+ # Stage all changes (tracked + untracked, respects .gitignore)
224
+ if ! git -C "$project_root" add -A 2>/dev/null; then
225
+ log_warn "git add -A failed — uncommitted work may be lost on branch switch"
226
+ return 0
227
+ fi
228
+
229
+ # Commit with WIP marker
230
+ if git -C "$project_root" commit --no-verify \
231
+ -m "wip($dev_branch): interrupted — uncommitted work saved" \
232
+ -m "Pipeline was interrupted by signal. This commit preserves work-in-progress." \
233
+ -m "To resume: git checkout $dev_branch" 2>/dev/null; then
234
+ log_info "Saved uncommitted work on branch $dev_branch"
235
+ else
236
+ log_warn "git commit failed — uncommitted work may be lost on branch switch"
237
+ fi
238
+
239
+ return 0
240
+ }
241
+
242
+ # branch_ensure_return <project_root> <original_branch> [dev_branch]
243
+ #
244
+ # GUARANTEED return to the original branch. Like a try/finally block.
245
+ # Must be called in EVERY exit path: success, failure, interrupt, crash.
246
+ # This is the single point of truth for "always go back to original branch".
247
+ #
248
+ # If dev_branch is provided and we're currently on it, any uncommitted
249
+ # work is saved as a WIP commit before switching (via branch_save_wip).
250
+ #
251
+ # Handles:
252
+ # - Saving uncommitted WIP on dev branch (if dev_branch provided)
253
+ # - Aborting any in-progress rebase (leftover from branch_merge failure)
254
+ # - Stashing any tracked dirty files that block checkout
255
+ # - Checking out original_branch
256
+ # - Restoring stashed files
257
+ # - Logging for diagnostics
258
+ #
259
+ # Never fails — errors are logged but the function always returns 0
260
+ # so it can be used in cleanup traps without breaking error handling.
261
+ branch_ensure_return() {
262
+ local project_root="$1"
263
+ local original_branch="$2"
264
+ local dev_branch="${3:-}"
265
+
266
+ # If original_branch is empty or unset, nothing to return to
267
+ if [[ -z "$original_branch" ]]; then
268
+ return 0
269
+ fi
270
+
271
+ # Abort any in-progress rebase (can happen if branch_merge failed mid-way)
272
+ if git -C "$project_root" rebase --show-current-patch >/dev/null 2>&1; then
273
+ log_warn "Aborting in-progress rebase..."
274
+ git -C "$project_root" rebase --abort 2>/dev/null || true
275
+ fi
276
+
277
+ # Check current branch
278
+ local current_branch
279
+ current_branch=$(git -C "$project_root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
280
+
281
+ if [[ "$current_branch" == "$original_branch" ]]; then
282
+ return 0
283
+ fi
284
+
285
+ # Save any uncommitted WIP on dev branch before switching away
286
+ # Use dev_branch if provided; otherwise infer from current_branch
287
+ local _wip_branch="${dev_branch:-$current_branch}"
288
+ if [[ -n "$_wip_branch" && "$_wip_branch" != "$original_branch" ]]; then
289
+ branch_save_wip "$project_root" "$_wip_branch"
290
+ fi
291
+
292
+ log_info "Ensuring return to original branch: $original_branch (currently on: ${current_branch:-unknown})"
293
+
294
+ # Stash any tracked dirty files that would block checkout
295
+ # (branch_save_wip should have committed everything, but this is a safety net
296
+ # in case the commit failed or new files appeared)
297
+ local had_stash=false
298
+ local tracked_dirty
299
+ tracked_dirty=$(git -C "$project_root" diff --name-only 2>/dev/null || true)
300
+ local staged_dirty
301
+ staged_dirty=$(git -C "$project_root" diff --cached --name-only 2>/dev/null || true)
302
+ if [[ -n "$tracked_dirty" || -n "$staged_dirty" ]]; then
303
+ if git -C "$project_root" stash push -m "pipeline-ensure-return-stash" 2>/dev/null; then
304
+ had_stash=true
305
+ fi
306
+ fi
307
+
308
+ # Checkout original branch
309
+ if git -C "$project_root" checkout "$original_branch" 2>/dev/null; then
310
+ log_info "Returned to branch: $original_branch"
311
+ else
312
+ log_error "Failed to checkout $original_branch — manual recovery needed"
313
+ fi
314
+
315
+ # Restore stashed files
316
+ if [[ "$had_stash" == true ]]; then
317
+ if ! git -C "$project_root" stash pop 2>/dev/null; then
318
+ log_warn "git stash pop failed during branch return — stashed changes may be lost. Run 'git stash list' to check."
319
+ fi
320
+ fi
157
321
 
158
322
  return 0
159
323
  }
@@ -254,8 +254,15 @@ if incomplete:
254
254
  sys.exit(1)
255
255
  print('ALL_COMPLETE')
256
256
  sys.exit(0)
257
- " "$checkpoint_file" 2>&1)
257
+ " "$checkpoint_file" 2>&1) || checkpoint_result="CHECK_FAILED"
258
258
  local check_exit=$?
259
+ if [[ "$checkpoint_result" == "CHECK_FAILED" ]]; then
260
+ check_exit=2
261
+ elif [[ "$checkpoint_result" == *"INCOMPLETE"* ]]; then
262
+ check_exit=1
263
+ else
264
+ check_exit=0
265
+ fi
259
266
  if [[ $check_exit -eq 2 ]]; then
260
267
  log_warn "CHECKPOINT_CORRUPTED: workflow-checkpoint.json is not valid JSON"
261
268
  elif [[ $check_exit -eq 1 ]]; then
@@ -270,7 +277,7 @@ sys.exit(0)
270
277
  # Subagent detection
271
278
  prizm_detect_subagents "$session_log"
272
279
 
273
- # Update bug status
280
+ # Update bug status (do NOT commit on dev branch — commit happens after merge)
274
281
  python3 "$SCRIPTS_DIR/update-bug-status.py" \
275
282
  --bug-list "$bug_list" \
276
283
  --state-dir "$STATE_DIR" \
@@ -280,12 +287,6 @@ sys.exit(0)
280
287
  --max-retries "$max_retries" \
281
288
  --action update >/dev/null 2>&1 || true
282
289
 
283
- # Commit .prizmkit/plans/bug-fix-list.json status update (pipeline management commit)
284
- if ! git -C "$project_root" diff --quiet "$bug_list" 2>/dev/null; then
285
- git -C "$project_root" add "$bug_list"
286
- git -C "$project_root" commit --no-verify -m "chore($bug_id): update bug status" 2>/dev/null || true
287
- fi
288
-
289
290
  _SPAWN_RESULT="$session_status"
290
291
  }
291
292
 
@@ -306,13 +307,40 @@ cleanup() {
306
307
  log_info "Original branch was: $_ORIGINAL_BRANCH"
307
308
  fi
308
309
 
310
+ # Update status of currently in-progress bug to interrupted
309
311
  if [[ -n "$BUG_LIST" && -f "$BUG_LIST" ]]; then
312
+ # Find any in-progress bug and mark it as failed
313
+ local _interrupted_id
314
+ _interrupted_id=$(python3 -c "
315
+ import json, sys
316
+ with open(sys.argv[1]) as f:
317
+ data = json.load(f)
318
+ for bug in data.get('bugs', []):
319
+ if bug.get('status') == 'in_progress':
320
+ print(bug['id'])
321
+ break
322
+ " "$BUG_LIST" 2>/dev/null || echo "")
323
+
324
+ if [[ -n "$_interrupted_id" ]]; then
325
+ python3 "$SCRIPTS_DIR/update-bug-status.py" \
326
+ --bug-list "$BUG_LIST" \
327
+ --state-dir "$STATE_DIR" \
328
+ --bug-id "$_interrupted_id" \
329
+ --session-status "failed" \
330
+ --action update 2>/dev/null || true
331
+ log_info "Bug $_interrupted_id marked as failed due to interrupt"
332
+ fi
333
+
334
+ # Pause the pipeline
310
335
  python3 "$SCRIPTS_DIR/update-bug-status.py" \
311
336
  --bug-list "$BUG_LIST" \
312
337
  --state-dir "$STATE_DIR" \
313
338
  --action pause 2>/dev/null || true
314
339
  fi
315
340
 
341
+ # GUARANTEED: always return to original branch (save WIP on dev branch first)
342
+ branch_ensure_return "$(cd "$SCRIPT_DIR/.." && pwd)" "$_ORIGINAL_BRANCH" "$_DEV_BRANCH_NAME"
343
+
316
344
  log_info "Bug fix pipeline paused. Run './run-bugfix.sh run' to resume."
317
345
  exit 130
318
346
  }
@@ -639,6 +667,20 @@ else:
639
667
  log_info "Development was on branch: $_DEV_BRANCH_NAME"
640
668
  fi
641
669
  log_info "Session log: $session_dir/logs/session.log"
670
+
671
+ # Update bug status to failed on interrupt
672
+ if [[ -n "$bug_list" && -f "$bug_list" ]]; then
673
+ python3 "$SCRIPTS_DIR/update-bug-status.py" \
674
+ --bug-list "$bug_list" \
675
+ --state-dir "$STATE_DIR" \
676
+ --bug-id "$bug_id" \
677
+ --session-status "failed" \
678
+ --action update 2>/dev/null || true
679
+ log_info "Bug $bug_id marked as failed due to interrupt"
680
+ fi
681
+
682
+ # GUARANTEED: always return to original branch (save WIP on dev branch first)
683
+ branch_ensure_return "$(cd "$SCRIPT_DIR/.." && pwd)" "$_ORIGINAL_BRANCH" "$_DEV_BRANCH_NAME"
642
684
  exit 130
643
685
  }
644
686
  trap cleanup_single_bug SIGINT SIGTERM
@@ -652,6 +694,20 @@ else:
652
694
  _source_branch=$(git -C "$_proj_root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "main")
653
695
  _ORIGINAL_BRANCH="$_source_branch"
654
696
 
697
+ # Mark bug as in-progress BEFORE creating dev branch
698
+ # This ensures the in_progress status commit lands on the original branch,
699
+ # not the dev branch — preventing rebase conflicts in branch_merge later.
700
+ python3 "$SCRIPTS_DIR/update-bug-status.py" \
701
+ --bug-list "$bug_list" \
702
+ --state-dir "$STATE_DIR" \
703
+ --bug-id "$bug_id" \
704
+ --action start >/dev/null 2>&1 || true
705
+ # Commit the in_progress status on the original branch
706
+ if ! git -C "$_proj_root" diff --quiet "$bug_list" 2>/dev/null; then
707
+ git -C "$_proj_root" add "$bug_list" 2>/dev/null || true
708
+ git -C "$_proj_root" commit --no-verify -m "chore($bug_id): mark in_progress" 2>/dev/null || true
709
+ fi
710
+
655
711
  local _branch_name="${DEV_BRANCH:-bugfix/${bug_id}-$(date +%s)}"
656
712
  if branch_create "$_proj_root" "$_branch_name" "$_source_branch"; then
657
713
  _DEV_BRANCH_NAME="$_branch_name"
@@ -671,18 +727,23 @@ else:
671
727
  else
672
728
  log_warn "Auto-merge failed — dev branch preserved: $_DEV_BRANCH_NAME"
673
729
  log_warn "Merge manually: git checkout $_ORIGINAL_BRANCH && git rebase $_DEV_BRANCH_NAME"
674
- git -C "$_proj_root" checkout "$_ORIGINAL_BRANCH" 2>/dev/null || true
675
730
  _DEV_BRANCH_NAME=""
676
731
  fi
677
732
  elif [[ -n "$_DEV_BRANCH_NAME" ]]; then
678
- # Session failed — return to original branch, preserve dev branch for inspection
679
- if ! git -C "$_proj_root" checkout "$_ORIGINAL_BRANCH" 2>/dev/null; then
680
- log_warn "Failed to checkout $_ORIGINAL_BRANCH after session failure — staying on dev branch"
681
- fi
733
+ # Session failed — preserve dev branch for inspection
682
734
  log_warn "Session failed — dev branch preserved for inspection: $_DEV_BRANCH_NAME"
683
735
  _DEV_BRANCH_NAME=""
684
736
  fi
685
737
 
738
+ # GUARANTEED: always return to original branch regardless of success/failure/merge outcome
739
+ branch_ensure_return "$_proj_root" "$_ORIGINAL_BRANCH"
740
+
741
+ # Commit bug status update on the original branch (after guaranteed return)
742
+ if ! git -C "$_proj_root" diff --quiet "$bug_list" 2>/dev/null; then
743
+ git -C "$_proj_root" add "$bug_list"
744
+ git -C "$_proj_root" commit --no-verify -m "chore($bug_id): update bug status" 2>/dev/null || true
745
+ fi
746
+
686
747
  echo ""
687
748
  if [[ "$session_status" == "success" ]]; then
688
749
  log_success "════════════════════════════════════════════════════"
@@ -785,6 +846,18 @@ main() {
785
846
  local total_subagent_calls=0
786
847
 
787
848
  while true; do
849
+ # Safety net: ensure we're on the original branch at the start of each iteration.
850
+ # If a previous iteration's `continue` skipped branch_ensure_return, we could
851
+ # still be on a dev branch. This prevents cascading branch confusion.
852
+ if [[ -n "$_ORIGINAL_BRANCH" ]]; then
853
+ local _cur_branch
854
+ _cur_branch=$(git -C "$_proj_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
855
+ if [[ -n "$_cur_branch" && "$_cur_branch" != "$_ORIGINAL_BRANCH" ]]; then
856
+ log_warn "Still on branch $_cur_branch at loop start — returning to $_ORIGINAL_BRANCH"
857
+ branch_ensure_return "$_proj_root" "$_ORIGINAL_BRANCH"
858
+ fi
859
+ fi
860
+
788
861
  # Find next bug to process
789
862
  local next_bug
790
863
  if ! next_bug=$(python3 "$SCRIPTS_DIR/update-bug-status.py" \
@@ -839,7 +912,21 @@ main() {
839
912
  git -C "$_proj_root" commit --no-verify -m "chore: capture artifacts before $bug_id session" 2>/dev/null || true
840
913
  fi
841
914
 
842
- # Create per-bug dev branch
915
+ # Mark bug as in-progress BEFORE creating dev branch
916
+ # This ensures the in_progress status commit lands on the original branch,
917
+ # not the dev branch — preventing rebase conflicts in branch_merge later.
918
+ python3 "$SCRIPTS_DIR/update-bug-status.py" \
919
+ --bug-list "$bug_list" \
920
+ --state-dir "$STATE_DIR" \
921
+ --bug-id "$bug_id" \
922
+ --action start >/dev/null 2>&1 || true
923
+ # Commit the in_progress status on the original branch
924
+ if ! git -C "$_proj_root" diff --quiet "$bug_list" 2>/dev/null; then
925
+ git -C "$_proj_root" add "$bug_list" 2>/dev/null || true
926
+ git -C "$_proj_root" commit --no-verify -m "chore($bug_id): mark in_progress" 2>/dev/null || true
927
+ fi
928
+
929
+ # Create per-bug dev branch (from the now-updated original branch)
843
930
  local _bug_branch="${DEV_BRANCH:-bugfix/${bug_id}-$(date +%Y%m%d%H%M)}"
844
931
  if branch_create "$_proj_root" "$_bug_branch" "$_ORIGINAL_BRANCH"; then
845
932
  _DEV_BRANCH_NAME="$_bug_branch"
@@ -885,6 +972,8 @@ main() {
885
972
  local gen_output
886
973
  gen_output=$(python3 "$SCRIPTS_DIR/generate-bugfix-prompt.py" "${main_prompt_args[@]}" 2>/dev/null) || {
887
974
  log_error "Failed to generate bootstrap prompt for $bug_id"
975
+ branch_ensure_return "$_proj_root" "$_ORIGINAL_BRANCH"
976
+ _DEV_BRANCH_NAME=""
888
977
  continue
889
978
  }
890
979
  local bug_model pipeline_mode agent_count critic_enabled
@@ -907,13 +996,6 @@ main() {
907
996
  log_info "Bug model: $bug_model"
908
997
  fi
909
998
 
910
- # Mark bug as in-progress before spawning session
911
- python3 "$SCRIPTS_DIR/update-bug-status.py" \
912
- --bug-list "$bug_list" \
913
- --state-dir "$STATE_DIR" \
914
- --bug-id "$bug_id" \
915
- --action start >/dev/null 2>&1 || true
916
-
917
999
  # Spawn session
918
1000
  log_info "Spawning AI CLI session: $session_id"
919
1001
  _SPAWN_RESULT=""
@@ -931,18 +1013,23 @@ main() {
931
1013
  else
932
1014
  log_warn "Auto-merge failed — dev branch preserved: $_DEV_BRANCH_NAME"
933
1015
  log_warn "Merge manually: git checkout $_ORIGINAL_BRANCH && git rebase $_DEV_BRANCH_NAME"
934
- git -C "$_proj_root" checkout "$_ORIGINAL_BRANCH" 2>/dev/null || true
935
1016
  _DEV_BRANCH_NAME=""
936
1017
  fi
937
1018
  elif [[ -n "$_DEV_BRANCH_NAME" ]]; then
938
- # Session failed — return to original branch, preserve dev branch for inspection
939
- if ! git -C "$_proj_root" checkout "$_ORIGINAL_BRANCH" 2>/dev/null; then
940
- log_warn "Failed to checkout $_ORIGINAL_BRANCH after session failure — staying on dev branch"
941
- fi
1019
+ # Session failed — preserve dev branch for inspection
942
1020
  log_warn "Session failed — dev branch preserved for inspection: $_DEV_BRANCH_NAME"
943
1021
  _DEV_BRANCH_NAME=""
944
1022
  fi
945
1023
 
1024
+ # GUARANTEED: always return to original branch regardless of success/failure/merge outcome
1025
+ branch_ensure_return "$_proj_root" "$_ORIGINAL_BRANCH"
1026
+
1027
+ # Commit bug status update on the original branch (after guaranteed return)
1028
+ if ! git -C "$_proj_root" diff --quiet "$bug_list" 2>/dev/null; then
1029
+ git -C "$_proj_root" add "$bug_list"
1030
+ git -C "$_proj_root" commit --no-verify -m "chore($bug_id): update bug status" 2>/dev/null || true
1031
+ fi
1032
+
946
1033
  session_count=$((session_count + 1))
947
1034
  total_subagent_calls=$((total_subagent_calls + _SUBAGENT_COUNT))
948
1035
 
@@ -308,8 +308,15 @@ if incomplete:
308
308
  sys.exit(1)
309
309
  print('ALL_COMPLETE')
310
310
  sys.exit(0)
311
- " "$checkpoint_file" 2>&1)
311
+ " "$checkpoint_file" 2>&1) || checkpoint_result="CHECK_FAILED"
312
312
  local check_exit=$?
313
+ if [[ "$checkpoint_result" == "CHECK_FAILED" ]]; then
314
+ check_exit=2
315
+ elif [[ "$checkpoint_result" == *"INCOMPLETE"* ]]; then
316
+ check_exit=1
317
+ else
318
+ check_exit=0
319
+ fi
313
320
  if [[ $check_exit -eq 2 ]]; then
314
321
  log_warn "CHECKPOINT_CORRUPTED: workflow-checkpoint.json is not valid JSON"
315
322
  elif [[ $check_exit -eq 1 ]]; then
@@ -352,7 +359,7 @@ sys.exit(0)
352
359
  fi
353
360
  fi
354
361
 
355
- # Update feature status
362
+ # Update feature status (do NOT commit on dev branch — commit happens after merge)
356
363
  local update_output
357
364
  update_output=$(python3 "$SCRIPTS_DIR/update-feature-status.py" \
358
365
  --feature-list "$feature_list" \
@@ -366,12 +373,6 @@ sys.exit(0)
366
373
  log_error ".prizmkit/plans/feature-list.json may be out of sync. Manual intervention needed."
367
374
  }
368
375
 
369
- # Commit feature status update (pipeline management commit)
370
- if ! git -C "$project_root" diff --quiet "$feature_list" 2>/dev/null; then
371
- git -C "$project_root" add "$feature_list"
372
- git -C "$project_root" commit --no-verify -m "chore($feature_id): update feature status" 2>/dev/null || true
373
- fi
374
-
375
376
  # Return status via global variable (avoids $() swallowing stdout)
376
377
  _SPAWN_RESULT="$session_status"
377
378
  }
@@ -393,13 +394,40 @@ cleanup() {
393
394
  log_info "Original branch was: $_ORIGINAL_BRANCH"
394
395
  fi
395
396
 
397
+ # Update status of currently in-progress feature to interrupted
396
398
  if [[ -n "$FEATURE_LIST" && -f "$FEATURE_LIST" ]]; then
399
+ # Find any in-progress feature and mark it as interrupted
400
+ local _interrupted_id
401
+ _interrupted_id=$(python3 -c "
402
+ import json, sys
403
+ with open(sys.argv[1]) as f:
404
+ data = json.load(f)
405
+ for feat in data.get('features', []):
406
+ if feat.get('status') == 'in_progress':
407
+ print(feat['id'])
408
+ break
409
+ " "$FEATURE_LIST" 2>/dev/null || echo "")
410
+
411
+ if [[ -n "$_interrupted_id" ]]; then
412
+ python3 "$SCRIPTS_DIR/update-feature-status.py" \
413
+ --feature-list "$FEATURE_LIST" \
414
+ --state-dir "$STATE_DIR" \
415
+ --feature-id "$_interrupted_id" \
416
+ --session-status "failed" \
417
+ --action update 2>/dev/null || true
418
+ log_info "Feature $_interrupted_id marked as failed due to interrupt"
419
+ fi
420
+
421
+ # Pause the pipeline (mark remaining pending items)
397
422
  python3 "$SCRIPTS_DIR/update-feature-status.py" \
398
423
  --feature-list "$FEATURE_LIST" \
399
424
  --state-dir "$STATE_DIR" \
400
425
  --action pause 2>/dev/null || true
401
426
  fi
402
427
 
428
+ # GUARANTEED: always return to original branch (save WIP on dev branch first)
429
+ branch_ensure_return "$(cd "$SCRIPT_DIR/.." && pwd)" "$_ORIGINAL_BRANCH" "$_DEV_BRANCH_NAME"
430
+
403
431
  log_info "Pipeline paused. Run './run-feature.sh run' to resume."
404
432
  exit 130
405
433
  }
@@ -814,6 +842,20 @@ else:
814
842
  log_info "Development was on branch: $_DEV_BRANCH_NAME"
815
843
  fi
816
844
  log_info "Session log: $session_dir/logs/session.log"
845
+
846
+ # Update feature status to failed on interrupt
847
+ if [[ -n "$feature_list" && -f "$feature_list" ]]; then
848
+ python3 "$SCRIPTS_DIR/update-feature-status.py" \
849
+ --feature-list "$feature_list" \
850
+ --state-dir "$STATE_DIR" \
851
+ --feature-id "$feature_id" \
852
+ --session-status "failed" \
853
+ --action update 2>/dev/null || true
854
+ log_info "Feature $feature_id marked as failed due to interrupt"
855
+ fi
856
+
857
+ # GUARANTEED: always return to original branch (save WIP on dev branch first)
858
+ branch_ensure_return "$(cd "$SCRIPT_DIR/.." && pwd)" "$_ORIGINAL_BRANCH" "$_DEV_BRANCH_NAME"
817
859
  exit 130
818
860
  }
819
861
  trap cleanup_single_feature SIGINT SIGTERM
@@ -827,6 +869,20 @@ else:
827
869
  _source_branch=$(git -C "$_proj_root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "main")
828
870
  _ORIGINAL_BRANCH="$_source_branch"
829
871
 
872
+ # Mark feature as in-progress BEFORE creating dev branch
873
+ # This ensures the in_progress status commit lands on the original branch,
874
+ # not the dev branch — preventing rebase conflicts in branch_merge later.
875
+ python3 "$SCRIPTS_DIR/update-feature-status.py" \
876
+ --feature-list "$feature_list" \
877
+ --state-dir "$STATE_DIR" \
878
+ --feature-id "$feature_id" \
879
+ --action start >/dev/null 2>&1 || true
880
+ # Commit the in_progress status on the original branch
881
+ if ! git -C "$_proj_root" diff --quiet "$feature_list" 2>/dev/null; then
882
+ git -C "$_proj_root" add "$feature_list" 2>/dev/null || true
883
+ git -C "$_proj_root" commit --no-verify -m "chore($feature_id): mark in_progress" 2>/dev/null || true
884
+ fi
885
+
830
886
  local _branch_name="${DEV_BRANCH:-dev/${feature_id}-$(date +%Y%m%d%H%M)}"
831
887
  if branch_create "$_proj_root" "$_branch_name" "$_source_branch"; then
832
888
  _DEV_BRANCH_NAME="$_branch_name"
@@ -846,18 +902,23 @@ else:
846
902
  else
847
903
  log_warn "Auto-merge failed — dev branch preserved: $_DEV_BRANCH_NAME"
848
904
  log_warn "Merge manually: git checkout $_ORIGINAL_BRANCH && git rebase $_DEV_BRANCH_NAME"
849
- git -C "$_proj_root" checkout "$_ORIGINAL_BRANCH" 2>/dev/null || true
850
905
  _DEV_BRANCH_NAME=""
851
906
  fi
852
907
  elif [[ -n "$_DEV_BRANCH_NAME" ]]; then
853
- # Session failed — return to original branch, preserve dev branch for inspection
854
- if ! git -C "$_proj_root" checkout "$_ORIGINAL_BRANCH" 2>/dev/null; then
855
- log_warn "Failed to checkout $_ORIGINAL_BRANCH after session failure — staying on dev branch"
856
- fi
908
+ # Session failed — preserve dev branch for inspection
857
909
  log_warn "Session failed — dev branch preserved for inspection: $_DEV_BRANCH_NAME"
858
910
  _DEV_BRANCH_NAME=""
859
911
  fi
860
912
 
913
+ # GUARANTEED: always return to original branch regardless of success/failure/merge outcome
914
+ branch_ensure_return "$_proj_root" "$_ORIGINAL_BRANCH"
915
+
916
+ # Commit feature status update on the original branch (after guaranteed return)
917
+ if ! git -C "$_proj_root" diff --quiet "$feature_list" 2>/dev/null; then
918
+ git -C "$_proj_root" add "$feature_list"
919
+ git -C "$_proj_root" commit --no-verify -m "chore($feature_id): update feature status" 2>/dev/null || true
920
+ fi
921
+
861
922
  echo ""
862
923
  if [[ "$session_status" == "success" ]]; then
863
924
  log_success "════════════════════════════════════════════════════"
@@ -980,6 +1041,18 @@ main() {
980
1041
  local total_subagent_calls=0
981
1042
 
982
1043
  while true; do
1044
+ # Safety net: ensure we're on the original branch at the start of each iteration.
1045
+ # If a previous iteration's `continue` skipped branch_ensure_return, we could
1046
+ # still be on a dev branch. This prevents cascading branch confusion.
1047
+ if [[ -n "$_ORIGINAL_BRANCH" ]]; then
1048
+ local _cur_branch
1049
+ _cur_branch=$(git -C "$_proj_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
1050
+ if [[ -n "$_cur_branch" && "$_cur_branch" != "$_ORIGINAL_BRANCH" ]]; then
1051
+ log_warn "Still on branch $_cur_branch at loop start — returning to $_ORIGINAL_BRANCH"
1052
+ branch_ensure_return "$_proj_root" "$_ORIGINAL_BRANCH"
1053
+ fi
1054
+ fi
1055
+
983
1056
  # Check for stuck features
984
1057
  local stuck_result
985
1058
  stuck_result=$(python3 "$SCRIPTS_DIR/detect-stuck.py" \
@@ -1081,7 +1154,21 @@ print(count)
1081
1154
  git -C "$_proj_root" commit --no-verify -m "ready for run $feature_id" 2>/dev/null || true
1082
1155
  fi
1083
1156
 
1084
- # Create per-feature dev branch
1157
+ # Mark feature as in-progress BEFORE creating dev branch
1158
+ # This ensures the in_progress status commit lands on the original branch,
1159
+ # not the dev branch — preventing rebase conflicts in branch_merge later.
1160
+ python3 "$SCRIPTS_DIR/update-feature-status.py" \
1161
+ --feature-list "$feature_list" \
1162
+ --state-dir "$STATE_DIR" \
1163
+ --feature-id "$feature_id" \
1164
+ --action start >/dev/null 2>&1 || true
1165
+ # Commit the in_progress status on the original branch
1166
+ if ! git -C "$_proj_root" diff --quiet "$feature_list" 2>/dev/null; then
1167
+ git -C "$_proj_root" add "$feature_list" 2>/dev/null || true
1168
+ git -C "$_proj_root" commit --no-verify -m "chore($feature_id): mark in_progress" 2>/dev/null || true
1169
+ fi
1170
+
1171
+ # Create per-feature dev branch (from the now-updated original branch)
1085
1172
  local _feature_branch="${DEV_BRANCH:-dev/${feature_id}-$(date +%Y%m%d%H%M)}"
1086
1173
  if branch_create "$_proj_root" "$_feature_branch" "$_ORIGINAL_BRANCH"; then
1087
1174
  _DEV_BRANCH_NAME="$_feature_branch"
@@ -1127,6 +1214,8 @@ print(count)
1127
1214
  local gen_output
1128
1215
  gen_output=$(python3 "$SCRIPTS_DIR/generate-bootstrap-prompt.py" "${main_prompt_args[@]}" 2>/dev/null) || {
1129
1216
  log_error "Failed to generate bootstrap prompt for $feature_id"
1217
+ branch_ensure_return "$_proj_root" "$_ORIGINAL_BRANCH"
1218
+ _DEV_BRANCH_NAME=""
1130
1219
  continue
1131
1220
  }
1132
1221
  local feature_model pipeline_mode agent_count critic_enabled
@@ -1146,13 +1235,6 @@ print(count)
1146
1235
  log_info "Pipeline mode: ${BOLD}$pipeline_mode${NC} ($_mode_desc)"
1147
1236
  log_info "Agents: $agent_count (critic: $([ "$critic_enabled" = "true" ] && echo "enabled" || echo "disabled"))"
1148
1237
 
1149
- # Mark feature as in-progress before spawning session
1150
- python3 "$SCRIPTS_DIR/update-feature-status.py" \
1151
- --feature-list "$feature_list" \
1152
- --state-dir "$STATE_DIR" \
1153
- --feature-id "$feature_id" \
1154
- --action start >/dev/null 2>&1 || true
1155
-
1156
1238
  # Spawn session and wait
1157
1239
  prizm_log_bootstrap_prompt "$bootstrap_prompt" "$feature_id"
1158
1240
  log_info "Spawning AI CLI session: $session_id"
@@ -1173,19 +1255,23 @@ print(count)
1173
1255
  else
1174
1256
  log_warn "Auto-merge failed — dev branch preserved: $_DEV_BRANCH_NAME"
1175
1257
  log_warn "Merge manually: git checkout $_ORIGINAL_BRANCH && git rebase $_DEV_BRANCH_NAME"
1176
- # Return to original branch; state/ files are untracked and persist across checkout
1177
- git -C "$_proj_root" checkout "$_ORIGINAL_BRANCH" 2>/dev/null || true
1178
1258
  _DEV_BRANCH_NAME=""
1179
1259
  fi
1180
1260
  elif [[ -n "$_DEV_BRANCH_NAME" ]]; then
1181
- # Session failed — return to original branch, preserve dev branch for inspection
1182
- if ! git -C "$_proj_root" checkout "$_ORIGINAL_BRANCH" 2>/dev/null; then
1183
- log_warn "Failed to checkout $_ORIGINAL_BRANCH after session failure — staying on dev branch"
1184
- fi
1261
+ # Session failed — preserve dev branch for inspection
1185
1262
  log_warn "Session failed — dev branch preserved for inspection: $_DEV_BRANCH_NAME"
1186
1263
  _DEV_BRANCH_NAME=""
1187
1264
  fi
1188
1265
 
1266
+ # GUARANTEED: always return to original branch regardless of success/failure/merge outcome
1267
+ branch_ensure_return "$_proj_root" "$_ORIGINAL_BRANCH"
1268
+
1269
+ # Commit feature status update on the original branch (after guaranteed return)
1270
+ if ! git -C "$_proj_root" diff --quiet "$feature_list" 2>/dev/null; then
1271
+ git -C "$_proj_root" add "$feature_list"
1272
+ git -C "$_proj_root" commit --no-verify -m "chore($feature_id): update feature status" 2>/dev/null || true
1273
+ fi
1274
+
1189
1275
  session_count=$((session_count + 1))
1190
1276
  total_subagent_calls=$((total_subagent_calls + _SUBAGENT_COUNT))
1191
1277
 
@@ -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" \
@@ -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
@@ -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]
@@ -364,17 +364,32 @@ You MUST execute this phase. Do NOT skip it. Do NOT mark it as completed without
364
364
  You know this project's tech stack. Detect and start the dev server yourself:
365
365
 
366
366
  1. Identify the dev server start command from project config (`package.json` scripts, `Makefile`, `docker-compose.yml`, etc.)
367
- 2. Choose an available port — check what the project defaults to, or pick one that is free:
367
+ 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:
368
368
  ```bash
369
- lsof -ti:<port> 2>/dev/null && echo "PORT_IN_USE" || echo "PORT_FREE"
369
+ # Use pipeline-injected port if available, otherwise extract from package.json
370
+ DEV_PORT={{DEV_PORT}}
371
+ # If DEV_PORT is still a placeholder, detect at runtime:
372
+ if [ "$DEV_PORT" = "{{DEV_PORT}}" ]; then
373
+ DEV_PORT=$(node -e "const s=require('./package.json').scripts.dev; const m=s.match(/-p\s+(\d+)/); console.log(m?m[1]:'')")
374
+ if [ -z "$DEV_PORT" ]; then
375
+ DEV_PORT=$(echo "$NEXT_PUBLIC_SITE_URL" | sed -nE 's|.*:([0-9]+).*|\1|p')
376
+ fi
377
+ DEV_PORT=${DEV_PORT:-3000}
378
+ fi
379
+ echo "Detected DEV_PORT=$DEV_PORT"
370
380
  ```
371
- 3. Start the dev server in background, capture PID:
381
+ 3. Verify the port is available:
382
+ ```bash
383
+ lsof -ti:$DEV_PORT 2>/dev/null && echo "PORT_IN_USE" || echo "PORT_FREE"
384
+ ```
385
+ 4. Start the dev server in background, capture PID:
372
386
  ```bash
373
387
  <start-command> &
374
388
  DEV_SERVER_PID=$!
375
389
  ```
376
- 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)
377
- 5. If the page requires authentication, use playwright-cli to register a test user and log in first
390
+ 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)
391
+ 6. Open the app in playwright-cli: `playwright-cli open http://localhost:$DEV_PORT`
392
+ 7. If the page requires authentication, use playwright-cli to register a test user and log in first
378
393
 
379
394
  **Step 2 — Verification**:
380
395
 
@@ -387,14 +402,14 @@ Take a final screenshot for evidence.
387
402
  **Step 3 — Cleanup (REQUIRED — you started it, you stop it)**:
388
403
 
389
404
  1. Kill the dev server process: `kill $DEV_SERVER_PID 2>/dev/null || true`
390
- 2. Verify port is released: `lsof -ti:<port> | xargs kill -9 2>/dev/null || true`
405
+ 2. Verify port is released: `lsof -ti:$DEV_PORT | xargs kill -9 2>/dev/null || true`
391
406
 
392
407
  **Step 4 — Reporting**:
393
408
 
394
409
  Append results to `context-snapshot.md`:
395
410
  ```
396
411
  ## Browser Verification
397
- URL: <actual URL used>
412
+ URL: http://localhost:$DEV_PORT
398
413
  Dev Server Command: <actual command used>
399
414
  Steps executed: [list]
400
415
  Screenshot: [path]
@@ -7,17 +7,32 @@ You MUST execute this phase. Do NOT skip it. Do NOT mark it as completed without
7
7
  You know this project's tech stack. Detect and start the dev server yourself:
8
8
 
9
9
  1. Identify the dev server start command from project config (`package.json` scripts, `Makefile`, `docker-compose.yml`, etc.)
10
- 2. Choose an available port — check what the project defaults to, or pick one that is free:
10
+ 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:
11
11
  ```bash
12
- lsof -ti:<port> 2>/dev/null && echo "PORT_IN_USE" || echo "PORT_FREE"
12
+ # Use pipeline-injected port if available, otherwise extract from package.json
13
+ DEV_PORT={{DEV_PORT}}
14
+ # If DEV_PORT is still a placeholder, detect at runtime:
15
+ if [ "$DEV_PORT" = "{{DEV_PORT}}" ]; then
16
+ DEV_PORT=$(node -e "const s=require('./package.json').scripts.dev; const m=s.match(/-p\s+(\d+)/); console.log(m?m[1]:'')")
17
+ if [ -z "$DEV_PORT" ]; then
18
+ DEV_PORT=$(echo "$NEXT_PUBLIC_SITE_URL" | sed -nE 's|.*:([0-9]+).*|\1|p')
19
+ fi
20
+ DEV_PORT=${DEV_PORT:-3000}
21
+ fi
22
+ echo "Detected DEV_PORT=$DEV_PORT"
13
23
  ```
14
- 3. Start the dev server in background, capture PID:
24
+ 3. Verify the port is available:
25
+ ```bash
26
+ lsof -ti:$DEV_PORT 2>/dev/null && echo "PORT_IN_USE" || echo "PORT_FREE"
27
+ ```
28
+ 4. Start the dev server in background, capture PID:
15
29
  ```bash
16
30
  <start-command> &
17
31
  DEV_SERVER_PID=$!
18
32
  ```
19
- 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)
20
- 5. If the page requires authentication, use playwright-cli to register a test user and log in first
33
+ 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)
34
+ 6. Open the app in playwright-cli: `playwright-cli open http://localhost:$DEV_PORT`
35
+ 7. If the page requires authentication, use playwright-cli to register a test user and log in first
21
36
 
22
37
  **Step 2 — Verification**:
23
38
 
@@ -31,14 +46,14 @@ Take a final screenshot for evidence.
31
46
  **Step 3 — Cleanup (REQUIRED — you started it, you stop it)**:
32
47
 
33
48
  1. Kill the dev server process: `kill $DEV_SERVER_PID 2>/dev/null || true`
34
- 2. Verify port is released: `lsof -ti:<port> | xargs kill -9 2>/dev/null || true`
49
+ 2. Verify port is released: `lsof -ti:$DEV_PORT | xargs kill -9 2>/dev/null || true`
35
50
 
36
51
  **Step 4 — Reporting**:
37
52
 
38
53
  Append results to `context-snapshot.md`:
39
54
  ```
40
55
  ## Browser Verification
41
- URL: <actual URL used>
56
+ URL: http://localhost:$DEV_PORT
42
57
  Dev Server Command: <actual command used>
43
58
  Steps executed: [list]
44
59
  Screenshot: [path]
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.20",
2
+ "version": "1.1.21",
3
3
  "skills": {
4
4
  "prizm-kit": {
5
5
  "description": "Full-lifecycle dev toolkit. Covers spec-driven development, Prizm context docs, code quality, debugging, deployment, and knowledge management.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prizmkit",
3
- "version": "1.1.20",
3
+ "version": "1.1.21",
4
4
  "description": "Create a new PrizmKit-powered project with clean initialization — no framework dev files, just what you need.",
5
5
  "type": "module",
6
6
  "bin": {