@imdeadpool/guardex 5.0.17 → 6.0.1

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.
@@ -4,15 +4,24 @@ set -euo pipefail
4
4
  BASE_BRANCH=""
5
5
  BASE_BRANCH_EXPLICIT=0
6
6
  SOURCE_BRANCH=""
7
+ SOURCE_BRANCH_EXPLICIT=0
7
8
  PUSH_ENABLED=1
8
9
  DELETE_REMOTE_BRANCH=0
9
10
  DELETE_REMOTE_BRANCH_EXPLICIT=0
10
11
  MERGE_MODE="auto"
11
- GH_BIN="${MUSAFETY_GH_BIN:-gh}"
12
- CLEANUP_AFTER_MERGE_RAW="${MUSAFETY_FINISH_CLEANUP:-false}"
13
- WAIT_FOR_MERGE_RAW="${MUSAFETY_FINISH_WAIT_FOR_MERGE:-false}"
14
- WAIT_TIMEOUT_SECONDS_RAW="${MUSAFETY_FINISH_WAIT_TIMEOUT_SECONDS:-1800}"
15
- WAIT_POLL_SECONDS_RAW="${MUSAFETY_FINISH_WAIT_POLL_SECONDS:-10}"
12
+ GH_BIN="${GUARDEX_GH_BIN:-gh}"
13
+ CLEANUP_AFTER_MERGE_RAW="${GUARDEX_FINISH_CLEANUP:-false}"
14
+ WAIT_FOR_MERGE_RAW="${GUARDEX_FINISH_WAIT_FOR_MERGE:-false}"
15
+ WAIT_TIMEOUT_SECONDS_RAW="${GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS:-1800}"
16
+ WAIT_POLL_SECONDS_RAW="${GUARDEX_FINISH_WAIT_POLL_SECONDS:-10}"
17
+ REQUIRE_REMOTE_GATES_RAW="${GUARDEX_REQUIRE_REMOTE_GATES:-false}"
18
+ ENFORCE_AGENT_CLEANUP_RAW="${GUARDEX_ENFORCE_AGENT_CLEANUP:-true}"
19
+ SKIP_TASKS_GATE_RAW="${GUARDEX_SKIP_TASKS_GATE:-false}"
20
+ PR_REF="${GUARDEX_GH_PR_REF:-}"
21
+ GH_REPO_REF="${GUARDEX_GH_REPO:-}"
22
+ NO_CLEANUP_REQUESTED=0
23
+ TIER_LEVEL_RAW="${GUARDEX_TIER:-}"
24
+ TIER_LEVEL_EXPLICIT=0
16
25
 
