@imdeadpool/guardex 7.0.13 → 7.0.15

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imdeadpool/guardex",
3
- "version": "7.0.13",
3
+ "version": "7.0.15",
4
4
  "description": "GitGuardex: hardened multi-agent git guardrails for parallel agent work.",
5
5
  "license": "MIT",
6
6
  "preferGlobal": true,
@@ -60,7 +60,7 @@
60
60
  "bugs": {
61
61
  "url": "https://github.com/recodeee/gitguardex/issues"
62
62
  },
63
- "homepage": "https://guardextutorial.com",
63
+ "homepage": "https://github.com/recodeee/gitguardex-frontend",
64
64
  "funding": "https://github.com/sponsors/recodeecom",
65
65
  "publishConfig": {
66
66
  "access": "public"
@@ -1,21 +1,28 @@
1
1
  <!-- multiagent-safety:START -->
2
- ## Multi-Agent Safety Contract
2
+ ## Multi-Agent Execution Contract (GX)
3
3
 
4
- **Repo toggle.** `GUARDEX_ON=0|false|no|off` disables this contract. `GUARDEX_ON=1|true|yes|on` re-enables it.
4
+ **Repo toggle.** Guardex is enabled by default. If the repo root `.env` sets `GUARDEX_ON=0`, `false`, `no`, or `off`, treat this entire Guardex contract as disabled for the repo and do not require Guardex worktrees, lock claims, completion flow, or OpenSpec workflow until `GUARDEX_ON` is re-enabled.
5
5
 
6
- **Isolation.** One task = one `agent/*` branch + worktree. Start `scripts/agent-branch-start.sh "<task>" "<agent>"`. Base branches stay read-only. No `git checkout` on primary worktrees; use `git worktree add`. `.githooks/post-checkout` auto-reverts primary-branch switches unless `GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1`.
6
+ **Repo toggle examples.** Add one of these lines to the repo-root `.env` file:
7
+ `GUARDEX_ON=0` disables Guardex for that repo.
8
+ `GUARDEX_ON=1` explicitly enables Guardex for that repo again.
7
9
 
8
- **Ownership.** Claim before edits: `scripts/agent-file-locks.py claim --branch "<agent-branch>" <file...>`. Delete only claimed paths.
10
+ **Isolation.** Every task runs on a dedicated `agent/*` branch + worktree. Start with `scripts/agent-branch-start.sh "<task>" "<agent-name>"`. Treat the base branch (`main`/`dev`) as read-only while an agent branch is active. Never `git checkout <branch>` on a primary working tree (including nested repos); use `git worktree add` instead. The `.githooks/post-checkout` hook auto-reverts primary-branch switches during agent sessions - bypass only with `GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1`.
11
+ For every new task, including follow-up work in the same chat/session, if an assigned agent sub-branch/worktree is already open, continue in that sub-branch instead of creating a fresh lane unless the user explicitly redirects scope.
12
+ Never implement directly on the local/base branch checkout; keep it unchanged and perform all edits in the agent sub-branch/worktree.
9
13
 
10
- **Handoff.** Post a one-line note before edits. Re-read latest handoffs before replacing nearby work.
14
+ **Ownership.** Before editing, claim files: `scripts/agent-file-locks.py claim --branch "<agent-branch>" <file...>`. Before deleting, confirm the path is in your claim. Don't edit outside your scope unless reassigned.
11
15
 
12
- **Completion.** Finish with `scripts/agent-branch-finish.sh --branch "<agent-branch>" --via-pr --wait-for-merge --cleanup` or `gx finish --all`. Done = commit pushed, PR URL recorded, state=`MERGED`, sandbox pruned. If blocked, append `BLOCKED:` and stop.
16
+ **Handoff gate.** Post a one-line handoff note (plan/change, owned scope, intended action) before editing. Re-read the latest handoffs before replacing others' code.
13
17
 
14
- **Parallel safety.** Never revert unrelated edits. Report conflicts.
18
+ **Completion.** Finish with `scripts/agent-branch-finish.sh --branch "<agent-branch>" --via-pr --wait-for-merge --cleanup` (or `gx finish --all`). Task is only complete when: commit pushed, PR URL recorded, state = `MERGED`, sandbox worktree pruned. If anything blocks, append a `BLOCKED:` note and stop - don't half-finish.
19
+ OMX completion policy: when a task is done, the agent must commit the task changes, push the agent branch, and create/update a PR before considering the branch complete.
15
20
 
16
- **Reporting.** Completion handoff includes files changed, behavior touched, verification commands/results, and risks/follow-ups.
21
+ **Parallel safety.** Assume other agents edit nearby. Never revert unrelated changes. Report conflicts in the handoff.
17
22
 
18
- **OpenSpec.** Keep `openspec/changes/<slug>/tasks.md` current. End task scaffolds with PR merge + sandbox cleanup evidence. Run `openspec validate --specs` before archive.
23
+ **Reporting.** Every completion handoff includes: files changed, behavior touched, verification commands + results, risks/follow-ups.
24
+
25
+ **OpenSpec (when change-driven).** Keep `openspec/changes/<slug>/tasks.md` checkboxes current during work, not batched at the end. Task scaffolds and manual task edits must include an explicit final completion/cleanup section that ends with PR merge + sandbox cleanup (`gx finish --via-pr --wait-for-merge --cleanup` or `scripts/agent-branch-finish.sh ... --cleanup`) and records PR URL + final `MERGED` evidence. Verify specs with `openspec validate --specs` before archive. Don't archive unverified.
19
26
 
20
27
  **Version bumps.** If a change bumps a published version, the same PR updates release notes/changelog.
21
28
  <!-- multiagent-safety:END -->
@@ -10,12 +10,18 @@ permissions:
10
10
 
11
11
  jobs:
12
12
  review:
13
- if: ${{ secrets.OPENAI_API_KEY != '' }}
14
13
  runs-on: ubuntu-latest
14
+ env:
15
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
15
16
  steps:
16
- - uses: anc95/ChatGPT-CodeReview@main
17
+ - name: Skip when OPENAI_API_KEY is missing
18
+ if: ${{ env.OPENAI_API_KEY == '' }}
19
+ run: echo "OPENAI_API_KEY is not configured; skipping Code Review workflow."
20
+
21
+ - uses: anc95/ChatGPT-CodeReview@1e3df152c1b85c12da580b206c91ad343460c584 # v1.0.23
22
+ if: ${{ env.OPENAI_API_KEY != '' }}
17
23
  env:
18
24
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
25
+ OPENAI_API_KEY: ${{ env.OPENAI_API_KEY }}
20
26
  OPENAI_API_ENDPOINT: https://api.openai.com/v1
21
27
  MODEL: gpt-4o-mini
@@ -144,24 +144,44 @@ else
144
144
  common_git_dir="$(cd "$repo_root/$common_git_dir_raw" && pwd -P)"
145
145
  fi
146
146
  repo_common_root="$(cd "$common_git_dir/.." && pwd -P)"
147
- agent_worktree_root="${repo_common_root}/.omx/agent-worktrees"
148
147
 
149
148
  if [[ -z "$SOURCE_BRANCH" ]]; then
150
149
  SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
151
150
  fi
152
151
 
152
+ stored_worktree_root_rel="$(git -C "$repo_root" config --get "branch.${SOURCE_BRANCH}.guardexWorktreeRoot" || true)"
153
+ if [[ -z "$stored_worktree_root_rel" ]]; then
154
+ stored_worktree_root_rel=".omx/agent-worktrees"
155
+ fi
156
+ agent_worktree_root="${repo_common_root}/${stored_worktree_root_rel}"
157
+
153
158
  if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
154
159
  echo "[agent-branch-finish] --base requires a non-empty branch name." >&2
155
160
  exit 1
156
161
  fi
157
162
 
158
163
  if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
159
- configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
160
- if [[ -n "$configured_base" ]]; then
161
- BASE_BRANCH="$configured_base"
164
+ source_branch_base="$(git -C "$repo_root" config --get "branch.${SOURCE_BRANCH}.guardexBase" || true)"
165
+ if [[ -n "$source_branch_base" ]]; then
166
+ BASE_BRANCH="$source_branch_base"
167
+ else
168
+ configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
169
+ if [[ -n "$configured_base" ]]; then
170
+ BASE_BRANCH="$configured_base"
171
+ fi
162
172
  fi
163
173
  fi
164
174
 
175
+ if [[ -z "$BASE_BRANCH" ]]; then
176
+ for fallback_branch in dev main master; do
177
+ if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${fallback_branch}" \
178
+ || git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${fallback_branch}"; then
179
+ BASE_BRANCH="$fallback_branch"
180
+ break
181
+ fi
182
+ done
183
+ fi
184
+
165
185
  if [[ -z "$BASE_BRANCH" ]]; then
166
186
  BASE_BRANCH="dev"
167
187
  fi
@@ -268,8 +288,17 @@ if [[ "$should_require_sync" -eq 1 ]] && git -C "$repo_root" show-ref --verify -
268
288
  fi
269
289
  fi
270
290
 
271
- integration_worktree="${agent_worktree_root}/__integrate-${BASE_BRANCH//\//__}-$(date +%Y%m%d-%H%M%S)"
272
- integration_branch="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)"
291
+ integration_stamp="$(date +%Y%m%d-%H%M%S)"
292
+ integration_worktree_base="${agent_worktree_root}/__integrate-${BASE_BRANCH//\//__}-${integration_stamp}"
293
+ integration_branch_base="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)"
294
+ integration_worktree="$integration_worktree_base"
295
+ integration_branch="$integration_branch_base"
296
+ integration_suffix=1
297
+ while [[ -e "$integration_worktree" ]] || git -C "$repo_root" show-ref --verify --quiet "refs/heads/${integration_branch}"; do
298
+ integration_worktree="${integration_worktree_base}-${integration_suffix}"
299
+ integration_branch="${integration_branch_base}_${integration_suffix}"
300
+ integration_suffix=$((integration_suffix + 1))
301
+ done
273
302
  mkdir -p "$(dirname "$integration_worktree")"
274
303
 
275
304
  git -C "$repo_root" worktree add "$integration_worktree" "$start_ref" >/dev/null
@@ -5,7 +5,8 @@ TASK_NAME="task"
5
5
  AGENT_NAME="agent"
6
6
  BASE_BRANCH=""
7
7
  BASE_BRANCH_EXPLICIT=0
8
- WORKTREE_ROOT_REL=".omx/agent-worktrees"
8
+ WORKTREE_ROOT_REL=""
9
+ WORKTREE_ROOT_EXPLICIT=0
9
10
  OPENSPEC_AUTO_INIT_RAW="${GUARDEX_OPENSPEC_AUTO_INIT:-false}"
10
11
  OPENSPEC_PLAN_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_PLAN_SLUG:-}"
