diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js
index 8d8d52b..8a33b4d 100755
--- a/bin/multiagent-safety.js
+++ b/bin/multiagent-safety.js
@@ -89,6 +89,7 @@ const TEMPLATE_ROOT = path.resolve(__dirname, '..', 'templates');
const TEMPLATE_FILES = [
'scripts/agent-branch-start.sh',
'scripts/agent-branch-finish.sh',
+ 'scripts/agent-branch-merge.sh',
'scripts/codex-agent.sh',
'scripts/guardex-docker-loader.sh',
'scripts/review-bot-watch.sh',
@@ -112,6 +113,7 @@ const TEMPLATE_FILES = [
const REQUIRED_WORKFLOW_FILES = [
'scripts/agent-branch-start.sh',
'scripts/agent-branch-finish.sh',
+ 'scripts/agent-branch-merge.sh',
'scripts/guardex-docker-loader.sh',
'scripts/agent-worktree-prune.sh',
'scripts/agent-file-locks.py',
@@ -126,6 +128,7 @@ const REQUIRED_PACKAGE_SCRIPTS = {
'agent:codex': 'bash ./scripts/codex-agent.sh',
'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
+ 'agent:branch:merge': 'bash ./scripts/agent-branch-merge.sh',
'agent:cleanup': 'gx cleanup',
'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
@@ -149,6 +152,7 @@ const REQUIRED_PACKAGE_SCRIPTS = {
const EXECUTABLE_RELATIVE_PATHS = new Set([
'scripts/agent-branch-start.sh',
'scripts/agent-branch-finish.sh',
+ 'scripts/agent-branch-merge.sh',
'scripts/codex-agent.sh',
'scripts/guardex-docker-loader.sh',
'scripts/review-bot-watch.sh',
@@ -171,6 +175,7 @@ const CRITICAL_GUARDRAIL_PATHS = new Set([
'.githooks/post-checkout',
'scripts/agent-branch-start.sh',
'scripts/agent-branch-finish.sh',
+ 'scripts/agent-branch-merge.sh',
'scripts/agent-worktree-prune.sh',
'scripts/codex-agent.sh',
'scripts/agent-file-locks.py',
@@ -233,6 +238,7 @@ const SUGGESTIBLE_COMMANDS = [
'setup',
'doctor',
'agents',
+ 'merge',
'finish',
'report',
'protect',
@@ -257,6 +263,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
['setup', 'Install, repair, and verify guardrails (flags: --repair, --install-only, --target)'],
['doctor', 'Repair drift + verify (auto-sandboxes on protected main)'],
['protect', 'Manage protected branches (list/add/remove/set/reset)'],
+ ['merge', 'Create/reuse an integration lane and merge overlapping agent branches'],
['sync', 'Sync agent branches with origin/'],
['finish', 'Commit + PR + merge completed agent branches (--all, --branch)'],
['cleanup', 'Prune merged/stale agent branches and worktrees'],
@@ -304,13 +311,14 @@ const AI_SETUP_PROMPT = `GitGuardex (gx) setup checklist for Codex/Claude in thi
3) Repair: gx doctor
4) Task loop: bash scripts/codex-agent.sh "" ""
or branch-start -> python3 scripts/agent-file-locks.py claim -> branch-finish
-5) Finish: gx finish --all
-6) Cleanup: gx cleanup
-7) OpenSpec: /opsx:propose -> /opsx:apply -> /opsx:archive
-8) Optional: gx protect add release staging
-9) Optional: gx sync --check && gx sync
-10) Review bot: install https://github.com/apps/cr-gpt + set OPENAI_API_KEY
-11) Fork sync: install https://github.com/apps/pull + cp .github/pull.yml.example .github/pull.yml
+5) Integrate: gx merge --branch --branch
+6) Finish: gx finish --all
+7) Cleanup: gx cleanup
+8) OpenSpec: /opsx:propose -> /opsx:apply -> /opsx:archive
+9) Optional: gx protect add release staging
+10) Optional: gx sync --check && gx sync
+11) Review bot: install https://github.com/apps/cr-gpt + set OPENAI_API_KEY
+12) Fork sync: install https://github.com/apps/pull + cp .github/pull.yml.example .github/pull.yml
`;
const AI_SETUP_COMMANDS = `npm i -g @imdeadpool/guardex
@@ -319,6 +327,7 @@ gx setup
gx doctor
bash scripts/codex-agent.sh "" ""
python3 scripts/agent-file-locks.py claim --branch ""
+gx merge --branch "" --branch ""
gx finish --all
gx cleanup
gx protect add release staging
@@ -3543,6 +3552,82 @@ function parseCleanupArgs(rawArgs) {
return options;
}
+function parseMergeArgs(rawArgs) {
+ const options = {
+ target: process.cwd(),
+ base: '',
+ into: '',
+ branches: [],
+ task: '',
+ agent: '',
+ };
+
+ for (let index = 0; index < rawArgs.length; index += 1) {
+ const arg = rawArgs[index];
+ if (arg === '--target') {
+ const next = rawArgs[index + 1];
+ if (!next) {
+ throw new Error('--target requires a path value');
+ }
+ options.target = next;
+ index += 1;
+ continue;
+ }
+ if (arg === '--base') {
+ const next = rawArgs[index + 1];
+ if (!next) {
+ throw new Error('--base requires a branch value');
+ }
+ options.base = next;
+ index += 1;
+ continue;
+ }
+ if (arg === '--into') {
+ const next = rawArgs[index + 1];
+ if (!next) {
+ throw new Error('--into requires an agent/* branch value');
+ }
+ options.into = next;
+ index += 1;
+ continue;
+ }
+ if (arg === '--branch') {
+ const next = rawArgs[index + 1];
+ if (!next) {
+ throw new Error('--branch requires an agent/* branch value');
+ }
+ options.branches.push(next);
+ index += 1;
+ continue;
+ }
+ if (arg === '--task') {
+ const next = rawArgs[index + 1];
+ if (!next) {
+ throw new Error('--task requires a task value');
+ }
+ options.task = next;
+ index += 1;
+ continue;
+ }
+ if (arg === '--agent') {
+ const next = rawArgs[index + 1];
+ if (!next) {
+ throw new Error('--agent requires an agent value');
+ }
+ options.agent = next;
+ index += 1;
+ continue;
+ }
+ throw new Error(`Unknown option: ${arg}`);
+ }
+
+ if (options.branches.length === 0) {
+ throw new Error('merge requires at least one --branch input');
+ }
+
+ return options;
+}
+
function parseFinishArgs(rawArgs) {
const options = {
target: process.cwd(),
@@ -6587,6 +6672,46 @@ function cleanup(rawArgs) {
process.exitCode = 0;
}
+function merge(rawArgs) {
+ const options = parseMergeArgs(rawArgs);
+ const repoRoot = resolveRepoRoot(options.target);
+ const mergeScript = path.join(repoRoot, 'scripts', 'agent-branch-merge.sh');
+
+ if (!fs.existsSync(mergeScript)) {
+ throw new Error(`Missing merge script: ${mergeScript}. Run '${SHORT_TOOL_NAME} setup' first.`);
+ }
+
+ const args = [mergeScript];
+ if (options.base) {
+ args.push('--base', options.base);
+ }
+ if (options.into) {
+ args.push('--into', options.into);
+ }
+ if (options.task) {
+ args.push('--task', options.task);
+ }
+ if (options.agent) {
+ args.push('--agent', options.agent);
+ }
+ for (const branch of options.branches) {
+ args.push('--branch', branch);
+ }
+
+ const mergeResult = run('bash', args, { cwd: repoRoot, stdio: 'pipe' });
+ if (mergeResult.stdout) {
+ process.stdout.write(mergeResult.stdout);
+ }
+ if (mergeResult.stderr) {
+ process.stderr.write(mergeResult.stderr);
+ }
+ if (mergeResult.status !== 0) {
+ throw new Error(`merge command failed with status ${mergeResult.status}`);
+ }
+
+ process.exitCode = 0;
+}
+
function finish(rawArgs) {
const options = parseFinishArgs(rawArgs);
const repoRoot = resolveRepoRoot(options.target);
@@ -7073,6 +7198,7 @@ function main() {
if (command === 'prompt') return prompt(rest);
if (command === 'doctor') return doctor(rest);
if (command === 'agents') return agents(rest);
+ if (command === 'merge') return merge(rest);
if (command === 'finish') return finish(rest);
if (command === 'report') return report(rest);
if (command === 'protect') return protect(rest);
diff --git a/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/.openspec.yaml b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/.openspec.yaml
new file mode 100644
index 0000000..4b8c565
--- /dev/null
+++ b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-04-21
diff --git a/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/proposal.md b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/proposal.md
new file mode 100644
index 0000000..aeca6ba
--- /dev/null
+++ b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/proposal.md
@@ -0,0 +1,19 @@
+## Why
+
+- GitGuardex currently finishes one `agent/*` branch into the base branch at a time, but it does not provide an integration workflow for the common case where multiple agent worktrees touched the same implementation files.
+- In that situation users end up staring at several parallel worktrees with overlapping edits and no first-class Guardex command that creates the right integration branch/worktree, reports overlap, and gives a safe place to resolve conflicts.
+- OpenSpec already treats implementation as owner/helper lanes with durable artifacts, so the merge workflow should preserve that model instead of pushing users back to ad hoc manual git work on the protected base.
+
+## What Changes
+
+- Add a first-class `gx merge` command plus a managed `scripts/agent-branch-merge.sh` workflow.
+- Let the workflow either create a fresh integration `agent/*` branch/worktree from the configured base branch or merge helper branches into an existing owner branch via `--into`.
+- Report overlapping changed files across the requested source branches before merging so users can see where collisions are expected, especially inside shared implementation files and OpenSpec surfaces.
+- Merge branches in explicit order, stop on conflicts without touching the protected base branch, and print resumable instructions that keep conflict resolution inside the integration worktree.
+- Ship the new script through the setup/templates/package metadata path so downstream repos get the same capability.
+
+## Impact
+
+- Affected surfaces: `gx` CLI command catalog, managed workflow scripts/templates, setup/doctor script expectations, and regression tests.
+- Risk: merge automation is sensitive to dirty worktrees and stale branches, so the implementation needs strict preflight checks and clear conflict-stop behavior.
+- Rollout: local CLI/script addition only; no data migration.
diff --git a/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/specs/overlapping-agent-merge/spec.md b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/specs/overlapping-agent-merge/spec.md
new file mode 100644
index 0000000..2464106
--- /dev/null
+++ b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/specs/overlapping-agent-merge/spec.md
@@ -0,0 +1,42 @@
+## ADDED Requirements
+
+### Requirement: integration merge runs inside an agent worktree
+GitGuardex SHALL merge overlapping `agent/*` branches inside an integration `agent/*` branch/worktree instead of merging directly on the protected base branch.
+
+#### Scenario: create a fresh integration lane
+- **WHEN** a user runs `gx merge` with one or more `--branch agent/...` inputs and no `--into`
+- **THEN** the system creates a new integration `agent/*` branch/worktree from the configured base branch
+- **AND** all requested merges run inside that integration worktree
+- **AND** the command prints the integration branch and worktree path.
+
+#### Scenario: reuse an existing owner lane
+- **WHEN** a user runs `gx merge --into ` with additional source branches
+- **THEN** the system reuses that owner branch as the merge target
+- **AND** it refuses to proceed if the target worktree has uncommitted changes or an in-progress merge operation.
+
+### Requirement: overlapping file edits are reported before merge
+GitGuardex SHALL detect and report files changed by more than one requested source branch before applying the merges.
+
+#### Scenario: overlapping implementation files exist
+- **WHEN** two or more requested source branches changed the same file relative to the merge base
+- **THEN** the command prints each overlapping file
+- **AND** it identifies the source branches that changed that file
+- **AND** it still allows the user to continue into the integration lane unless another hard preflight check fails.
+
+### Requirement: conflicts stop with resumable guidance
+GitGuardex SHALL stop on merge conflicts inside the integration worktree and provide resumable next-step guidance without mutating the protected base branch.
+
+#### Scenario: sequential merge hits a conflict
+- **WHEN** `gx merge` successfully merges earlier source branches and then encounters a conflict on a later source branch
+- **THEN** the command exits non-zero
+- **AND** it prints the target integration branch/worktree, the source branch that conflicted, and the conflicting files
+- **AND** it tells the user how to resolve or abort the conflict inside the integration worktree
+- **AND** it prints the remaining branches so the merge sequence can be resumed intentionally afterward.
+
+### Requirement: setup-managed repos receive the merge workflow
+GitGuardex setup/doctor SHALL install the managed merge workflow files and package script entry needed to run `gx merge`.
+
+#### Scenario: setup bootstraps a repo
+- **WHEN** `gx setup` or `gx doctor --repair` installs managed workflow files
+- **THEN** the repo contains `scripts/agent-branch-merge.sh`
+- **AND** the repo package scripts include a stable merge entry point for the managed workflow.
diff --git a/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/tasks.md b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/tasks.md
new file mode 100644
index 0000000..2468cf1
--- /dev/null
+++ b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/tasks.md
@@ -0,0 +1,25 @@
+## 1. Specification
+
+- [x] 1.1 Finalize acceptance criteria for the overlapping-agent merge workflow.
+- [x] 1.2 Define normative requirements for integration-branch creation, overlap reporting, and conflict-stop behavior.
+
+## 2. Implementation
+
+- [x] 2.1 Add a managed `agent-branch-merge` script that can create or reuse an integration worktree and merge multiple `agent/*` branches in order.
+- [x] 2.2 Add `gx merge` CLI wiring, package metadata, and template/setup propagation for the new workflow.
+- [x] 2.3 Keep the protected base branch untouched while merging and print resumable instructions for conflict resolution.
+
+## 3. Verification
+
+- [x] 3.1 Add/update focused regression coverage for clean merges, overlap reporting, and conflict-stop behavior.
+- [ ] 3.2 Run `npm test`. BLOCKED: full suite produced early passing output but then stayed silent/hung in this environment; focused `node --test test/merge-workflow.test.js` passed.
+- [x] 3.3 Run `node --check bin/multiagent-safety.js`.
+- [x] 3.4 Run `openspec validate agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28 --type change --strict`.
+- [x] 3.5 Run `openspec validate --specs`.
+- [x] 3.6 Run `git diff --check`.
+
+## 4. Completion
+
+- [ ] 4.1 Finish the agent branch via PR merge + cleanup (`gx finish --via-pr --wait-for-merge --cleanup` or `bash scripts/agent-branch-finish.sh --branch --base --via-pr --wait-for-merge --cleanup`).
+- [ ] 4.2 Record PR URL + final `MERGED` state in the completion handoff.
+- [ ] 4.3 Confirm sandbox cleanup (`git worktree list`, `git branch -a`) or capture a `BLOCKED:` handoff if merge/cleanup is pending.
diff --git a/package.json b/package.json
index a8dfd04..20c1daf 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"agent:codex": "bash ./scripts/codex-agent.sh",
"agent:branch:start": "bash ./scripts/agent-branch-start.sh",
"agent:branch:finish": "bash ./scripts/agent-branch-finish.sh",
+ "agent:branch:merge": "bash ./scripts/agent-branch-merge.sh",
"agent:cleanup": "gx cleanup",
"agent:hooks:install": "bash ./scripts/install-agent-git-hooks.sh",
"agent:locks:claim": "python3 ./scripts/agent-file-locks.py claim",
diff --git a/scripts/agent-branch-merge.sh b/scripts/agent-branch-merge.sh
new file mode 100755
index 0000000..c8ab622
--- /dev/null
+++ b/scripts/agent-branch-merge.sh
@@ -0,0 +1,421 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+BASE_BRANCH=""
+BASE_BRANCH_EXPLICIT=0
+TARGET_BRANCH=""
+TASK_NAME=""
+AGENT_NAME="${GUARDEX_MERGE_AGENT_NAME:-codex}"
+declare -a SOURCE_BRANCHES=()
+
+usage() {
+ cat <<'EOF'
+Usage: scripts/agent-branch-merge.sh --branch [--branch ...] [--into ] [--task ] [--agent ] [--base ]
+
+Examples:
+ bash scripts/agent-branch-merge.sh --branch agent/codex/ui-a --branch agent/codex/ui-b
+ bash scripts/agent-branch-merge.sh --into agent/codex/owner-lane --branch agent/codex/helper-a --branch agent/codex/helper-b
+EOF
+}
+
+sanitize_slug() {
+ local raw="$1"
+ local fallback="${2:-merge-agent-branches}"
+ local slug
+ slug="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')"
+ if [[ -z "$slug" ]]; then
+ slug="$fallback"
+ fi
+ printf '%s' "$slug"
+}
+
+resolve_base_branch() {
+ local repo="$1"
+ local explicit_target="$2"
+ local configured=""
+ local branch_base=""
+
+ if [[ -n "$explicit_target" ]]; then
+ branch_base="$(git -C "$repo" config --get "branch.${explicit_target}.guardexBase" || true)"
+ if [[ -n "$branch_base" ]]; then
+ printf '%s' "$branch_base"
+ return 0
+ fi
+ fi
+
+ configured="$(git -C "$repo" config --get multiagent.baseBranch || true)"
+ if [[ -n "$configured" ]]; then
+ printf '%s' "$configured"
+ return 0
+ fi
+
+ for fallback in dev main master; do
+ if git -C "$repo" show-ref --verify --quiet "refs/heads/${fallback}" \
+ || git -C "$repo" show-ref --verify --quiet "refs/remotes/origin/${fallback}"; then
+ printf '%s' "$fallback"
+ return 0
+ fi
+ done
+
+ printf '%s' "dev"
+}
+
+get_worktree_for_branch() {
+ local repo="$1"
+ local branch="$2"
+ git -C "$repo" worktree list --porcelain | awk -v target="refs/heads/${branch}" '
+ $1 == "worktree" { wt = $2 }
+ $1 == "branch" && $2 == target { print wt; exit }
+ '
+}
+
+is_clean_worktree() {
+ local wt="$1"
+ git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \
+ && git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \
+ && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]]
+}
+
+has_in_progress_git_op() {
+ local wt="$1"
+ local git_dir=""
+ git_dir="$(git -C "$wt" rev-parse --git-dir 2>/dev/null || true)"
+ if [[ -z "$git_dir" ]]; then
+ return 1
+ fi
+ if [[ "$git_dir" != /* ]]; then
+ git_dir="$(cd "$wt/$git_dir" 2>/dev/null && pwd -P || true)"
+ fi
+ if [[ -z "$git_dir" ]]; then
+ return 1
+ fi
+ [[ -f "${git_dir}/MERGE_HEAD" || -d "${git_dir}/rebase-merge" || -d "${git_dir}/rebase-apply" ]]
+}
+
+select_unique_worktree_path() {
+ local root="$1"
+ local name="$2"
+ local candidate="${root}/${name}"
+ local suffix=2
+ while [[ -e "$candidate" ]]; do
+ candidate="${root}/${name}-${suffix}"
+ suffix=$((suffix + 1))
+ done
+ printf '%s' "$candidate"
+}
+
+branch_exists() {
+ local repo="$1"
+ local branch="$2"
+ git -C "$repo" show-ref --verify --quiet "refs/heads/${branch}"
+}
+
+branch_is_agent_lane() {
+ local branch="$1"
+ [[ "$branch" == agent/* ]]
+}
+
+array_contains() {
+ local needle="$1"
+ shift || true
+ local item
+ for item in "$@"; do
+ if [[ "$item" == "$needle" ]]; then
+ return 0
+ fi
+ done
+ return 1
+}
+
+collect_branch_files() {
+ local repo="$1"
+ local base_ref="$2"
+ local branch="$3"
+ git -C "$repo" diff --name-only "${base_ref}...${branch}" -- . ":(exclude).omx/state/agent-file-locks.json" 2>/dev/null || true
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --base)
+ BASE_BRANCH="${2:-}"
+ BASE_BRANCH_EXPLICIT=1
+ shift 2
+ ;;
+ --into)
+ TARGET_BRANCH="${2:-}"
+ shift 2
+ ;;
+ --branch)
+ SOURCE_BRANCHES+=("${2:-}")
+ shift 2
+ ;;
+ --task)
+ TASK_NAME="${2:-}"
+ shift 2
+ ;;
+ --agent)
+ AGENT_NAME="${2:-codex}"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "[agent-branch-merge] Unknown argument: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ echo "[agent-branch-merge] Not inside a git repository." >&2
+ exit 1
+fi
+
+repo_root="$(git rev-parse --show-toplevel)"
+common_git_dir_raw="$(git -C "$repo_root" rev-parse --git-common-dir)"
+if [[ "$common_git_dir_raw" == /* ]]; then
+ common_git_dir="$common_git_dir_raw"
+else
+ common_git_dir="$(cd "$repo_root/$common_git_dir_raw" && pwd -P)"
+fi
+repo_common_root="$(cd "$common_git_dir/.." && pwd -P)"
+agent_worktree_root="${repo_common_root}/.omx/agent-worktrees"
+mkdir -p "$agent_worktree_root"
+
+if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
+ echo "[agent-branch-merge] --base requires a branch value." >&2
+ exit 1
+fi
+
+if [[ -z "$TARGET_BRANCH" && "${#SOURCE_BRANCHES[@]}" -lt 1 ]]; then
+ echo "[agent-branch-merge] Provide at least one --branch source lane." >&2
+ exit 1
+fi
+
+if [[ -n "$TARGET_BRANCH" ]] && ! branch_is_agent_lane "$TARGET_BRANCH"; then
+ echo "[agent-branch-merge] --into must reference an agent/* branch: ${TARGET_BRANCH}" >&2
+ exit 1
+fi
+
+deduped_sources=()
+for branch in "${SOURCE_BRANCHES[@]}"; do
+ if [[ -z "$branch" ]]; then
+ echo "[agent-branch-merge] --branch requires an agent/* branch value." >&2
+ exit 1
+ fi
+ if ! branch_is_agent_lane "$branch"; then
+ echo "[agent-branch-merge] Source branch must be agent/*: ${branch}" >&2
+ exit 1
+ fi
+ if ! branch_exists "$repo_root" "$branch"; then
+ echo "[agent-branch-merge] Local source branch not found: ${branch}" >&2
+ exit 1
+ fi
+ if ! array_contains "$branch" "${deduped_sources[@]}"; then
+ deduped_sources+=("$branch")
+ fi
+done
+SOURCE_BRANCHES=("${deduped_sources[@]}")
+
+if [[ "${#SOURCE_BRANCHES[@]}" -eq 0 ]]; then
+ echo "[agent-branch-merge] No unique source branches were provided." >&2
+ exit 1
+fi
+
+if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
+ BASE_BRANCH="$(resolve_base_branch "$repo_root" "$TARGET_BRANCH")"
+fi
+
+if [[ -z "$BASE_BRANCH" ]]; then
+ echo "[agent-branch-merge] Unable to resolve a base branch." >&2
+ exit 1
+fi
+
+start_ref="$BASE_BRANCH"
+if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
+ git -C "$repo_root" fetch origin "$BASE_BRANCH" --quiet
+ start_ref="origin/${BASE_BRANCH}"
+elif ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then
+ echo "[agent-branch-merge] Base branch not found locally or on origin: ${BASE_BRANCH}" >&2
+ exit 1
+fi
+
+target_worktree=""
+target_created=0
+
+if [[ -z "$TARGET_BRANCH" ]]; then
+ if [[ -z "$TASK_NAME" ]]; then
+ first_hint="$(printf '%s' "${SOURCE_BRANCHES[0]}" | sed -E 's#^agent/[^/]+/##; s#^agent/##')"
+ source_count="${#SOURCE_BRANCHES[@]}"
+ if [[ "$source_count" -gt 1 ]]; then
+ TASK_NAME="$(sanitize_slug "merge-${first_hint}-and-$((source_count - 1))-more" "merge-agent-branches")"
+ else
+ TASK_NAME="$(sanitize_slug "merge-${first_hint}" "merge-agent-branches")"
+ fi
+ else
+ TASK_NAME="$(sanitize_slug "$TASK_NAME" "merge-agent-branches")"
+ fi
+
+ start_output=""
+ if ! start_output="$(
+ cd "$repo_root"
+ env GUARDEX_OPENSPEC_AUTO_INIT=1 bash "scripts/agent-branch-start.sh" "$TASK_NAME" "$AGENT_NAME" "$BASE_BRANCH" 2>&1
+ )"; then
+ printf '%s\n' "$start_output" >&2
+ exit 1
+ fi
+
+ printf '%s\n' "$start_output"
+ TARGET_BRANCH="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Created branch: //p' | head -n 1)"
+ target_worktree="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | head -n 1)"
+ if [[ -z "$TARGET_BRANCH" || -z "$target_worktree" ]]; then
+ echo "[agent-branch-merge] Unable to parse target branch/worktree from agent-branch-start output." >&2
+ exit 1
+ fi
+ target_created=1
+else
+ if ! branch_exists "$repo_root" "$TARGET_BRANCH"; then
+ echo "[agent-branch-merge] Target branch not found: ${TARGET_BRANCH}" >&2
+ exit 1
+ fi
+
+ target_worktree="$(get_worktree_for_branch "$repo_root" "$TARGET_BRANCH")"
+ if [[ -z "$target_worktree" ]]; then
+ target_worktree="$(select_unique_worktree_path "$agent_worktree_root" "${TARGET_BRANCH//\//__}")"
+ git -C "$repo_root" worktree add "$target_worktree" "$TARGET_BRANCH" >/dev/null
+ target_created=1
+ echo "[agent-branch-merge] Attached worktree for target branch '${TARGET_BRANCH}': ${target_worktree}"
+ fi
+fi
+
+if [[ "$TARGET_BRANCH" == "$BASE_BRANCH" ]]; then
+ echo "[agent-branch-merge] Target branch must not equal the protected base branch '${BASE_BRANCH}'." >&2
+ exit 1
+fi
+
+if ! is_clean_worktree "$target_worktree"; then
+ if [[ "$target_created" -eq 1 ]]; then
+ echo "[agent-branch-merge] Target worktree has freshly generated scaffold changes; continuing inside the new integration lane."
+ else
+ echo "[agent-branch-merge] Target worktree is not clean: ${target_worktree}" >&2
+ echo "[agent-branch-merge] Commit, stash, or discard local changes before merging agent lanes." >&2
+ exit 1
+ fi
+fi
+
+if has_in_progress_git_op "$target_worktree"; then
+ echo "[agent-branch-merge] Target worktree has an in-progress merge/rebase: ${target_worktree}" >&2
+ echo "[agent-branch-merge] Resolve or abort that git operation before running the merge workflow." >&2
+ exit 1
+fi
+
+for source_branch in "${SOURCE_BRANCHES[@]}"; do
+ if [[ "$source_branch" == "$TARGET_BRANCH" ]]; then
+ echo "[agent-branch-merge] Source branch list includes the target branch: ${source_branch}" >&2
+ exit 1
+ fi
+ source_worktree="$(get_worktree_for_branch "$repo_root" "$source_branch")"
+ if [[ -n "$source_worktree" ]] && ! is_clean_worktree "$source_worktree"; then
+ echo "[agent-branch-merge] Source worktree is not clean for '${source_branch}': ${source_worktree}" >&2
+ echo "[agent-branch-merge] Commit or stash source-lane changes before integration." >&2
+ exit 1
+ fi
+done
+
+pending_branches=()
+for source_branch in "${SOURCE_BRANCHES[@]}"; do
+ if git -C "$repo_root" merge-base --is-ancestor "$source_branch" "$TARGET_BRANCH" >/dev/null 2>&1; then
+ echo "[agent-branch-merge] Skipping '${source_branch}' because it is already integrated into '${TARGET_BRANCH}'."
+ continue
+ fi
+ pending_branches+=("$source_branch")
+done
+
+if [[ "${#pending_branches[@]}" -eq 0 ]]; then
+ echo "[agent-branch-merge] No pending source branches remain for target '${TARGET_BRANCH}'."
+ echo "[agent-branch-merge] Target worktree: ${target_worktree}"
+ exit 0
+fi
+
+declare -A file_to_branches=()
+declare -a overlap_files=()
+for source_branch in "${pending_branches[@]}"; do
+ while IFS= read -r changed_file; do
+ [[ -z "$changed_file" ]] && continue
+ existing="${file_to_branches[$changed_file]:-}"
+ if [[ -z "$existing" ]]; then
+ file_to_branches["$changed_file"]="$source_branch"
+ continue
+ fi
+ if [[ ",${existing}," == *",${source_branch},"* ]]; then
+ continue
+ fi
+ file_to_branches["$changed_file"]="${existing},${source_branch}"
+ if ! array_contains "$changed_file" "${overlap_files[@]}"; then
+ overlap_files+=("$changed_file")
+ fi
+ done < <(collect_branch_files "$repo_root" "$start_ref" "$source_branch")
+done
+
+echo "[agent-branch-merge] Target branch: ${TARGET_BRANCH}"
+echo "[agent-branch-merge] Target worktree: ${target_worktree}"
+echo "[agent-branch-merge] Base branch: ${BASE_BRANCH} (${start_ref})"
+echo "[agent-branch-merge] Merge order: ${pending_branches[*]}"
+
+if [[ "${#overlap_files[@]}" -gt 0 ]]; then
+ echo "[agent-branch-merge] Overlapping changed files detected across requested branches:"
+ for overlap_file in "${overlap_files[@]}"; do
+ branches_csv="${file_to_branches[$overlap_file]}"
+ branches_display="$(printf '%s' "$branches_csv" | sed 's/,/, /g')"
+ echo " - ${overlap_file} <- ${branches_display}"
+ done
+else
+ echo "[agent-branch-merge] No overlapping changed files detected across requested branches."
+fi
+
+for index in "${!pending_branches[@]}"; do
+ source_branch="${pending_branches[$index]}"
+ echo "[agent-branch-merge] Merging '${source_branch}' into '${TARGET_BRANCH}'..."
+ if git -C "$target_worktree" merge --no-ff --no-edit "$source_branch"; then
+ echo "[agent-branch-merge] Merged '${source_branch}'."
+ continue
+ fi
+
+ conflict_files="$(git -C "$target_worktree" diff --name-only --diff-filter=U || true)"
+ echo "[agent-branch-merge] Merge conflict detected while merging '${source_branch}' into '${TARGET_BRANCH}'." >&2
+ echo "[agent-branch-merge] Target worktree: ${target_worktree}" >&2
+ if [[ -n "$conflict_files" ]]; then
+ echo "[agent-branch-merge] Conflicting files:" >&2
+ while IFS= read -r conflict_file; do
+ [[ -n "$conflict_file" ]] && echo " - ${conflict_file}" >&2
+ done <<< "$conflict_files"
+ fi
+ echo "[agent-branch-merge] Resolve or abort inside the integration worktree:" >&2
+ echo " cd \"${target_worktree}\"" >&2
+ echo " git status" >&2
+ echo " git add && git commit" >&2
+ echo " # or: git merge --abort" >&2
+
+ remaining_branches=("${pending_branches[@]:$((index + 1))}")
+ if [[ "${#remaining_branches[@]}" -gt 0 ]]; then
+ echo "[agent-branch-merge] Remaining branches:" >&2
+ for remaining in "${remaining_branches[@]}"; do
+ echo " - ${remaining}" >&2
+ done
+ resume_cmd="gx merge --into ${TARGET_BRANCH} --base ${BASE_BRANCH}"
+ for remaining in "${remaining_branches[@]}"; do
+ resume_cmd="${resume_cmd} --branch ${remaining}"
+ done
+ echo "[agent-branch-merge] Resume after resolving with: ${resume_cmd}" >&2
+ fi
+ exit 1
+done
+
+echo "[agent-branch-merge] Merge sequence complete for '${TARGET_BRANCH}'."
+if [[ "$target_created" -eq 1 ]]; then
+ echo "[agent-branch-merge] Review and verify in '${target_worktree}', then finish the integration branch when ready."
+fi
+echo "[agent-branch-merge] Next step: bash scripts/agent-branch-finish.sh --branch \"${TARGET_BRANCH}\" --base \"${BASE_BRANCH}\" --via-pr --wait-for-merge --cleanup"
diff --git a/templates/scripts/agent-branch-merge.sh b/templates/scripts/agent-branch-merge.sh
new file mode 100755
index 0000000..c8ab622
--- /dev/null
+++ b/templates/scripts/agent-branch-merge.sh
@@ -0,0 +1,421 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+BASE_BRANCH=""
+BASE_BRANCH_EXPLICIT=0
+TARGET_BRANCH=""
+TASK_NAME=""
+AGENT_NAME="${GUARDEX_MERGE_AGENT_NAME:-codex}"
+declare -a SOURCE_BRANCHES=()
+
+usage() {
+ cat <<'EOF'
+Usage: scripts/agent-branch-merge.sh --branch [--branch ...] [--into ] [--task ] [--agent ] [--base ]
+
+Examples:
+ bash scripts/agent-branch-merge.sh --branch agent/codex/ui-a --branch agent/codex/ui-b
+ bash scripts/agent-branch-merge.sh --into agent/codex/owner-lane --branch agent/codex/helper-a --branch agent/codex/helper-b
+EOF
+}
+
+sanitize_slug() {
+ local raw="$1"
+ local fallback="${2:-merge-agent-branches}"
+ local slug
+ slug="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')"
+ if [[ -z "$slug" ]]; then
+ slug="$fallback"
+ fi
+ printf '%s' "$slug"
+}
+
+resolve_base_branch() {
+ local repo="$1"
+ local explicit_target="$2"
+ local configured=""
+ local branch_base=""
+
+ if [[ -n "$explicit_target" ]]; then
+ branch_base="$(git -C "$repo" config --get "branch.${explicit_target}.guardexBase" || true)"
+ if [[ -n "$branch_base" ]]; then
+ printf '%s' "$branch_base"
+ return 0
+ fi
+ fi
+
+ configured="$(git -C "$repo" config --get multiagent.baseBranch || true)"
+ if [[ -n "$configured" ]]; then
+ printf '%s' "$configured"
+ return 0
+ fi
+
+ for fallback in dev main master; do
+ if git -C "$repo" show-ref --verify --quiet "refs/heads/${fallback}" \
+ || git -C "$repo" show-ref --verify --quiet "refs/remotes/origin/${fallback}"; then
+ printf '%s' "$fallback"
+ return 0
+ fi
+ done
+
+ printf '%s' "dev"
+}
+
+get_worktree_for_branch() {
+ local repo="$1"
+ local branch="$2"
+ git -C "$repo" worktree list --porcelain | awk -v target="refs/heads/${branch}" '
+ $1 == "worktree" { wt = $2 }
+ $1 == "branch" && $2 == target { print wt; exit }
+ '
+}
+
+is_clean_worktree() {
+ local wt="$1"
+ git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \
+ && git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \
+ && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]]
+}
+
+has_in_progress_git_op() {
+ local wt="$1"
+ local git_dir=""
+ git_dir="$(git -C "$wt" rev-parse --git-dir 2>/dev/null || true)"
+ if [[ -z "$git_dir" ]]; then
+ return 1
+ fi
+ if [[ "$git_dir" != /* ]]; then
+ git_dir="$(cd "$wt/$git_dir" 2>/dev/null && pwd -P || true)"
+ fi
+ if [[ -z "$git_dir" ]]; then
+ return 1
+ fi
+ [[ -f "${git_dir}/MERGE_HEAD" || -d "${git_dir}/rebase-merge" || -d "${git_dir}/rebase-apply" ]]
+}
+
+select_unique_worktree_path() {
+ local root="$1"
+ local name="$2"
+ local candidate="${root}/${name}"
+ local suffix=2
+ while [[ -e "$candidate" ]]; do
+ candidate="${root}/${name}-${suffix}"
+ suffix=$((suffix + 1))
+ done
+ printf '%s' "$candidate"
+}
+
+branch_exists() {
+ local repo="$1"
+ local branch="$2"
+ git -C "$repo" show-ref --verify --quiet "refs/heads/${branch}"
+}
+
+branch_is_agent_lane() {
+ local branch="$1"
+ [[ "$branch" == agent/* ]]
+}
+
+array_contains() {
+ local needle="$1"
+ shift || true
+ local item
+ for item in "$@"; do
+ if [[ "$item" == "$needle" ]]; then
+ return 0
+ fi
+ done
+ return 1
+}
+
+collect_branch_files() {
+ local repo="$1"
+ local base_ref="$2"
+ local branch="$3"
+ git -C "$repo" diff --name-only "${base_ref}...${branch}" -- . ":(exclude).omx/state/agent-file-locks.json" 2>/dev/null || true
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --base)
+ BASE_BRANCH="${2:-}"
+ BASE_BRANCH_EXPLICIT=1
+ shift 2
+ ;;
+ --into)
+ TARGET_BRANCH="${2:-}"
+ shift 2
+ ;;
+ --branch)
+ SOURCE_BRANCHES+=("${2:-}")
+ shift 2
+ ;;
+ --task)
+ TASK_NAME="${2:-}"
+ shift 2
+ ;;
+ --agent)
+ AGENT_NAME="${2:-codex}"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "[agent-branch-merge] Unknown argument: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ echo "[agent-branch-merge] Not inside a git repository." >&2
+ exit 1
+fi
+
+repo_root="$(git rev-parse --show-toplevel)"
+common_git_dir_raw="$(git -C "$repo_root" rev-parse --git-common-dir)"
+if [[ "$common_git_dir_raw" == /* ]]; then
+ common_git_dir="$common_git_dir_raw"
+else
+ common_git_dir="$(cd "$repo_root/$common_git_dir_raw" && pwd -P)"
+fi
+repo_common_root="$(cd "$common_git_dir/.." && pwd -P)"
+agent_worktree_root="${repo_common_root}/.omx/agent-worktrees"
+mkdir -p "$agent_worktree_root"
+
+if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
+ echo "[agent-branch-merge] --base requires a branch value." >&2
+ exit 1
+fi
+
+if [[ -z "$TARGET_BRANCH" && "${#SOURCE_BRANCHES[@]}" -lt 1 ]]; then
+ echo "[agent-branch-merge] Provide at least one --branch source lane." >&2
+ exit 1
+fi
+
+if [[ -n "$TARGET_BRANCH" ]] && ! branch_is_agent_lane "$TARGET_BRANCH"; then
+ echo "[agent-branch-merge] --into must reference an agent/* branch: ${TARGET_BRANCH}" >&2
+ exit 1
+fi
+
+deduped_sources=()
+for branch in "${SOURCE_BRANCHES[@]}"; do
+ if [[ -z "$branch" ]]; then
+ echo "[agent-branch-merge] --branch requires an agent/* branch value." >&2
+ exit 1
+ fi
+ if ! branch_is_agent_lane "$branch"; then
+ echo "[agent-branch-merge] Source branch must be agent/*: ${branch}" >&2
+ exit 1
+ fi
+ if ! branch_exists "$repo_root" "$branch"; then
+ echo "[agent-branch-merge] Local source branch not found: ${branch}" >&2
+ exit 1
+ fi
+ if ! array_contains "$branch" "${deduped_sources[@]}"; then
+ deduped_sources+=("$branch")
+ fi
+done
+SOURCE_BRANCHES=("${deduped_sources[@]}")
+
+if [[ "${#SOURCE_BRANCHES[@]}" -eq 0 ]]; then
+ echo "[agent-branch-merge] No unique source branches were provided." >&2
+ exit 1
+fi
+
+if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
+ BASE_BRANCH="$(resolve_base_branch "$repo_root" "$TARGET_BRANCH")"
+fi
+
+if [[ -z "$BASE_BRANCH" ]]; then
+ echo "[agent-branch-merge] Unable to resolve a base branch." >&2
+ exit 1
+fi
+
+start_ref="$BASE_BRANCH"
+if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
+ git -C "$repo_root" fetch origin "$BASE_BRANCH" --quiet
+ start_ref="origin/${BASE_BRANCH}"
+elif ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then
+ echo "[agent-branch-merge] Base branch not found locally or on origin: ${BASE_BRANCH}" >&2
+ exit 1
+fi
+
+target_worktree=""
+target_created=0
+
+if [[ -z "$TARGET_BRANCH" ]]; then
+ if [[ -z "$TASK_NAME" ]]; then
+ first_hint="$(printf '%s' "${SOURCE_BRANCHES[0]}" | sed -E 's#^agent/[^/]+/##; s#^agent/##')"
+ source_count="${#SOURCE_BRANCHES[@]}"
+ if [[ "$source_count" -gt 1 ]]; then
+ TASK_NAME="$(sanitize_slug "merge-${first_hint}-and-$((source_count - 1))-more" "merge-agent-branches")"
+ else
+ TASK_NAME="$(sanitize_slug "merge-${first_hint}" "merge-agent-branches")"
+ fi
+ else
+ TASK_NAME="$(sanitize_slug "$TASK_NAME" "merge-agent-branches")"
+ fi
+
+ start_output=""
+ if ! start_output="$(
+ cd "$repo_root"
+ env GUARDEX_OPENSPEC_AUTO_INIT=1 bash "scripts/agent-branch-start.sh" "$TASK_NAME" "$AGENT_NAME" "$BASE_BRANCH" 2>&1
+ )"; then
+ printf '%s\n' "$start_output" >&2
+ exit 1
+ fi
+
+ printf '%s\n' "$start_output"
+ TARGET_BRANCH="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Created branch: //p' | head -n 1)"
+ target_worktree="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | head -n 1)"
+ if [[ -z "$TARGET_BRANCH" || -z "$target_worktree" ]]; then
+ echo "[agent-branch-merge] Unable to parse target branch/worktree from agent-branch-start output." >&2
+ exit 1
+ fi
+ target_created=1
+else
+ if ! branch_exists "$repo_root" "$TARGET_BRANCH"; then
+ echo "[agent-branch-merge] Target branch not found: ${TARGET_BRANCH}" >&2
+ exit 1
+ fi
+
+ target_worktree="$(get_worktree_for_branch "$repo_root" "$TARGET_BRANCH")"
+ if [[ -z "$target_worktree" ]]; then
+ target_worktree="$(select_unique_worktree_path "$agent_worktree_root" "${TARGET_BRANCH//\//__}")"
+ git -C "$repo_root" worktree add "$target_worktree" "$TARGET_BRANCH" >/dev/null
+ target_created=1
+ echo "[agent-branch-merge] Attached worktree for target branch '${TARGET_BRANCH}': ${target_worktree}"
+ fi
+fi
+
+if [[ "$TARGET_BRANCH" == "$BASE_BRANCH" ]]; then
+ echo "[agent-branch-merge] Target branch must not equal the protected base branch '${BASE_BRANCH}'." >&2
+ exit 1
+fi
+
+if ! is_clean_worktree "$target_worktree"; then
+ if [[ "$target_created" -eq 1 ]]; then
+ echo "[agent-branch-merge] Target worktree has freshly generated scaffold changes; continuing inside the new integration lane."
+ else
+ echo "[agent-branch-merge] Target worktree is not clean: ${target_worktree}" >&2
+ echo "[agent-branch-merge] Commit, stash, or discard local changes before merging agent lanes." >&2
+ exit 1
+ fi
+fi
+
+if has_in_progress_git_op "$target_worktree"; then
+ echo "[agent-branch-merge] Target worktree has an in-progress merge/rebase: ${target_worktree}" >&2
+ echo "[agent-branch-merge] Resolve or abort that git operation before running the merge workflow." >&2
+ exit 1
+fi
+
+for source_branch in "${SOURCE_BRANCHES[@]}"; do
+ if [[ "$source_branch" == "$TARGET_BRANCH" ]]; then
+ echo "[agent-branch-merge] Source branch list includes the target branch: ${source_branch}" >&2
+ exit 1
+ fi
+ source_worktree="$(get_worktree_for_branch "$repo_root" "$source_branch")"
+ if [[ -n "$source_worktree" ]] && ! is_clean_worktree "$source_worktree"; then
+ echo "[agent-branch-merge] Source worktree is not clean for '${source_branch}': ${source_worktree}" >&2
+ echo "[agent-branch-merge] Commit or stash source-lane changes before integration." >&2
+ exit 1
+ fi
+done
+
+pending_branches=()
+for source_branch in "${SOURCE_BRANCHES[@]}"; do
+ if git -C "$repo_root" merge-base --is-ancestor "$source_branch" "$TARGET_BRANCH" >/dev/null 2>&1; then
+ echo "[agent-branch-merge] Skipping '${source_branch}' because it is already integrated into '${TARGET_BRANCH}'."
+ continue
+ fi
+ pending_branches+=("$source_branch")
+done
+
+if [[ "${#pending_branches[@]}" -eq 0 ]]; then
+ echo "[agent-branch-merge] No pending source branches remain for target '${TARGET_BRANCH}'."
+ echo "[agent-branch-merge] Target worktree: ${target_worktree}"
+ exit 0
+fi
+
+declare -A file_to_branches=()
+declare -a overlap_files=()
+for source_branch in "${pending_branches[@]}"; do
+ while IFS= read -r changed_file; do
+ [[ -z "$changed_file" ]] && continue
+ existing="${file_to_branches[$changed_file]:-}"
+ if [[ -z "$existing" ]]; then
+ file_to_branches["$changed_file"]="$source_branch"
+ continue
+ fi
+ if [[ ",${existing}," == *",${source_branch},"* ]]; then
+ continue
+ fi
+ file_to_branches["$changed_file"]="${existing},${source_branch}"
+ if ! array_contains "$changed_file" "${overlap_files[@]}"; then
+ overlap_files+=("$changed_file")
+ fi
+ done < <(collect_branch_files "$repo_root" "$start_ref" "$source_branch")
+done
+
+echo "[agent-branch-merge] Target branch: ${TARGET_BRANCH}"
+echo "[agent-branch-merge] Target worktree: ${target_worktree}"
+echo "[agent-branch-merge] Base branch: ${BASE_BRANCH} (${start_ref})"
+echo "[agent-branch-merge] Merge order: ${pending_branches[*]}"
+
+if [[ "${#overlap_files[@]}" -gt 0 ]]; then
+ echo "[agent-branch-merge] Overlapping changed files detected across requested branches:"
+ for overlap_file in "${overlap_files[@]}"; do
+ branches_csv="${file_to_branches[$overlap_file]}"
+ branches_display="$(printf '%s' "$branches_csv" | sed 's/,/, /g')"
+ echo " - ${overlap_file} <- ${branches_display}"
+ done
+else
+ echo "[agent-branch-merge] No overlapping changed files detected across requested branches."
+fi
+
+for index in "${!pending_branches[@]}"; do
+ source_branch="${pending_branches[$index]}"
+ echo "[agent-branch-merge] Merging '${source_branch}' into '${TARGET_BRANCH}'..."
+ if git -C "$target_worktree" merge --no-ff --no-edit "$source_branch"; then
+ echo "[agent-branch-merge] Merged '${source_branch}'."
+ continue
+ fi
+
+ conflict_files="$(git -C "$target_worktree" diff --name-only --diff-filter=U || true)"
+ echo "[agent-branch-merge] Merge conflict detected while merging '${source_branch}' into '${TARGET_BRANCH}'." >&2
+ echo "[agent-branch-merge] Target worktree: ${target_worktree}" >&2
+ if [[ -n "$conflict_files" ]]; then
+ echo "[agent-branch-merge] Conflicting files:" >&2
+ while IFS= read -r conflict_file; do
+ [[ -n "$conflict_file" ]] && echo " - ${conflict_file}" >&2
+ done <<< "$conflict_files"
+ fi
+ echo "[agent-branch-merge] Resolve or abort inside the integration worktree:" >&2
+ echo " cd \"${target_worktree}\"" >&2
+ echo " git status" >&2
+ echo " git add && git commit" >&2
+ echo " # or: git merge --abort" >&2
+
+ remaining_branches=("${pending_branches[@]:$((index + 1))}")
+ if [[ "${#remaining_branches[@]}" -gt 0 ]]; then
+ echo "[agent-branch-merge] Remaining branches:" >&2
+ for remaining in "${remaining_branches[@]}"; do
+ echo " - ${remaining}" >&2
+ done
+ resume_cmd="gx merge --into ${TARGET_BRANCH} --base ${BASE_BRANCH}"
+ for remaining in "${remaining_branches[@]}"; do
+ resume_cmd="${resume_cmd} --branch ${remaining}"
+ done
+ echo "[agent-branch-merge] Resume after resolving with: ${resume_cmd}" >&2
+ fi
+ exit 1
+done
+
+echo "[agent-branch-merge] Merge sequence complete for '${TARGET_BRANCH}'."
+if [[ "$target_created" -eq 1 ]]; then
+ echo "[agent-branch-merge] Review and verify in '${target_worktree}', then finish the integration branch when ready."
+fi
+echo "[agent-branch-merge] Next step: bash scripts/agent-branch-finish.sh --branch \"${TARGET_BRANCH}\" --base \"${BASE_BRANCH}\" --via-pr --wait-for-merge --cleanup"
diff --git a/test/merge-workflow.test.js b/test/merge-workflow.test.js
new file mode 100644
index 0000000..62e5216
--- /dev/null
+++ b/test/merge-workflow.test.js
@@ -0,0 +1,276 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const cp = require('node:child_process');
+
+const cliPath = path.resolve(__dirname, '..', 'bin', 'multiagent-safety.js');
+const defaultGuardexHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-merge-home-'));
+
+function withGuardexHome(extraEnv = {}) {
+ return {
+ ...process.env,
+ GUARDEX_HOME_DIR: extraEnv.GUARDEX_HOME_DIR || defaultGuardexHomeDir,
+ ...extraEnv,
+ };
+}
+
+function runNode(args, cwd, extraEnv = {}) {
+ return cp.spawnSync('node', [cliPath, ...args], {
+ cwd,
+ encoding: 'utf8',
+ env: withGuardexHome(extraEnv),
+ });
+}
+
+function runCmd(cmd, args, cwd, extraEnv = {}) {
+ const sanitizedEnv = { ...process.env };
+ delete sanitizedEnv.CODEX_THREAD_ID;
+ delete sanitizedEnv.OMX_SESSION_ID;
+ delete sanitizedEnv.CODEX_CI;
+ delete sanitizedEnv.CLAUDECODE;
+ delete sanitizedEnv.CLAUDE_CODE_SESSION_ID;
+
+ const pushBypassEnv =
+ cmd === 'git' && Array.isArray(args) && args[0] === 'push'
+ ? { ALLOW_PUSH_ON_PROTECTED_BRANCH: '1' }
+ : {};
+
+ return cp.spawnSync(cmd, args, {
+ cwd,
+ encoding: 'utf8',
+ env: { ...sanitizedEnv, ...pushBypassEnv, ...extraEnv },
+ });
+}
+
+function initRepo() {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-merge-'));
+ const repoDir = path.join(tempDir, 'repo');
+ fs.mkdirSync(repoDir);
+
+ let result = runCmd('git', ['init', '-b', 'dev'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ result = runCmd('git', ['config', 'user.email', 'bot@example.com'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ result = runCmd('git', ['config', 'user.name', 'Bot'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+
+ fs.writeFileSync(
+ path.join(repoDir, 'package.json'),
+ JSON.stringify({ name: path.basename(repoDir), private: true, scripts: {} }, null, 2) + '\n',
+ 'utf8',
+ );
+
+ return repoDir;
+}
+
+function seedCommit(repoDir) {
+ let result = runCmd('git', ['add', '.'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ result = runCmd('git', ['commit', '-m', 'seed'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+}
+
+function commitSetup(repoDir) {
+ let result = runCmd('git', ['add', '.'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ result = runCmd('git', ['commit', '-m', 'apply gx setup'], repoDir, {
+ ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1',
+ });
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+}
+
+function commitFile(repoDir, relativePath, contents, message, options = {}) {
+ const filePath = path.join(repoDir, relativePath);
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, contents, 'utf8');
+
+ const currentBranch = runCmd('git', ['branch', '--show-current'], repoDir);
+ assert.equal(currentBranch.status, 0, currentBranch.stderr || currentBranch.stdout);
+ const branchName = currentBranch.stdout.trim();
+ const lockScriptPath = path.join(repoDir, 'scripts', 'agent-file-locks.py');
+ if (branchName.startsWith('agent/') && fs.existsSync(lockScriptPath)) {
+ const claim = runCmd(
+ 'python3',
+ ['scripts/agent-file-locks.py', 'claim', '--branch', branchName, relativePath],
+ repoDir,
+ );
+ if (!options.allowLockConflict) {
+ assert.equal(claim.status, 0, claim.stderr || claim.stdout);
+ }
+ }
+
+ let result = runCmd('git', ['add', relativePath], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ const commitEnv = ['dev', 'main', 'master'].includes(branchName)
+ ? { ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1' }
+ : {};
+ const commitArgs = options.noVerify ? ['commit', '--no-verify', '-m', message] : ['commit', '-m', message];
+ result = runCmd('git', commitArgs, repoDir, commitEnv);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+}
+
+function combinedOutput(result) {
+ return `${result.stdout || ''}\n${result.stderr || ''}`;
+}
+
+function extractMergeTargetBranch(output) {
+ const match = String(output || '').match(/\[agent-branch-merge\] Target branch: (.+)/);
+ assert.ok(match, `missing merge target branch in output: ${output}`);
+ return match[1].trim();
+}
+
+function extractMergeTargetWorktree(output) {
+ const match = String(output || '').match(/\[agent-branch-merge\] Target worktree: (.+)/);
+ assert.ok(match, `missing merge target worktree in output: ${output}`);
+ return match[1].trim();
+}
+
+test('setup installs the managed merge workflow script and package entry', () => {
+ const repoDir = initRepo();
+ seedCommit(repoDir);
+
+ const result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+
+ const mergeScriptPath = path.join(repoDir, 'scripts', 'agent-branch-merge.sh');
+ assert.equal(fs.existsSync(mergeScriptPath), true, 'merge script should be installed');
+ fs.accessSync(mergeScriptPath, fs.constants.X_OK);
+
+ const packageJson = JSON.parse(fs.readFileSync(path.join(repoDir, 'package.json'), 'utf8'));
+ assert.equal(packageJson.scripts['agent:branch:merge'], 'bash ./scripts/agent-branch-merge.sh');
+});
+
+test('merge command creates an integration lane, reports overlaps, and merges cleanly', () => {
+ const repoDir = initRepo();
+ seedCommit(repoDir);
+
+ let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ commitSetup(repoDir);
+
+ commitFile(repoDir, 'shared.txt', 'alpha\nbeta\ngamma\n', 'add shared baseline');
+
+ result = runCmd('git', ['checkout', '-b', 'agent/test-merge-a'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ commitFile(repoDir, 'shared.txt', 'alpha-one\nbeta\ngamma\n', 'agent a update');
+
+ result = runCmd('git', ['checkout', 'dev'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ result = runCmd('git', ['checkout', '-b', 'agent/test-merge-b'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ commitFile(repoDir, 'shared.txt', 'alpha\nbeta\ngamma-two\n', 'agent b update', {
+ allowLockConflict: true,
+ noVerify: true,
+ });
+
+ result = runCmd('git', ['checkout', 'dev'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+
+ result = runNode(
+ [
+ 'merge',
+ '--target',
+ repoDir,
+ '--task',
+ 'merge-shared-smoke',
+ '--branch',
+ 'agent/test-merge-a',
+ '--branch',
+ 'agent/test-merge-b',
+ ],
+ repoDir,
+ );
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+
+ const output = combinedOutput(result);
+ assert.match(output, /Overlapping changed files detected across requested branches/);
+ assert.match(output, /shared\.txt <- agent\/test-merge-a, agent\/test-merge-b/);
+
+ const targetBranch = extractMergeTargetBranch(output);
+ const targetWorktree = extractMergeTargetWorktree(output);
+ assert.match(targetBranch, /^agent\/codex\/merge-shared-smoke-/);
+
+ const mergedFile = fs.readFileSync(path.join(targetWorktree, 'shared.txt'), 'utf8');
+ assert.equal(mergedFile, 'alpha-one\nbeta\ngamma-two\n');
+
+ let ancestry = runCmd('git', ['merge-base', '--is-ancestor', 'agent/test-merge-a', targetBranch], repoDir);
+ assert.equal(ancestry.status, 0, ancestry.stderr || ancestry.stdout);
+ ancestry = runCmd('git', ['merge-base', '--is-ancestor', 'agent/test-merge-b', targetBranch], repoDir);
+ assert.equal(ancestry.status, 0, ancestry.stderr || ancestry.stdout);
+
+ assert.match(output, /OpenSpec change workspace: .+openspec\/changes\/agent-codex-merge-shared-smoke-/);
+ assert.match(output, /OpenSpec plan workspace: .+openspec\/plan\/agent-codex-merge-shared-smoke-/);
+});
+
+test('merge command reuses an owner lane and stops with resumable guidance on conflict', () => {
+ const repoDir = initRepo();
+ seedCommit(repoDir);
+
+ let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ commitSetup(repoDir);
+
+ commitFile(repoDir, 'shared.txt', 'base-line\n', 'add shared conflict baseline');
+
+ result = runCmd('git', ['checkout', '-b', 'agent/test-owner'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ result = runCmd('git', ['checkout', 'dev'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+
+ result = runCmd('git', ['checkout', '-b', 'agent/test-helper-a'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ commitFile(repoDir, 'shared.txt', 'helper-a-line\n', 'helper a change');
+
+ result = runCmd('git', ['checkout', 'dev'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ result = runCmd('git', ['checkout', '-b', 'agent/test-helper-b'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ commitFile(repoDir, 'shared.txt', 'helper-b-line\n', 'helper b conflicting change', {
+ allowLockConflict: true,
+ noVerify: true,
+ });
+
+ result = runCmd('git', ['checkout', 'dev'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ result = runCmd('git', ['checkout', '-b', 'agent/test-helper-c'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ commitFile(repoDir, 'later.txt', 'later branch\n', 'helper c later branch');
+
+ result = runCmd('git', ['checkout', 'dev'], repoDir);
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+
+ result = runNode(
+ [
+ 'merge',
+ '--target',
+ repoDir,
+ '--into',
+ 'agent/test-owner',
+ '--branch',
+ 'agent/test-helper-a',
+ '--branch',
+ 'agent/test-helper-b',
+ '--branch',
+ 'agent/test-helper-c',
+ ],
+ repoDir,
+ );
+ assert.equal(result.status, 1, 'merge should stop on conflict');
+
+ const output = combinedOutput(result);
+ assert.match(output, /Merge conflict detected while merging 'agent\/test-helper-b' into 'agent\/test-owner'/);
+ assert.match(output, /Remaining branches:/);
+ assert.match(output, /agent\/test-helper-c/);
+ assert.match(output, /Resume after resolving with: gx merge --into agent\/test-owner --base dev --branch agent\/test-helper-c/);
+
+ const targetWorktree = extractMergeTargetWorktree(output);
+ let mergeHead = runCmd('git', ['-C', targetWorktree, 'rev-parse', '-q', '--verify', 'MERGE_HEAD'], repoDir);
+ assert.equal(mergeHead.status, 0, mergeHead.stderr || mergeHead.stdout);
+
+ let ancestry = runCmd('git', ['merge-base', '--is-ancestor', 'agent/test-helper-a', 'agent/test-owner'], repoDir);
+ assert.equal(ancestry.status, 0, ancestry.stderr || ancestry.stdout);
+ ancestry = runCmd('git', ['merge-base', '--is-ancestor', 'agent/test-helper-b', 'agent/test-owner'], repoDir);
+ assert.notEqual(ancestry.status, 0, 'conflicting branch should not be fully integrated yet');
+});