17
26
  normalize_bool() {
18
27
  local raw="${1:-}"
@@ -44,10 +53,62 @@ normalize_int() {
44
53
  printf '%s' "$value"
45
54
  }
46
55
 
56
+ normalize_tier() {
57
+ local raw="${1:-}"
58
+ local upper
59
+ upper="$(printf '%s' "$raw" | tr '[:lower:]' '[:upper:]')"
60
+ case "$upper" in
61
+ T0|T1|T2|T3) printf '%s' "$upper" ;;
62
+ 0) printf 'T0' ;;
63
+ 1) printf 'T1' ;;
64
+ 2) printf 'T2' ;;
65
+ 3) printf 'T3' ;;
66
+ '') printf '' ;;
67
+ *)
68
+ echo "[agent-branch-finish] Unknown tier: ${raw} (expected T0|T1|T2|T3)" >&2
69
+ return 1
70
+ ;;
71
+ esac
72
+ }
73
+
74
+ resolve_tier_from_manifest() {
75
+ local worktree="$1"
76
+ local git_dir manifest_path
77
+ git_dir="$(git -C "$worktree" rev-parse --git-dir 2>/dev/null || true)"
78
+ if [[ -z "$git_dir" ]]; then
79
+ return 1
80
+ fi
81
+ if [[ "$git_dir" != /* ]]; then
82
+ git_dir="$(cd "$worktree/$git_dir" 2>/dev/null && pwd -P || true)"
83
+ fi
84
+ manifest_path="${git_dir}/guardex-bootstrap-manifest.json"
85
+ if [[ ! -f "$manifest_path" ]]; then
86
+ return 1
87
+ fi
88
+ python3 - "$manifest_path" <<'PY' 2>/dev/null || return 1
89
+ import json
90
+ import sys
91
+
92
+ try:
93
+ data = json.loads(open(sys.argv[1]).read())
94
+ except Exception:
95
+ sys.exit(1)
96
+ tier = data.get("tier")
97
+ if isinstance(tier, str) and tier:
98
+ print(tier)
99
+ else:
100
+ sys.exit(1)
101
+ PY
102
+ }
103
+
47
104
  CLEANUP_AFTER_MERGE="$(normalize_bool "$CLEANUP_AFTER_MERGE_RAW" "0")"
48
105
  WAIT_FOR_MERGE="$(normalize_bool "$WAIT_FOR_MERGE_RAW" "0")"
49
106
  WAIT_TIMEOUT_SECONDS="$(normalize_int "$WAIT_TIMEOUT_SECONDS_RAW" "1800" "30")"
50
107
  WAIT_POLL_SECONDS="$(normalize_int "$WAIT_POLL_SECONDS_RAW" "10" "0")"
108
+ REQUIRE_REMOTE_GATES="$(normalize_bool "$REQUIRE_REMOTE_GATES_RAW" "0")"
109
+ ENFORCE_AGENT_CLEANUP="$(normalize_bool "$ENFORCE_AGENT_CLEANUP_RAW" "1")"
110
+ SKIP_TASKS_GATE="$(normalize_bool "$SKIP_TASKS_GATE_RAW" "0")"
111
+ TIER_LEVEL=""
51
112
 
52
113
  while [[ $# -gt 0 ]]; do
53
114
  case "$1" in
@@ -58,6 +119,7 @@ while [[ $# -gt 0 ]]; do
58
119
  ;;
59
120
  --branch)
60
121
  SOURCE_BRANCH="${2:-}"
122
+ SOURCE_BRANCH_EXPLICIT=1
61
123
  shift 2
62
124
  ;;
63
125
  --no-push)
@@ -80,6 +142,7 @@ while [[ $# -gt 0 ]]; do
80
142
  ;;
81
143
  --no-cleanup)
82
144
  CLEANUP_AFTER_MERGE=0
145
+ NO_CLEANUP_REQUESTED=1
83
146
  shift
84
147
  ;;
85
148
  --wait-for-merge)
@@ -102,6 +165,22 @@ while [[ $# -gt 0 ]]; do
102
165
  MERGE_MODE="${2:-auto}"
103
166
  shift 2
104
167
  ;;
168
+ --pr)
169
+ PR_REF="${2:-}"
170
+ shift 2
171
+ ;;
172
+ --repo)
173
+ GH_REPO_REF="${2:-}"
174
+ shift 2
175
+ ;;
176
+ --require-remote-gates)
177
+ REQUIRE_REMOTE_GATES=1
178
+ shift
179
+ ;;
180
+ --no-require-remote-gates)
181
+ REQUIRE_REMOTE_GATES=0
182
+ shift
183
+ ;;
105
184
  --via-pr)
106
185
  MERGE_MODE="pr"
107
186
  shift
@@ -110,18 +189,23 @@ while [[ $# -gt 0 ]]; do
110
189
  MERGE_MODE="direct"
111
190
  shift
112
191
  ;;
192
+ --skip-tasks-gate)
193
+ SKIP_TASKS_GATE=1
194
+ shift
195
+ ;;
196
+ --tier)
197
+ TIER_LEVEL_RAW="${2:-}"
198
+ TIER_LEVEL_EXPLICIT=1
199
+ shift 2
200
+ ;;
113
201
  *)
114
202
  echo "[agent-branch-finish] Unknown argument: $1" >&2
115
- echo "Usage: $0 [--base <branch>] [--branch <branch>] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds <n>] [--wait-poll-seconds <n>] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2
203
+ echo "Usage: $0 [--base <branch>] [--branch <branch>] [--tier T0|T1|T2|T3] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds <n>] [--wait-poll-seconds <n>] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only] [--pr <ref>] [--repo <owner/name>] [--require-remote-gates|--no-require-remote-gates]" >&2
116
204
  exit 1
117
205
  ;;
118
206
  esac
119
207
  done
120
208
 
121
- if [[ "$CLEANUP_AFTER_MERGE" -eq 1 && "$DELETE_REMOTE_BRANCH_EXPLICIT" -eq 0 ]]; then
122
- DELETE_REMOTE_BRANCH=1
123
- fi
124
-
125
209
  case "$MERGE_MODE" in
126
210
  auto|direct|pr) ;;
127
211
  *)
@@ -146,10 +230,65 @@ fi
146
230
  repo_common_root="$(cd "$common_git_dir/.." && pwd -P)"
147
231
  agent_worktree_root="${repo_common_root}/.omx/agent-worktrees"
148
232
 
233
+ infer_agent_branch_from_worktree_path() {
234
+ local wt_path="$1"
235
+ local wt_name=""
236
+ local suffix=""
237
+ local candidate=""
238
+
239
+ if [[ "$wt_path" != "${agent_worktree_root}"/* ]]; then
240
+ return 1
241
+ fi
242
+
243
+ wt_name="$(basename "$wt_path")"
244
+ if [[ "$wt_name" != agent__* ]]; then
245
+ return 1
246
+ fi
247
+
248
+ suffix="${wt_name#agent__}"
249
+ candidate="agent/${suffix//__//}"
250
+ if [[ ! "$candidate" =~ ^agent/[A-Za-z0-9._/-]+$ ]]; then
251
+ return 1
252
+ fi
253
+ printf '%s' "$candidate"
254
+ }
255
+
149
256
  if [[ -z "$SOURCE_BRANCH" ]]; then
150
257
  SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
151
258
  fi
152
259
 
260
+ if [[ "$SOURCE_BRANCH_EXPLICIT" -eq 0 && "$SOURCE_BRANCH" == "HEAD" ]]; then
261
+ detached_hint_branch=""
262
+ detached_recover_cmd=""
263
+ detached_recover_branch=""
264
+ detached_conflicts="$(git -C "$current_worktree" diff --name-only --diff-filter=U 2>/dev/null || true)"
265
+
266
+ detached_hint_branch="$(infer_agent_branch_from_worktree_path "$current_worktree" || true)"
267
+ if [[ -n "$detached_hint_branch" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${detached_hint_branch}"; then
268
+ detached_recover_cmd="git -C \"$current_worktree\" checkout \"$detached_hint_branch\""
269
+ elif [[ -n "$detached_hint_branch" ]]; then
270
+ detached_recover_cmd="git -C \"$current_worktree\" checkout -b \"$detached_hint_branch\""
271
+ else
272
+ detached_recover_branch="agent/recover/detached-$(date +%Y%m%d-%H%M%S)"
273
+ detached_recover_cmd="git -C \"$current_worktree\" checkout -b \"$detached_recover_branch\""
274
+ fi
275
+
276
+ echo "[agent-branch-finish] Current worktree is in detached HEAD; finish requires a branch context." >&2
277
+ if [[ -n "$detached_conflicts" ]]; then
278
+ echo "[agent-branch-finish] Unmerged files detected in this detached worktree:" >&2
279
+ while IFS= read -r file; do
280
+ [[ -n "$file" ]] && echo " - ${file}" >&2
281
+ done <<< "$detached_conflicts"
282
+ fi
283
+ echo "[agent-branch-finish] Recover branch context with: ${detached_recover_cmd}" >&2
284
+ if [[ -n "$detached_hint_branch" ]]; then
285
+ echo "[agent-branch-finish] Then resolve/commit and rerun finish with: bash scripts/agent-branch-finish.sh --branch \"${detached_hint_branch}\" --base dev --via-pr --wait-for-merge --cleanup" >&2
286
+ else
287
+ echo "[agent-branch-finish] Then resolve/commit and rerun finish with --branch <your-recovered-agent-branch>." >&2
288
+ fi
289
+ exit 1
290
+ fi
291
+
153
292
  if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
154
293
  echo "[agent-branch-finish] --base requires a non-empty branch name." >&2
155
294
  exit 1
@@ -162,6 +301,28 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
162
301
  fi
163
302
  fi
164
303
 
304
+ if [[ -z "$BASE_BRANCH" ]]; then
305
+ branch_stored_base="$(git -C "$repo_root" config --get "branch.${SOURCE_BRANCH}.guardexBase" || true)"
306
+ if [[ -n "$branch_stored_base" ]]; then
307
+ BASE_BRANCH="$branch_stored_base"
308
+ fi
309
+ fi
310
+
311
+ if [[ -z "$BASE_BRANCH" ]]; then
312
+ source_upstream="$(git -C "$repo_root" for-each-ref --count=1 --format='%(upstream:short)' "refs/heads/${SOURCE_BRANCH}" || true)"
313
+ source_upstream="${source_upstream:-}"
314
+ if [[ "$source_upstream" == */* ]]; then
315
+ BASE_BRANCH="${source_upstream#*/}"
316
+ fi
317
+ fi
318
+
319
+ if [[ -z "$BASE_BRANCH" ]]; then
320
+ current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
321
+ if [[ -n "$current_branch" && "$current_branch" != "HEAD" && "$current_branch" != "$SOURCE_BRANCH" ]]; then
322
+ BASE_BRANCH="$current_branch"
323
+ fi
324
+ fi
325
+
165
326
  if [[ -z "$BASE_BRANCH" ]]; then
166
327
  BASE_BRANCH="dev"
167
328
  fi
@@ -172,6 +333,32 @@ if [[ "$SOURCE_BRANCH" == "$BASE_BRANCH" ]]; then
172
333
  exit 1
173
334
  fi
174
335
 
336
+ cleanup_mandatory=0
337
+ if [[ "$ENFORCE_AGENT_CLEANUP" -eq 1 && "$PUSH_ENABLED" -eq 1 && "$SOURCE_BRANCH" =~ ^agent/ ]]; then
338
+ cleanup_mandatory=1
339
+ fi
340
+
341
+ if [[ "$cleanup_mandatory" -eq 1 ]]; then
342
+ if [[ "$CLEANUP_AFTER_MERGE" -ne 1 ]]; then
343
+ if [[ "$NO_CLEANUP_REQUESTED" -eq 1 ]]; then
344
+ echo "[agent-branch-finish] Ignoring --no-cleanup for '${SOURCE_BRANCH}': cleanup is mandatory for merged agent branches." >&2
345
+ else
346
+ echo "[agent-branch-finish] Enforcing mandatory cleanup for merged agent branch '${SOURCE_BRANCH}'." >&2
347
+ fi
348
+ CLEANUP_AFTER_MERGE=1
349
+ fi
350
+ if [[ "$DELETE_REMOTE_BRANCH" -ne 1 ]]; then
351
+ if [[ "$DELETE_REMOTE_BRANCH_EXPLICIT" -eq 1 ]]; then
352
+ echo "[agent-branch-finish] Ignoring --keep-remote-branch for '${SOURCE_BRANCH}': remote branch deletion is required by cleanup policy." >&2
353
+ fi
354
+ DELETE_REMOTE_BRANCH=1
355
+ fi
356
+ fi
357
+
358
+ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 && "$DELETE_REMOTE_BRANCH_EXPLICIT" -eq 0 ]]; then
359
+ DELETE_REMOTE_BRANCH=1
360
+ fi
361
+
175
362
  if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${SOURCE_BRANCH}"; then
