prizmkit 1.1.19 → 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.19",
3
- "bundledAt": "2026-04-11T01:16:59.208Z",
4
- "bundledFrom": "fff3866"
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