11
12
  OPENSPEC_CHANGE_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CHANGE_SLUG:-}"
@@ -44,6 +45,7 @@ while [[ $# -gt 0 ]]; do
44
45
  ;;
45
46
  --worktree-root)
46
47
  WORKTREE_ROOT_REL="${2:-.omx/agent-worktrees}"
48
+ WORKTREE_ROOT_EXPLICIT=1
47
49
  shift 2
48
50
  ;;
49
51
  --)
@@ -123,12 +125,41 @@ shorten_slug() {
123
125
  printf '%s' "$shortened"
124
126
  }
125
127
 
126
- # Collapse arbitrary agent identifiers to a clean role token: claude | codex |
127
- # <other-kebab>. Priority: GUARDEX_AGENT_TYPE env override, then the raw
128
- # AGENT_NAME (if it contains 'claude' or 'codex'), then CLAUDECODE=1 sentinel
129
- # (set by Claude Code CLI), else fall back to 'codex'. Any other role name
130
- # (integrator, executor, rust-port, etc.) is preserved as-is after slug
131
- # sanitization.
128
+ env_flag_truthy() {
129
+ local raw="${1:-}"
130
+ local lowered
131
+ lowered="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
132
+ case "$lowered" in
133
+ 1|true|yes|on) return 0 ;;
134
+ *) return 1 ;;
135
+ esac
136
+ }
137
+
138
+ default_worktree_root_rel() {
139
+ local raw_agent="$1"
140
+ local override="${GUARDEX_AGENT_TYPE:-}"
141
+ local lowered_agent lowered_override
142
+ lowered_agent="$(printf '%s' "$raw_agent" | tr '[:upper:]' '[:lower:]')"
143
+ lowered_override="$(printf '%s' "$override" | tr '[:upper:]' '[:lower:]')"
144
+
145
+ if [[ -n "${CLAUDE_CODE_SESSION_ID:-}" ]] || env_flag_truthy "${CLAUDECODE:-}"; then
146
+ printf '.omc/agent-worktrees'
147
+ return 0
148
+ fi
149
+
150
+ if [[ "$lowered_agent" == *claude* ]] || [[ "$lowered_override" == *claude* ]]; then
151
+ printf '.omc/agent-worktrees'
152
+ return 0
153
+ fi
154
+
155
+ printf '.omx/agent-worktrees'
156
+ }
157
+
158
+ # Collapse arbitrary agent identifiers to a clean role token. Priority:
159
+ # GUARDEX_AGENT_TYPE env override, then recognizable claude/codex aliases, then
160
+ # a small legacy compatibility set, then the literal requested role after slug
161
+ # sanitization. This preserves explicit roles such as planner/executor while
162
+ # keeping the older bot -> codex fallback stable for existing callers.
132
163
  normalize_role() {
133
164
  local raw_agent="$1"
134
165
  local override="${GUARDEX_AGENT_TYPE:-}"
@@ -150,10 +181,13 @@ normalize_role() {
150
181
  printf 'claude'
151
182
  return 0
152
183
  fi
153
- # Unrecognized raw name (rust-port-lead, some-worker, empty, ...): default to
154
- # codex. To get a different role (integrator, executor, ...) pass the role
155
- # explicitly via GUARDEX_AGENT_TYPE, handled above.
156
- printf 'codex'
184
+ local sanitized
185
+ sanitized="$(sanitize_slug "$raw_agent" "codex")"
186
+ if [[ "$sanitized" == "bot" ]]; then
187
+ printf 'codex'
188
+ return 0
189
+ fi
190
+ printf '%s' "$sanitized"
157
191
  }
158
192
 
159
193
  # Timestamp the branch/worktree/openspec slug so parallel agents never collide
@@ -413,6 +447,9 @@ fi
413
447
 
414
448
  task_slug="$(sanitize_slug "$TASK_NAME" "task")"
415
449
  agent_slug="$(normalize_role "$AGENT_NAME")"
450
+ if [[ "$WORKTREE_ROOT_EXPLICIT" -eq 0 ]]; then
451
+ WORKTREE_ROOT_REL="$(default_worktree_root_rel "$AGENT_NAME")"
452
+ fi
416
453
  branch_timestamp="$(compose_branch_timestamp)"
417
454
  branch_descriptor="$(compose_branch_descriptor "$task_slug" "$branch_timestamp")"
418
455
  branch_name_base="agent/${agent_slug}/${branch_descriptor}"
@@ -497,6 +534,7 @@ if ! worktree_add_output="$(git -C "$repo_root" worktree add -b "$branch_name" "
497
534
  exit 1
498
535
  fi
499
536
  git -C "$repo_root" config "branch.${branch_name}.guardexBase" "$BASE_BRANCH" >/dev/null 2>&1 || true
537
+ git -C "$repo_root" config "branch.${branch_name}.guardexWorktreeRoot" "$WORKTREE_ROOT_REL" >/dev/null 2>&1 || true
500
538
  # Fresh agent branches should start unpublished; clear any inherited base-branch tracking.
501
539
  git -C "$worktree_path" branch --unset-upstream "$branch_name" >/dev/null 2>&1 || true
502
540
 
@@ -533,4 +571,4 @@ echo "[agent-branch-start] Next steps:"
533
571
  echo " cd \"${worktree_path}\""
534
572
  echo " python3 scripts/agent-file-locks.py claim --branch \"${branch_name}\" <file...>"
535
573
  echo " # implement + commit"
536
- echo " bash scripts/agent-branch-finish.sh --branch \"${branch_name}\" --base dev --via-pr --wait-for-merge"
574
+ echo " bash scripts/agent-branch-finish.sh --branch \"${branch_name}\" --base ${BASE_BRANCH} --via-pr --wait-for-merge"
@@ -8,11 +8,20 @@ FORCE_DIRTY=0
8
8
  DELETE_BRANCHES=0
9
9
  DELETE_REMOTE_BRANCHES=0
10
10
  ONLY_DIRTY_WORKTREES=0
11
+ INCLUDE_PR_MERGED=0
11
12
  TARGET_BRANCH=""
12
13
  IDLE_MINUTES=0
13
14
  NOW_EPOCH_RAW="${GUARDEX_PRUNE_NOW_EPOCH:-}"
14
15
  IDLE_SECONDS=0
15
16
  NOW_EPOCH=0
17
+ GH_BIN="${GUARDEX_GH_BIN:-gh}"
18
+ PR_MERGED_LOOKUP_DISABLED=0
19
+ PR_MERGED_LOOKUP_LOADED=0
20
+ declare -A MERGED_PR_BRANCHES=()
21
+ WORKTREE_ROOT_RELS=(
22
+ ".omx/agent-worktrees"
23
+ ".omc/agent-worktrees"
24
+ )
16
25
 
17
26
  if [[ -n "$BASE_BRANCH" ]]; then
18
27
  BASE_BRANCH_EXPLICIT=1
@@ -45,6 +54,10 @@ while [[ $# -gt 0 ]]; do
45
54
  ONLY_DIRTY_WORKTREES=1
46
55
  shift
47
56
  ;;
57
+ --include-pr-merged)
58
+ INCLUDE_PR_MERGED=1
59
+ shift
60
+ ;;
48
61
  --branch)
49
62
  TARGET_BRANCH="${2:-}"
50
63
  shift 2
@@ -55,7 +68,7 @@ while [[ $# -gt 0 ]]; do
55
68
  ;;
56
69
  *)
57
70
  echo "[agent-worktree-prune] Unknown argument: $1" >&2
58
- echo "Usage: $0 [--base <branch>] [--dry-run] [--force-dirty] [--delete-branches] [--delete-remote-branches] [--only-dirty-worktrees] [--branch <agent/...>] [--idle-minutes <minutes>]" >&2
71
+ echo "Usage: $0 [--base <branch>] [--dry-run] [--force-dirty] [--delete-branches] [--delete-remote-branches] [--only-dirty-worktrees] [--include-pr-merged] [--branch <agent/...>] [--idle-minutes <minutes>]" >&2
59
72
  exit 1
60
73
  ;;
61
74
  esac
@@ -68,13 +81,36 @@ fi
68
81
 
69
82
  repo_root="$(git rev-parse --show-toplevel)"
70
83
  current_pwd="$(pwd -P)"
71
- worktree_root="${repo_root}/.omx/agent-worktrees"
72
84
  repo_common_dir="$(
73
85
  git -C "$repo_root" rev-parse --git-common-dir \
74
86
  | awk -v root="$repo_root" '{ if ($0 ~ /^\//) { print $0 } else { print root "/" $0 } }'
75
87
  )"
76
88
  repo_common_dir="$(cd "$repo_common_dir" && pwd -P)"
77
89
 
90
+ resolve_worktree_root_rel_for_entry() {
91
+ local entry="$1"
92
+ case "$entry" in
93
+ */.omc/agent-worktrees/*)
94
+ printf '%s' '.omc/agent-worktrees'
95
+ ;;
96
+ *)
97
+ printf '%s' '.omx/agent-worktrees'
98
+ ;;
99
+ esac
100
+ }
101
+
102
+ is_managed_worktree_path() {
103
+ local entry="$1"
104
+ local rel root
105
+ for rel in "${WORKTREE_ROOT_RELS[@]}"; do
106
+ root="${repo_root}/${rel}"
107
+ if [[ "$entry" == "${root}"/* ]]; then
108
+ return 0
109
+ fi
110
+ done
111
+ return 1
112
+ }
113
+
78
114
  resolve_base_branch() {
79
115
  local configured=""
80
116
  local current=""
@@ -101,6 +137,44 @@ resolve_base_branch() {
101
137
  printf '%s' ""
102
138
  }
103
139
 
140
+ load_merged_pr_branches() {
141
+ if [[ "$INCLUDE_PR_MERGED" -ne 1 ]]; then
142
+ return 1
143
+ fi
144
+ if [[ "$PR_MERGED_LOOKUP_DISABLED" -eq 1 ]]; then
145
+ return 1
146
+ fi
147
+ if [[ "$PR_MERGED_LOOKUP_LOADED" -eq 1 ]]; then
148
+ return 0
149
+ fi
150
+ if ! command -v "$GH_BIN" >/dev/null 2>&1; then
151
+ PR_MERGED_LOOKUP_DISABLED=1
152
+ return 1
153
+ fi
154
+
155
+ local merged_branches=""
156
+ merged_branches="$(
157
+ "$GH_BIN" pr list --state merged --base "$BASE_BRANCH" --limit 200 --json headRefName --jq '.[].headRefName' 2>/dev/null || true
158
+ )"
159
+ if [[ -n "$merged_branches" ]]; then
160
+ while IFS= read -r merged_branch; do
161
+ [[ -z "$merged_branch" ]] && continue
162
+ MERGED_PR_BRANCHES["$merged_branch"]=1
163
+ done <<< "$merged_branches"
164
+ fi
165
+ PR_MERGED_LOOKUP_LOADED=1
166
+ return 0
167
+ }
168
+
169
+ branch_has_merged_pr() {
170
+ local branch="$1"
171
+ if [[ "$INCLUDE_PR_MERGED" -ne 1 ]]; then
172
+ return 1
173
+ fi
174
+ load_merged_pr_branches || return 1
175
+ [[ -n "${MERGED_PR_BRANCHES[$branch]:-}" ]]
176
+ }
177
+
104
178
  if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
105
179
  echo "[agent-worktree-prune] --base requires a non-empty branch name." >&2
106
180
  exit 1
@@ -261,54 +335,59 @@ relocated_foreign=0
261
335
  skipped_foreign=0
262
336
 
263
337
  relocate_foreign_worktree_entries() {
264
- [[ -d "$worktree_root" ]] || return 0
265
-
266
- local entry=""
267
- for entry in "${worktree_root}"/*; do
268
- [[ -d "$entry" ]] || continue
269
- if ! git -C "$entry" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
270
- continue
271
- fi
272
-
273
- local entry_common_dir=""
274
- entry_common_dir="$(resolve_worktree_common_dir "$entry" || true)"
275
- [[ -n "$entry_common_dir" ]] || continue
338
+ local rel="" worktree_root="" entry=""
339
+ for rel in "${WORKTREE_ROOT_RELS[@]}"; do
340
+ worktree_root="${repo_root}/${rel}"
341
+ [[ -d "$worktree_root" ]] || continue
342
+
343
+ for entry in "${worktree_root}"/*; do
344
+ [[ -d "$entry" ]] || continue
345
+ if ! git -C "$entry" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
346
+ continue
347
+ fi
276
348
 
277
- if [[ "$entry_common_dir" == "$repo_common_dir" ]]; then
278
- continue
279
- fi
349
+ local entry_common_dir=""
350
+ entry_common_dir="$(resolve_worktree_common_dir "$entry" || true)"
351
+ [[ -n "$entry_common_dir" ]] || continue
280
352
 
281
- if [[ "$(basename "$entry_common_dir")" != ".git" ]]; then
282
- skipped_foreign=$((skipped_foreign + 1))
283
- echo "[agent-worktree-prune] Skipping foreign worktree with unsupported git common dir: ${entry}"
284
- continue
285
- fi
353
+ if [[ "$entry_common_dir" == "$repo_common_dir" ]]; then
354
+ continue
355
+ fi
286
356
 
287
- local owner_repo_root
288
- owner_repo_root="$(dirname "$entry_common_dir")"
289
- local owner_worktree_root="${owner_repo_root}/.omx/agent-worktrees"
290
- local target_path
291
- target_path="$(select_unique_worktree_path "$owner_worktree_root" "$(basename "$entry")")"
357
+ if [[ "$(basename "$entry_common_dir")" != ".git" ]]; then
358
+ skipped_foreign=$((skipped_foreign + 1))
359
+ echo "[agent-worktree-prune] Skipping foreign worktree with unsupported git common dir: ${entry}"
360
+ continue
361
+ fi
292
362
 
293
- if [[ "$entry" == "$current_pwd" || "$current_pwd" == "${entry}"/* ]]; then
294
- skipped_foreign=$((skipped_foreign + 1))
295
- echo "[agent-worktree-prune] Skipping active foreign worktree: ${entry}"
296
- continue
297
- fi
363
+ local owner_repo_root
364
+ owner_repo_root="$(dirname "$entry_common_dir")"
365
+ local owner_worktree_root_rel owner_worktree_root
366
+ owner_worktree_root_rel="$(resolve_worktree_root_rel_for_entry "$entry")"
367
+ owner_worktree_root="${owner_repo_root}/${owner_worktree_root_rel}"
368
+ local target_path
369
+ target_path="$(select_unique_worktree_path "$owner_worktree_root" "$(basename "$entry")")"
370
+
371
+ if [[ "$entry" == "$current_pwd" || "$current_pwd" == "${entry}"/* ]]; then
372
+ skipped_foreign=$((skipped_foreign + 1))
373
+ echo "[agent-worktree-prune] Skipping active foreign worktree: ${entry}"
374
+ continue
375
+ fi
298
376
 
299
- echo "[agent-worktree-prune] Relocating foreign worktree to owning repo: ${entry} -> ${target_path}"
300
- if [[ "$DRY_RUN" -eq 1 ]]; then
301
- relocated_foreign=$((relocated_foreign + 1))
302
- continue
303
- fi
377
+ echo "[agent-worktree-prune] Relocating foreign worktree to owning repo: ${entry} -> ${target_path}"
378
+ if [[ "$DRY_RUN" -eq 1 ]]; then
379
+ relocated_foreign=$((relocated_foreign + 1))
380
+ continue
381
+ fi
304
382
 
305
- mkdir -p "$owner_worktree_root"
306
- if git -C "$owner_repo_root" worktree move "$entry" "$target_path" >/dev/null 2>&1; then
307
- relocated_foreign=$((relocated_foreign + 1))
308
- else
309
- skipped_foreign=$((skipped_foreign + 1))
310
- echo "[agent-worktree-prune] Failed to relocate foreign worktree: ${entry}" >&2
311
- fi
383
+ mkdir -p "$owner_worktree_root"
384
+ if git -C "$owner_repo_root" worktree move "$entry" "$target_path" >/dev/null 2>&1; then
385
+ relocated_foreign=$((relocated_foreign + 1))
386
+ else
387
+ skipped_foreign=$((skipped_foreign + 1))
388
+ echo "[agent-worktree-prune] Failed to relocate foreign worktree: ${entry}" >&2
389
+ fi
390
+ done
312
391
  done
313
392
  }
314
393
 
@@ -324,7 +403,9 @@ process_entry() {
324
403
  local branch_ref="$2"
325
404
 
326
405
  [[ -z "$wt" ]] && return
327
- [[ "$wt" != "${worktree_root}"/* ]] && return
406
+ if ! is_managed_worktree_path "$wt"; then
407
+ return
408
+ fi
328
409
 
329
410
  local branch=""
330
411
  if [[ -n "$branch_ref" ]]; then
@@ -342,6 +423,7 @@ process_entry() {
342
423
  fi
343
424
 
344
425
  local remove_reason=""
426
+ local branch_delete_mode="safe"
345
427
 
346
428
  if [[ -z "$branch_ref" ]]; then
347
429
  remove_reason="detached-worktree"
@@ -352,6 +434,9 @@ process_entry() {
352
434
  if [[ "$DELETE_BRANCHES" -eq 1 ]]; then
353
435
  remove_reason="merged-agent-branch"
354
436
  fi
437
+ elif [[ "$DELETE_BRANCHES" -eq 1 ]] && branch_has_merged_pr "$branch"; then
438
+ remove_reason="merged-agent-pr"
439
+ branch_delete_mode="force"
355
440
  elif [[ "$ONLY_DIRTY_WORKTREES" -eq 1 ]] && is_clean_worktree "$wt"; then
356
441
  remove_reason="clean-agent-worktree"
357
442
  fi
@@ -383,13 +468,19 @@ process_entry() {
383
468
 
384
469
  if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}" && ! branch_has_worktree "$branch"; then
385
470
  if [[ "$branch" == agent/* && "$DELETE_BRANCHES" -eq 1 ]]; then
386
- if run_cmd git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then
471
+ local delete_flag="-d"
472
+ local deleted_label="merged"
473
+ if [[ "$branch_delete_mode" == "force" ]]; then
474
+ delete_flag="-D"
475
+ deleted_label="merged PR"
476
+ fi
477
+ if run_cmd git -C "$repo_root" branch "$delete_flag" "$branch" >/dev/null 2>&1; then
387
478
  removed_branches=$((removed_branches + 1))
388
- echo "[agent-worktree-prune] Deleted merged branch: ${branch}"
479
+ echo "[agent-worktree-prune] Deleted ${deleted_label} branch: ${branch}"
389
480
  if [[ "$DELETE_REMOTE_BRANCHES" -eq 1 ]]; then
390
481
  if git -C "$repo_root" ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
391
482
  run_cmd git -C "$repo_root" push origin --delete "$branch" >/dev/null 2>&1 || true
392
- echo "[agent-worktree-prune] Deleted merged remote branch: ${branch}"
483
+ echo "[agent-worktree-prune] Deleted ${deleted_label} remote branch: ${branch}"
393
484
  fi
394
485
  fi
395
486
  fi
@@ -420,7 +511,7 @@ while IFS= read -r line || [[ -n "$line" ]]; do
420
511
  current_branch_ref="${line#branch }"
421
512
  ;;
422
513
  esac
423
- done < <(git -C "$repo_root" worktree list --porcelain)
514
+ done < <(git -C "$repo_root" worktree list --porcelain)
424
515
 
425
516
  process_entry "$current_wt" "$current_branch_ref"
426
517
 
@@ -436,14 +527,27 @@ if [[ "$DELETE_BRANCHES" -eq 1 ]]; then
436
527
  if ! branch_idle_gate "$branch" "" "stale-merged-branch"; then
437
528
  continue
438
529
  fi
530
+ merged_by_ancestor=0
531
+ merged_by_pr=0
439
532
  if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then
440
- if run_cmd git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then
533
+ merged_by_ancestor=1
534
+ elif branch_has_merged_pr "$branch"; then
535
+ merged_by_pr=1
536
+ fi
537
+ if [[ "$merged_by_ancestor" -eq 1 || "$merged_by_pr" -eq 1 ]]; then
538
+ delete_flag="-d"
539
+ deleted_label="merged"
540
+ if [[ "$merged_by_pr" -eq 1 && "$merged_by_ancestor" -eq 0 ]]; then
541
+ delete_flag="-D"
542
+ deleted_label="merged PR"
543
+ fi
544
+ if run_cmd git -C "$repo_root" branch "$delete_flag" "$branch" >/dev/null 2>&1; then
441
545
  removed_branches=$((removed_branches + 1))
442
- echo "[agent-worktree-prune] Deleted stale merged branch: ${branch}"
546
+ echo "[agent-worktree-prune] Deleted stale ${deleted_label} branch: ${branch}"
443
547
  if [[ "$DELETE_REMOTE_BRANCHES" -eq 1 ]]; then
444
548
  if git -C "$repo_root" ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
445
549
  run_cmd git -C "$repo_root" push origin --delete "$branch" >/dev/null 2>&1 || true
446
- echo "[agent-worktree-prune] Deleted stale merged remote branch: ${branch}"
550
+ echo "[agent-worktree-prune] Deleted stale ${deleted_label} remote branch: ${branch}"
447
551
  fi
448
552
  fi
449
553
  fi
@@ -249,6 +249,35 @@ resolve_start_ref() {
249
249
  return 1
250
250
  }
251
251
 
252
+ origin_remote_looks_like_github() {
253
+ local wt="$1"
254
+ local origin_url=""
255
+ origin_url="$(git -C "$wt" remote get-url origin 2>/dev/null || true)"
256
+ [[ -n "$origin_url" && "$origin_url" =~ github\.com[:/] ]]
257
+ }
258
+
259
+ auto_finish_context_is_ready() {
260
+ local wt="$1"
261
+ local gh_bin="${GUARDEX_GH_BIN:-gh}"
262
+
263
+ if ! git -C "$wt" remote get-url origin >/dev/null 2>&1; then
264
+ return 1
265
+ fi
266
+ if ! command -v "$gh_bin" >/dev/null 2>&1; then
267
+ return 1
268
+ fi
269
+
270
+ if [[ -n "${GUARDEX_GH_BIN:-}" ]]; then
271
+ return 0
272
+ fi
273
+
274
+ if ! origin_remote_looks_like_github "$wt"; then
275
+ return 1
276
+ fi
277
+
278
+ "$gh_bin" auth status >/dev/null 2>&1
279
+ }
280
+
252
281
  restore_repo_branch_if_changed() {
253
282
  local expected_branch="$1"
254
283
  if [[ -z "$expected_branch" || "$expected_branch" == "HEAD" ]]; then
@@ -372,6 +401,17 @@ has_origin_remote() {
372
401
  git -C "$repo_root" remote get-url origin >/dev/null 2>&1
373
402
  }
374
403
 
404
+ origin_remote_supports_pr_finish() {
405
+ local origin_url
406
+ origin_url="$(git -C "$repo_root" remote get-url origin 2>/dev/null || true)"
407
+ case "$origin_url" in
408
+ ''|/*|./*|../*|file://*)
409
+ return 1
410
+ ;;
411
+ esac
412
+ return 0
413
+ }
414
+
375
415
  resolve_worktree_base_branch() {
376
416
  local _wt="$1"
377
417
  if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -n "$BASE_BRANCH" ]]; then
@@ -685,7 +725,12 @@ run_finish_flow() {
685
725
  echo "[codex-agent] Auto-finish requires GitHub CLI for PR flow; command not found: ${GUARDEX_GH_BIN:-gh}" >&2
686
726
  return 2
687
727
  fi
688
- finish_args+=(--via-pr)
728
+ if origin_remote_supports_pr_finish; then
729
+ finish_args+=(--via-pr)
730
+ else
731
+ echo "[codex-agent] Origin remote does not provide a mergeable PR surface; skipping auto-finish merge/PR pipeline." >&2
732
+ return 2
733
+ fi
689
734
  else
690
735
  echo "[codex-agent] No origin remote detected; skipping auto-finish merge/PR pipeline." >&2
691
736
  return 2
@@ -764,7 +809,9 @@ if [[ "$AUTO_FINISH" -eq 1 && -n "$worktree_branch" && "$worktree_branch" != "HE
764
809
  else
765
810
  echo "[codex-agent] Auto-finish enabled: commit -> push/PR -> merge (keep branch/worktree)."
766
811
  fi
767
- if auto_commit_worktree_changes "$worktree_path" "$worktree_branch"; then
812
+ if ! auto_finish_context_is_ready "$worktree_path"; then
813
+ echo "[codex-agent] Auto-finish skipped for '${worktree_branch}' (no mergeable remote context)." >&2
814
+ elif auto_commit_worktree_changes "$worktree_path" "$worktree_branch"; then
768
815
  if run_finish_flow "$worktree_path" "$worktree_branch"; then
769
816
  auto_finish_completed=1
770
817
  echo "[codex-agent] Auto-finish completed for '${worktree_branch}'."