176
363
  echo "[agent-branch-finish] Local source branch does not exist: ${SOURCE_BRANCH}" >&2
177
364
  exit 1
@@ -191,9 +378,188 @@ is_clean_worktree() {
191
378
  && git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json"
192
379
  }
193
380
 
381
+ validate_openspec_tasks_gate() {
382
+ local branch="$1"
383
+ local branch_root="$2"
384
+ local helper_base=""
385
+
386
+ if [[ ! "$branch" =~ ^agent/ ]]; then
387
+ return 0
388
+ fi
389
+
390
+ helper_base="$(git -C "$repo_root" config --get "branch.${branch}.guardexBase" || true)"
391
+ if [[ "$BASE_BRANCH" == agent/* ]] || [[ "$helper_base" == agent/* ]]; then
392
+ if [[ -z "$helper_base" && "$BASE_BRANCH" == agent/* ]]; then
393
+ helper_base="$BASE_BRANCH"
394
+ fi
395
+ echo "[agent-branch-finish] Skipping OpenSpec tasks gate for helper branch '${branch}' (base '${helper_base}')." >&2
396
+ return 0
397
+ fi
398
+
399
+ local change_slug="${branch//\//-}"
400
+ local tasks_file="${branch_root}/openspec/changes/${change_slug}/tasks.md"
401
+ local use_collaboration_flow=0
402
+ local cleanup_step="4"
403
+ local required_section_labels=(
404
+ "## 1. Specification"
405
+ "## 2. Implementation"
406
+ "## 3. Verification"
407
+ )
408
+ local required_section_patterns=(
409
+ '^## 1\. Specification([[:space:]].*)?$'
410
+ '^## 2\. Implementation([[:space:]].*)?$'
411
+ '^## 3\. Verification([[:space:]].*)?$'
412
+ )
413
+
414
+ if [[ ! -f "$tasks_file" ]]; then
415
+ echo "[agent-branch-finish] OpenSpec tasks gate failed for '${branch}'." >&2
416
+ echo "[agent-branch-finish] Missing required file: openspec/changes/${change_slug}/tasks.md" >&2
417
+ echo "[agent-branch-finish] Finish is blocked until the checklist file exists and is fully updated." >&2
418
+ exit 1
419
+ fi
420
+
421
+ if grep -Eq '^## 4\. Collaboration([[:space:]].*)?$' "$tasks_file" && grep -Eq '^## 5\. Cleanup([[:space:]].*)?$' "$tasks_file"; then
422
+ use_collaboration_flow=1
423
+ cleanup_step="5"
424
+ required_section_labels+=("## 4. Collaboration" "## 5. Cleanup")
425
+ required_section_patterns+=('^## 4\. Collaboration([[:space:]].*)?$' '^## 5\. Cleanup([[:space:]].*)?$')
426
+ else
427
+ required_section_labels+=("## 4. Cleanup")
428
+ required_section_patterns+=('^## 4\. Cleanup([[:space:]].*)?$')
429
+ fi
430
+
431
+ local missing_section=0
432
+ local i
433
+ for i in "${!required_section_labels[@]}"; do
434
+ local section_label="${required_section_labels[$i]}"
435
+ local section_pattern="${required_section_patterns[$i]}"
436
+ if ! grep -Eq "$section_pattern" "$tasks_file"; then
437
+ missing_section=1
438
+ echo "[agent-branch-finish] OpenSpec tasks gate failed for '${branch}'." >&2
439
+ echo "[agent-branch-finish] Missing required section in ${tasks_file}: ${section_label}" >&2
440
+ fi
441
+ done
442
+ if [[ "$missing_section" -eq 1 ]]; then
443
+ echo "[agent-branch-finish] Finish is blocked until all required checklist sections are present." >&2
444
+ exit 1
445
+ fi
446
+
447
+ if ! grep -Eq "^[[:space:]]*-[[:space:]]*\\[[ xX]\\][[:space:]]*${cleanup_step}\\.1\\b" "$tasks_file"; then
448
+ echo "[agent-branch-finish] OpenSpec tasks gate failed for '${branch}'." >&2
449
+ echo "[agent-branch-finish] Missing required cleanup readiness item in ${tasks_file}: ${cleanup_step}.1" >&2
450
+ echo "[agent-branch-finish] Finish is blocked until cleanup item ${cleanup_step}.1 is present." >&2
451
+ exit 1
452
+ fi
453
+
454
+ local gate_unchecked
455
+ gate_unchecked="$(awk -v collab_flow="$use_collaboration_flow" '
456
+ BEGIN { scope = "" }
457
+ /^## 1\. Specification([[:space:]].*)?$/ { scope = "spec"; next }
458
+ /^## 2\. Implementation([[:space:]].*)?$/ { scope = "impl"; next }
459
+ /^## 3\. Verification([[:space:]].*)?$/ { scope = "verify"; next }
460
+ collab_flow == 1 && /^## 4\. Collaboration([[:space:]].*)?$/ { scope = "collaboration"; next }
461
+ collab_flow == 1 && /^## 5\. Cleanup([[:space:]].*)?$/ { scope = "cleanup"; next }
462
+ collab_flow != 1 && /^## 4\. Cleanup([[:space:]].*)?$/ { scope = "cleanup"; next }
463
+ /^## / { scope = "" }
464
+
465
+ scope ~ /^(spec|impl|verify)$/ && /^[[:space:]]*-[[:space:]]*\[ \]/ {
466
+ print NR ":" $0
467
+ next
468
+ }
469
+ ' "$tasks_file")"
470
+
471
+ if [[ -n "$gate_unchecked" ]]; then
472
+ echo "[agent-branch-finish] OpenSpec tasks gate failed for '${branch}'." >&2
473
+ echo "[agent-branch-finish] Unchecked checklist items remain in ${tasks_file}:" >&2
474
+ while IFS= read -r line; do
475
+ [[ -n "$line" ]] && echo " - ${line}" >&2
476
+ done <<< "$gate_unchecked"
477
+ echo "[agent-branch-finish] Finish is blocked until all items in sections 1-3 are marked [x]." >&2
478
+ exit 1
479
+ fi
480
+
481
+ if [[ "$use_collaboration_flow" -eq 1 ]]; then
482
+ local collaboration_section=""
483
+ collaboration_section="$(awk '
484
+ BEGIN { in_collaboration = 0 }
485
+ /^## 4\. Collaboration([[:space:]].*)?$/ { in_collaboration = 1; next }
486
+ in_collaboration && /^## / { exit }
487
+ in_collaboration { print }
488
+ ' "$tasks_file")"
489
+
490
+ local collaboration_ack_done=0
491
+ local collaboration_na_done=0
492
+ if grep -Eiq '^[[:space:]]*-[[:space:]]*\[[xX]\][[:space:]]*4\.3\b' <<<"$collaboration_section"; then
493
+ collaboration_ack_done=1
494
+ fi
495
+ if grep -Eiq '^[[:space:]]*-[[:space:]]*\[[xX]\][[:space:]]*4\.4\b.*n[[:space:]]*/[[:space:]]*a' <<<"$collaboration_section"; then
496
+ collaboration_na_done=1
497
+ fi
498
+
499
+ if [[ "$collaboration_ack_done" -ne 1 && "$collaboration_na_done" -ne 1 ]]; then
500
+ echo "[agent-branch-finish] OpenSpec tasks gate failed for '${branch}'." >&2
501
+ echo "[agent-branch-finish] Collaboration section in ${tasks_file} requires one completion path before cleanup:" >&2
502
+ echo " - [x] 4.3 Owner Codex acknowledges joined outputs (accept/revise/reject), OR" >&2
503
+ echo " - [x] 4.4 explicitly marked with N/A when no Codex joined." >&2
504
+ echo "[agent-branch-finish] Finish is blocked until section 4 records one of these paths." >&2
505
+ exit 1
506
+ fi
507
+ fi
508
+ }
509
+
194
510
  source_worktree="$(get_worktree_for_branch "$SOURCE_BRANCH")"
195
511
  created_source_probe=0
196
512
  source_probe_path=""
513
+ integration_worktree=""
514
+ integration_branch=""
515
+ transient_worktrees_released=0
516
+
517
+ release_transient_worktrees() {
518
+ if [[ "$transient_worktrees_released" -eq 1 ]]; then
519
+ return
520
+ fi
521
+ if [[ -n "$integration_worktree" && -d "$integration_worktree" ]]; then
522
+ git -C "$repo_root" worktree remove "$integration_worktree" --force >/dev/null 2>&1 || true
523
+ fi
524
+ if [[ -n "$integration_branch" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${integration_branch}"; then
525
+ local integration_branch_worktree=""
526
+ integration_branch_worktree="$(get_worktree_for_branch "$integration_branch" || true)"
527
+ if [[ -z "$integration_branch_worktree" ]]; then
528
+ git -C "$repo_root" branch -D "$integration_branch" >/dev/null 2>&1 || true
529
+ fi
530
+ fi
531
+ if [[ "$created_source_probe" -eq 1 && -n "$source_probe_path" && -d "$source_probe_path" ]]; then
532
+ # Abort any in-progress rebase/merge so the branch ref is left at pre-op state
533
+ # before removing the worktree. This prevents stranded mid-rebase sandboxes
534
+ # when the auto-sync/preflight steps below exit on conflict.
535
+ git -C "$source_probe_path" rebase --abort >/dev/null 2>&1 || true
536
+ git -C "$source_probe_path" merge --abort >/dev/null 2>&1 || true
537
+ git -C "$repo_root" worktree remove "$source_probe_path" --force >/dev/null 2>&1 || true
538
+ fi
539
+ if [[ "$created_source_probe" -eq 1 ]]; then
540
+ source_worktree="$repo_root"
541
+ created_source_probe=0
542
+ source_probe_path=""
543
+ fi
544
+ transient_worktrees_released=1
545
+ }
546
+
547
+ cleanup() {
548
+ release_transient_worktrees
549
+ }
550
+
551
+ handle_interrupt() {
552
+ cleanup
553
+ exit 130
554
+ }
555
+
556
+ # Install the cleanup trap BEFORE creating the probe (and before any operation
557
+ # that can fail on it) so every exit path — auto-stash failure, tasks gate
558
+ # failure, auto-sync rebase conflict, preflight merge conflict — triggers
559
+ # release_transient_worktrees. Previously the trap was installed much later,
560
+ # leaving stranded __source-probe-* worktrees in mid-rebase state.
561
+ trap cleanup EXIT
562
+ trap handle_interrupt INT TERM HUP
197
563
 
198
564
  if [[ -z "$source_worktree" ]]; then
199
565
  source_probe_path="${agent_worktree_root}/__source-probe-${SOURCE_BRANCH//\//__}-$(date +%Y%m%d-%H%M%S)"
@@ -203,10 +569,79 @@ if [[ -z "$source_worktree" ]]; then
203
569
  created_source_probe=1
204
570
  fi
205
571
 
572
+ if [[ -z "$TIER_LEVEL" && -n "$TIER_LEVEL_RAW" ]]; then
573
+ if ! TIER_LEVEL="$(normalize_tier "$TIER_LEVEL_RAW")"; then
574
+ exit 1
575
+ fi
576
+ fi
577
+
578
+ if [[ -z "$TIER_LEVEL" ]]; then
579
+ TIER_LEVEL="$(resolve_tier_from_manifest "$source_worktree" 2>/dev/null || true)"
580
+ fi
581
+
582
+ if [[ -z "$TIER_LEVEL" ]]; then
583
+ TIER_LEVEL="T3"
584
+ fi
585
+
586
+ case "$TIER_LEVEL" in
587
+ T0|T1)
588
+ if [[ "$SKIP_TASKS_GATE" -ne 1 ]]; then
589
+ echo "[agent-branch-finish] Tier ${TIER_LEVEL}: skipping OpenSpec tasks gate." >&2
590
+ SKIP_TASKS_GATE=1
591
+ fi
592
+ ;;
593
+ esac
594
+
595
+ pr_already_merged=0
596
+ pr_already_merged_url=""
597
+ pr_already_merged_at=""
598
+ if [[ "$MERGE_MODE" != "direct" ]] && command -v "$GH_BIN" >/dev/null 2>&1; then
599
+ pr_check_payload="$("$GH_BIN" pr view "$SOURCE_BRANCH" --json state,mergedAt,url --jq '[.state, (.mergedAt // ""), (.url // "")] | join("\u001f")' 2>/dev/null || true)"
600
+ if [[ -n "$pr_check_payload" ]]; then
601
+ pr_check_state=""
602
+ pr_check_merged_at=""
603
+ pr_check_url=""
604
+ IFS=$'\x1f' read -r pr_check_state pr_check_merged_at pr_check_url <<< "$pr_check_payload" || true
605
+ if [[ "$pr_check_state" == "MERGED" ]]; then
606
+ pr_already_merged=1
607
+ pr_already_merged_url="$pr_check_url"
608
+ pr_already_merged_at="$pr_check_merged_at"
609
+ echo "[agent-branch-finish] PR for '${SOURCE_BRANCH}' is already MERGED on origin${pr_check_url:+ (${pr_check_url})}." >&2
610
+ echo "[agent-branch-finish] Skipping pre-merge work (auto-tick, tasks gate, sync, preflight, merge-quality gate, push/merge) and proceeding to cleanup." >&2
611
+ fi
612
+ fi
613
+ fi
614
+
615
+ if [[ "$pr_already_merged" -ne 1 && "$SKIP_TASKS_GATE" -ne 1 ]]; then
616
+ if [[ "$SOURCE_BRANCH" =~ ^agent/ && "$BASE_BRANCH" != agent/* ]]; then
617
+ auto_tick_script="${repo_root}/scripts/openspec/auto-tick-tasks.py"
618
+ if [[ -f "$auto_tick_script" ]]; then
619
+ python3 "$auto_tick_script" \
620
+ --worktree "$source_worktree" \
621
+ --branch "$SOURCE_BRANCH" \
622
+ --base "$BASE_BRANCH" \
623
+ || echo "[agent-branch-finish] auto-tick-tasks helper failed; continuing with gate" >&2
624
+ fi
625
+ fi
626
+ validate_openspec_tasks_gate "$SOURCE_BRANCH" "$source_worktree"
627
+ elif [[ "$pr_already_merged" -ne 1 ]]; then
628
+ echo "[agent-branch-finish] Skipping OpenSpec tasks gate (--skip-tasks-gate)." >&2
629
+ fi
630
+
206
631
  if ! is_clean_worktree "$source_worktree"; then
632
+ stash_label="guardex/pre-finish/${SOURCE_BRANCH//\//-}-$(date +%Y%m%d-%H%M%S)"
207
633
  echo "[agent-branch-finish] Source worktree is not clean for '${SOURCE_BRANCH}': ${source_worktree}" >&2
208
- echo "[agent-branch-finish] Commit/stash changes on the source branch before finishing." >&2
209
- exit 1
634
+ echo "[agent-branch-finish] Auto-stashing uncommitted changes as '${stash_label}' so finish can proceed." >&2
635
+ if git -C "$source_worktree" stash push --include-untracked -m "$stash_label" >/dev/null 2>&1; then
636
+ echo "[agent-branch-finish] Recover later with: git -C \"$source_worktree\" stash list | grep '${stash_label}'" >&2
637
+ else
638
+ echo "[agent-branch-finish] Auto-stash failed; commit/stash changes on the source branch before finishing." >&2
639
+ exit 1
640
+ fi
641
+ if ! is_clean_worktree "$source_worktree"; then
642
+ echo "[agent-branch-finish] Source worktree still not clean after auto-stash; aborting." >&2
643
+ exit 1
644
+ fi
210
645
  fi
211
646
 
212
647
  start_ref="$BASE_BRANCH"
@@ -227,51 +662,103 @@ case "$require_before_finish" in
227
662
  *) should_require_sync=1 ;;
228
663
  esac
229
664
 
230
- if [[ "$should_require_sync" -eq 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
665
+ if [[ "$pr_already_merged" -ne 1 && "$should_require_sync" -eq 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
231
666
  behind_count="$(git -C "$repo_root" rev-list --left-right --count "${SOURCE_BRANCH}...origin/${BASE_BRANCH}" 2>/dev/null | awk '{print $2}')"
232
667
  behind_count="${behind_count:-0}"
233
668
  if [[ "$behind_count" -gt 0 ]]; then
234
- echo "[agent-sync-guard] Branch '${SOURCE_BRANCH}' is behind origin/${BASE_BRANCH} by ${behind_count} commit(s)." >&2
235
- echo "[agent-sync-guard] Auto-syncing '${SOURCE_BRANCH}' onto origin/${BASE_BRANCH} before finish..." >&2
236
- if ! git -C "$source_worktree" rebase "origin/${BASE_BRANCH}"; then
237
- git_dir="$(git -C "$source_worktree" rev-parse --git-dir)"
238
- rebase_active=0
239
- if [[ -e "${git_dir}/rebase-merge" || -e "${git_dir}/rebase-apply" ]]; then
240
- rebase_active=1
241
- fi
669
+ pr_merge_state=""
670
+ if command -v "$GH_BIN" >/dev/null 2>&1; then
671
+ pr_merge_state="$("$GH_BIN" pr view "$SOURCE_BRANCH" --json state --jq '.state' 2>/dev/null || true)"
672
+ fi
673
+
674
+ if [[ "$pr_merge_state" == "MERGED" ]]; then
675
+ echo "[agent-sync-guard] Branch '${SOURCE_BRANCH}' is behind origin/${BASE_BRANCH} by ${behind_count} commit(s), but its PR is already MERGED on origin." >&2
676
+ echo "[agent-sync-guard] Skipping pre-merge rebase to avoid replaying commits the squash already shipped; proceeding to cleanup." >&2
677
+ else
678
+ echo "[agent-sync-guard] Branch '${SOURCE_BRANCH}' is behind origin/${BASE_BRANCH} by ${behind_count} commit(s)." >&2
679
+ echo "[agent-sync-guard] Auto-syncing '${SOURCE_BRANCH}' onto origin/${BASE_BRANCH} before finish..." >&2
680
+ if ! git -C "$source_worktree" rebase "origin/${BASE_BRANCH}"; then
681
+ git_dir="$(git -C "$source_worktree" rev-parse --git-dir)"
682
+ rebase_active=0
683
+ if [[ -e "${git_dir}/rebase-merge" || -e "${git_dir}/rebase-apply" ]]; then
684
+ rebase_active=1
685
+ fi
242
686
 
243
- echo "[agent-sync-guard] Auto-sync failed while rebasing '${SOURCE_BRANCH}' onto origin/${BASE_BRANCH}." >&2
244
- if [[ "$rebase_active" -eq 1 ]]; then
245
- echo "[agent-sync-guard] Resolve conflicts, then run: git -C \"$source_worktree\" rebase --continue" >&2
246
- echo "[agent-sync-guard] Or abort: git -C \"$source_worktree\" rebase --abort" >&2
687
+ echo "[agent-sync-guard] Auto-sync failed while rebasing '${SOURCE_BRANCH}' onto origin/${BASE_BRANCH}." >&2
688
+ if [[ "$rebase_active" -eq 1 ]]; then
689
+ echo "[agent-sync-guard] Resolve conflicts, then run: git -C \"$source_worktree\" rebase --continue" >&2
690
+ echo "[agent-sync-guard] Or abort: git -C \"$source_worktree\" rebase --abort" >&2
691
+ fi
692
+ exit 1
247
693
  fi
248
- exit 1
249
- fi
250
694
 
251
- behind_after="$(git -C "$repo_root" rev-list --left-right --count "${SOURCE_BRANCH}...origin/${BASE_BRANCH}" 2>/dev/null | awk '{print $2}')"
252
- behind_after="${behind_after:-0}"
253
- echo "[agent-sync-guard] Auto-sync complete (behind now: ${behind_after})." >&2
695
+ behind_after="$(git -C "$repo_root" rev-list --left-right --count "${SOURCE_BRANCH}...origin/${BASE_BRANCH}" 2>/dev/null | awk '{print $2}')"
696
+ behind_after="${behind_after:-0}"
697
+ echo "[agent-sync-guard] Auto-sync complete (behind now: ${behind_after})." >&2
698
+ fi
254
699
  fi
255
700
  fi
256
701
 
257
- integration_worktree="${agent_worktree_root}/__integrate-${BASE_BRANCH//\//__}-$(date +%Y%m%d-%H%M%S)"
258
- integration_branch="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)"
259
- mkdir -p "$(dirname "$integration_worktree")"
702
+ integration_worktree=""
703
+ integration_branch=""
704
+ use_integration_worktree=1
705
+ if [[ "$pr_already_merged" -eq 1 ]]; then
706
+ # Pre-merged PR: cleanup-only path; no integration worktree needed.
707
+ use_integration_worktree=0
708
+ elif [[ "$MERGE_MODE" == "pr" && "$PUSH_ENABLED" -eq 1 ]]; then
709
+ # PR mode merges by pushing the source branch and letting GitHub merge.
710
+ # Skip creating temporary local integration worktrees in this lane.
711
+ use_integration_worktree=0
712
+ fi
713
+
714
+ if [[ "$use_integration_worktree" -eq 1 ]]; then
715
+ integration_worktree="${agent_worktree_root}/__integrate-${BASE_BRANCH//\//__}-$(date +%Y%m%d-%H%M%S)"
716
+ integration_branch="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)"
717
+ mkdir -p "$(dirname "$integration_worktree")"
718
+ git -C "$repo_root" worktree add "$integration_worktree" "$start_ref" >/dev/null
719
+ git -C "$integration_worktree" checkout -b "$integration_branch" >/dev/null
720
+ else
721
+ integration_worktree=""
722
+ integration_branch=""
723
+ fi
724
+
725
+ merge_gate_json=""
260
726
 
261
- git -C "$repo_root" worktree add "$integration_worktree" "$start_ref" >/dev/null
262
- git -C "$integration_worktree" checkout -b "$integration_branch" >/dev/null
727
+ run_merge_quality_gate() {
728
+ if [[ ! -x "${repo_root}/scripts/omx-merge-gate.sh" ]]; then
729
+ echo "[agent-branch-finish] Required merge-gate helper is missing: scripts/omx-merge-gate.sh" >&2
730
+ echo "[agent-branch-finish] Repair with: gx doctor (or restore the script) before finishing." >&2
731
+ return 1
732
+ fi
263
733
 
264
- cleanup() {
265
- if [[ -d "$integration_worktree" ]]; then
266
- git -C "$repo_root" worktree remove "$integration_worktree" --force >/dev/null 2>&1 || true
734
+ local gate_args=(--branch "$SOURCE_BRANCH" --base "$BASE_BRANCH" --output-dir "${repo_root}/.omx/state/merge-gates")
735
+ if [[ -n "$PR_REF" ]]; then
736
+ gate_args+=(--pr "$PR_REF")
267
737
  fi
268
- if [[ "$created_source_probe" -eq 1 && -n "$source_probe_path" && -d "$source_probe_path" ]]; then
269
- git -C "$repo_root" worktree remove "$source_probe_path" --force >/dev/null 2>&1 || true
738
+ if [[ -n "$GH_REPO_REF" ]]; then
739
+ gate_args+=(--repo "$GH_REPO_REF")
270
740
  fi
741
+ if [[ "$REQUIRE_REMOTE_GATES" -eq 1 ]]; then
742
+ gate_args+=(--require-remote)
743
+ fi
744
+
745
+ local gate_output=""
746
+ if gate_output="$(bash "${repo_root}/scripts/omx-merge-gate.sh" "${gate_args[@]}" 2>&1)"; then
747
+ printf '%s\n' "$gate_output"
748
+ merge_gate_json="$(printf '%s\n' "$gate_output" | sed -n 's/^Merge gate JSON: //p' | tail -n1)"
749
+ return 0
750
+ fi
751
+
752
+ merge_gate_json="$(printf '%s\n' "$gate_output" | sed -n 's/^Merge gate JSON: //p' | tail -n1)"
753
+ echo "$gate_output" >&2
754
+ echo "[agent-branch-finish] Merge-quality gate failed. Resolve blockers before finishing." >&2
755
+ if [[ -n "$merge_gate_json" ]]; then
756
+ echo "[agent-branch-finish] Gate details: ${merge_gate_json}" >&2
757
+ fi
758
+ return 1
271
759
  }
272
- trap cleanup EXIT
273
760
 
274
- if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
761
+ if [[ "$pr_already_merged" -ne 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
275
762
  git -C "$source_worktree" fetch origin "$BASE_BRANCH" --quiet
276
763
 
277
764
  if ! git -C "$source_worktree" merge --no-commit --no-ff "origin/${BASE_BRANCH}" >/dev/null 2>&1; then
@@ -292,16 +779,29 @@ if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRA
292
779
  git -C "$source_worktree" merge --abort >/dev/null 2>&1 || true
293
780
  fi
294
781
 
295
- if ! git -C "$integration_worktree" merge --no-ff --no-edit "$SOURCE_BRANCH"; then
296
- echo "[agent-branch-finish] Merge conflict detected while merging '${SOURCE_BRANCH}' into '${BASE_BRANCH}'." >&2
297
- git -C "$integration_worktree" merge --abort >/dev/null 2>&1 || true
782
+ if [[ "$pr_already_merged" -ne 1 ]] && ! run_merge_quality_gate; then
298
783
  exit 1
299
784
  fi
300
785
 
301
- merge_completed=1
302
- merge_status="direct"
303
- direct_push_error=""
304
- pr_url=""
786
+ if [[ "$pr_already_merged" -ne 1 && "$use_integration_worktree" -eq 1 ]]; then
787
+ if ! git -C "$integration_worktree" merge --no-ff --no-edit "$SOURCE_BRANCH"; then
788
+ echo "[agent-branch-finish] Merge conflict detected while merging '${SOURCE_BRANCH}' into '${BASE_BRANCH}'." >&2
789
+ git -C "$integration_worktree" merge --abort >/dev/null 2>&1 || true
790
+ exit 1
791
+ fi
792
+ fi
793
+
794
+ if [[ "$pr_already_merged" -eq 1 ]]; then
795
+ merge_completed=1
796
+ merge_status="pr-already-merged"
797
+ direct_push_error=""
798
+ pr_url="$pr_already_merged_url"
799
+ else
800
+ merge_completed=1
801
+ merge_status="direct"
802
+ direct_push_error=""
803
+ pr_url=""
804
+ fi
305
805
 
306
806
  is_local_branch_delete_error() {
307
807
  local output="$1"
@@ -314,26 +814,90 @@ is_local_branch_delete_error() {
314
814
  return 1
315
815
  }
316
816
 
317
- read_pr_state() {
318
- local state_line
319
- state_line="$("$GH_BIN" pr view "$SOURCE_BRANCH" --json state,mergedAt,url --jq '[.state, (.mergedAt // ""), (.url // "")] | join("\u001f")' 2>/dev/null || true)"
320
- if [[ -z "$state_line" ]]; then
321
- return 1
817
+ delete_local_source_branch() {
818
+ local branch="$1"
819
+ local base_branch="$2"
820
+ local delete_output=""
821
+ local branch_upstream=""
822
+ local safe_delete_ref=""
823
+ local safe_to_force_delete=0
824
+
825
+ branch_upstream="$(git -C "$repo_root" for-each-ref --count=1 --format='%(upstream:short)' "refs/heads/${branch}" || true)"
826
+ branch_upstream="${branch_upstream:-}"
827
+ if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${base_branch}"; then
828
+ safe_delete_ref="origin/${base_branch}"
829
+ elif git -C "$repo_root" show-ref --verify --quiet "refs/heads/${base_branch}"; then
830
+ safe_delete_ref="${base_branch}"
831
+ fi
832
+ if [[ -n "$safe_delete_ref" ]] && git -C "$repo_root" merge-base --is-ancestor "$branch" "$safe_delete_ref" >/dev/null 2>&1; then
833
+ safe_to_force_delete=1
834
+ fi
835
+
836
+ if delete_output="$(git -C "$repo_root" branch -d "$branch" 2>&1)"; then
837
+ return 0
838
+ fi
839
+
840
+ if [[ "$branch_upstream" == "origin/${branch}" ]]; then
841
+ git -C "$repo_root" branch --unset-upstream "$branch" >/dev/null 2>&1 || true
842
+ if git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then
843
+ echo "[agent-branch-finish] Cleared upstream tracking for '${branch}' to complete local merged-branch cleanup." >&2
844
+ return 0
845
+ fi
322
846
  fi
323
847
 
324
- local parsed_state=""
325
- local parsed_merged_at=""
326
- local parsed_url=""
327
- IFS=$'\x1f' read -r parsed_state parsed_merged_at parsed_url <<< "$state_line"
328
- PR_STATE="$parsed_state"
329
- PR_MERGED_AT="$parsed_merged_at"
330
- if [[ -n "$parsed_url" ]]; then
331
- pr_url="$parsed_url"
848
+ if [[ "$safe_to_force_delete" -eq 1 ]]; then
849
+ if git -C "$repo_root" branch -D "$branch" >/dev/null 2>&1; then
850
+ echo "[agent-branch-finish] Deleted '${branch}' with forced local cleanup after verifying merge ancestry in '${safe_delete_ref}'." >&2
851
+ return 0
852
+ fi
332
853
  fi
333
- return 0
854
+
855
+ echo "[agent-branch-finish] Failed to delete local branch '${branch}' after merge." >&2
856
+ echo "$delete_output" >&2
857
+ return 1
858
+ }
859
+
860
+ read_pr_state() {
861
+ local preferred_ref="${1:-}"
862
+ local state_line=""
863
+ local refs_to_try=()
864
+ local candidate_ref
865
+
866
+ if [[ -n "$preferred_ref" ]]; then
867
+ refs_to_try+=("$preferred_ref")
868
+ fi
869
+ if [[ -n "$pr_url" && "$pr_url" != "$preferred_ref" ]]; then
870
+ refs_to_try+=("$pr_url")
871
+ fi
872
+ if [[ "$SOURCE_BRANCH" != "$preferred_ref" ]]; then
873
+ refs_to_try+=("$SOURCE_BRANCH")
874
+ fi
875
+
876
+ for candidate_ref in "${refs_to_try[@]}"; do
877
+ state_line="$("$GH_BIN" pr view "$candidate_ref" --json state,mergedAt,url --jq '[.state, (.mergedAt // ""), (.url // "")] | join("\u001f")' 2>/dev/null || true)"
878
+ if [[ -z "$state_line" ]]; then
879
+ continue
880
+ fi
881
+
882
+ local parsed_state=""
883
+ local parsed_merged_at=""
884
+ local parsed_url=""
885
+ IFS=$'\x1f' read -r parsed_state parsed_merged_at parsed_url <<< "$state_line"
886
+ PR_STATE="$parsed_state"
887
+ PR_MERGED_AT="$parsed_merged_at"
888
+ if [[ -n "$parsed_url" ]]; then
889
+ pr_url="$parsed_url"
890
+ fi
891
+ return 0
892
+ done
893
+
894
+ return 1
334
895
  }
335
896
 
336
897
  wait_for_pr_merge() {
898
+ # Integration/source-probe worktrees are no longer needed during GH check wait loops.
899
+ # Release them early so long waits do not leave temporary repos visible in Source Control.
900
+ release_transient_worktrees
337
901
  local deadline
338
902
  deadline=$(( $(date +%s) + WAIT_TIMEOUT_SECONDS ))
339
903
  local wait_notice_printed=0
@@ -416,6 +980,13 @@ run_pr_flow() {
416
980
  echo "[agent-branch-finish] PR merged but gh could not delete the local branch (active worktree); continuing local cleanup." >&2
417
981
  return 0
418
982
  fi
983
+ PR_STATE=""
984
+ PR_MERGED_AT=""
985
+ if read_pr_state "$pr_url"; then
986
+ if [[ "$PR_STATE" == "MERGED" || -n "$PR_MERGED_AT" ]]; then
987
+ return 0
988
+ fi
989
+ fi
419
990
 
420
991
  if [[ "$WAIT_FOR_MERGE" -eq 1 ]]; then
421
992
  wait_for_pr_merge
@@ -438,7 +1009,43 @@ run_pr_flow() {
438
1009
  return 2
439
1010
  }
440
1011
 
441
- if [[ "$PUSH_ENABLED" -eq 1 ]]; then
1012
+ capture_post_merge_learning() {
1013
+ if [[ ! -x "${repo_root}/scripts/omx-learning-capture.sh" ]]; then
1014
+ return 0
1015
+ fi
1016
+
1017
+ local learning_args=(
1018
+ --branch "$SOURCE_BRANCH"
1019
+ --base "$BASE_BRANCH"
1020
+ --outcome "merged-${merge_status}"
1021
+ --summary "Merged ${SOURCE_BRANCH} into ${BASE_BRANCH} via ${merge_status} flow."
1022
+ --output-dir "${repo_root}/.omx/learning"
1023
+ )
1024
+ if [[ -n "$PR_REF" ]]; then
1025
+ learning_args+=(--pr "$PR_REF")
1026
+ elif [[ -n "$pr_url" ]]; then
1027
+ learning_args+=(--pr "$pr_url")
1028
+ fi
1029
+ if [[ -n "$GH_REPO_REF" ]]; then
1030
+ learning_args+=(--repo "$GH_REPO_REF")
1031
+ fi
1032
+ if [[ -n "$merge_gate_json" ]]; then
1033
+ learning_args+=(--merge-gate-file "$merge_gate_json")
1034
+ fi
1035
+ if [[ -f "${source_worktree}/.omx/context/github/sandbox-startup-latest.json" ]]; then
1036
+ learning_args+=(--context-file "${source_worktree}/.omx/context/github/sandbox-startup-latest.json")
1037
+ fi
1038
+
1039
+ local learning_output=""
1040
+ if learning_output="$(bash "${repo_root}/scripts/omx-learning-capture.sh" "${learning_args[@]}" 2>&1)"; then
1041
+ printf '%s\n' "$learning_output"
1042
+ else
1043
+ echo "[agent-branch-finish] Warning: post-merge learning capture failed." >&2
1044
+ printf '%s\n' "$learning_output" >&2
1045
+ fi
1046
+ }
1047
+
1048
+ if [[ "$pr_already_merged" -ne 1 && "$PUSH_ENABLED" -eq 1 ]]; then
442
1049
  if [[ "$MERGE_MODE" != "pr" ]]; then
443
1050
  if ! direct_push_output="$(git -C "$integration_worktree" push origin "HEAD:${BASE_BRANCH}" 2>&1)"; then
444
1051
  direct_push_error="$direct_push_output"
@@ -484,6 +1091,10 @@ if [[ "$PUSH_ENABLED" -eq 1 ]]; then
484
1091
  fi
485
1092
  fi
486
1093
 
1094
+ if [[ "$merge_completed" -eq 1 ]]; then
1095
+ capture_post_merge_learning
1096
+ fi
1097
+
487
1098
  if [[ -x "${repo_root}/scripts/agent-file-locks.py" ]]; then
488
1099
  python3 "${repo_root}/scripts/agent-file-locks.py" release --branch "$SOURCE_BRANCH" >/dev/null 2>&1 || true
489
1100
  fi
@@ -494,6 +1105,9 @@ if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_
494
1105
  fi
495
1106
 
496
1107
  if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
1108
+ cleanup_incomplete=0
1109
+ cleanup_remaining_messages=()
1110
+
497
1111
  if [[ "$source_worktree" == "$repo_root" ]]; then
498
1112
  if is_clean_worktree "$source_worktree"; then
499
1113
  switched_to_base=0
@@ -514,7 +1128,7 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
514
1128
  git -C "$repo_root" worktree remove "$source_worktree" --force >/dev/null 2>&1 || true
515
1129
  fi
516
1130
 
517
- git -C "$repo_root" branch -d "$SOURCE_BRANCH"
1131
+ delete_local_source_branch "$SOURCE_BRANCH" "$BASE_BRANCH"
518
1132
 
519
1133
  if [[ "$PUSH_ENABLED" -eq 1 && "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then
520
1134
  if git -C "$repo_root" ls-remote --exit-code --heads origin "$SOURCE_BRANCH" >/dev/null 2>&1; then
@@ -523,28 +1137,46 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
523
1137
  fi
524
1138
 
525
1139
  if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then
526
- prune_args=(--base "$BASE_BRANCH" --only-dirty-worktrees --delete-branches)
1140
+ prune_args=(--base "$BASE_BRANCH" --branch "$SOURCE_BRANCH" --only-dirty-worktrees --delete-branches --force-merged)
527
1141
  if [[ "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then
528
1142
  prune_args+=(--delete-remote-branches)
529
1143
  fi
530
1144
  if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then
531
1145
  echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2
532
- echo "[agent-branch-finish] You can run manual cleanup: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches" >&2
1146
+ echo "[agent-branch-finish] You can run manual cleanup: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches --delete-remote-branches" >&2
533
1147
  fi
534
1148
  fi
535
1149
 
536
- echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and cleaned source branch/worktree."
537
- if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${agent_worktree_root}"/* ]]; then
538
- echo "[agent-branch-finish] Current worktree '${source_worktree}' still exists because it is the active shell cwd." >&2
539
- echo "[agent-branch-finish] Leave this directory, then run: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches" >&2
1150
+ if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${SOURCE_BRANCH}"; then
1151
+ cleanup_incomplete=1
1152
+ cleanup_remaining_messages+=("local branch still exists: ${SOURCE_BRANCH}")
540
1153
  fi
541
- else
542
- if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then
543
- if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" --base "$BASE_BRANCH"; then
544
- echo "[agent-branch-finish] Warning: temporary worktree prune failed." >&2
1154
+
1155
+ if [[ "$PUSH_ENABLED" -eq 1 && "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then
1156
+ if git -C "$repo_root" ls-remote --exit-code --heads origin "$SOURCE_BRANCH" >/dev/null 2>&1; then
1157
+ cleanup_incomplete=1
1158
+ cleanup_remaining_messages+=("remote branch still exists: origin/${SOURCE_BRANCH}")
545
1159
  fi
546
1160
  fi
547
1161
 
1162
+ if [[ "$source_worktree" == "${agent_worktree_root}"/* && -d "$source_worktree" ]]; then
1163
+ cleanup_incomplete=1
1164
+ cleanup_remaining_messages+=("agent worktree path still exists: ${source_worktree}")
1165
+ fi
1166
+
1167
+ if [[ "$cleanup_incomplete" -eq 1 ]]; then
1168
+ echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow, but mandatory cleanup is still incomplete." >&2
1169
+ for cleanup_message in "${cleanup_remaining_messages[@]}"; do
1170
+ echo "[agent-branch-finish] Remaining cleanup: ${cleanup_message}" >&2
1171
+ done
1172
+ if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${agent_worktree_root}"/* ]]; then
1173
+ echo "[agent-branch-finish] Leave this active sandbox directory, then rerun: bash scripts/agent-branch-finish.sh --branch ${SOURCE_BRANCH} --base ${BASE_BRANCH} --via-pr --wait-for-merge --cleanup" >&2
1174
+ fi
1175
+ exit 1
1176
+ fi
1177
+
1178
+ echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and cleaned source branch/worktree."
1179
+ else
548
1180
  echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and kept source branch/worktree."
549
1181
  echo "[agent-branch-finish] Cleanup later with: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches --delete-remote-branches"
550
1182
  fi