diff --git a/openspec/changes/agent-codex-reuse-existing-agent-worktree-on-repeate-2026-04-27-18-17/.openspec.yaml b/openspec/changes/agent-codex-reuse-existing-agent-worktree-on-repeate-2026-04-27-18-17/.openspec.yaml new file mode 100644 index 0000000..1b4051e --- /dev/null +++ b/openspec/changes/agent-codex-reuse-existing-agent-worktree-on-repeate-2026-04-27-18-17/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-27 diff --git a/openspec/changes/agent-codex-reuse-existing-agent-worktree-on-repeate-2026-04-27-18-17/proposal.md b/openspec/changes/agent-codex-reuse-existing-agent-worktree-on-repeate-2026-04-27-18-17/proposal.md new file mode 100644 index 0000000..fe27b1d --- /dev/null +++ b/openspec/changes/agent-codex-reuse-existing-agent-worktree-on-repeate-2026-04-27-18-17/proposal.md @@ -0,0 +1,7 @@ +# Proposal: reuse the active agent worktree on branch start + +`gx branch start` currently creates a fresh timestamped branch even when it is invoked from inside an existing `agent/*` worktree. That copies the active sandbox into a nested sandbox and splits follow-up work away from the lane the user selected. + +- reuse the current `agent/*` worktree by default when `branch start` runs inside it +- keep an explicit `--new` / `--no-reuse` escape hatch for intentional child lanes +- preserve downstream parser compatibility for reused branch-start output diff --git a/openspec/changes/agent-codex-reuse-existing-agent-worktree-on-repeate-2026-04-27-18-17/specs/reuse-existing-agent-worktree-on-repeate/spec.md b/openspec/changes/agent-codex-reuse-existing-agent-worktree-on-repeate-2026-04-27-18-17/specs/reuse-existing-agent-worktree-on-repeate/spec.md new file mode 100644 index 0000000..6a72f1f --- /dev/null +++ b/openspec/changes/agent-codex-reuse-existing-agent-worktree-on-repeate-2026-04-27-18-17/specs/reuse-existing-agent-worktree-on-repeate/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: Branch start reuses current agent worktree +When `gx branch start` runs from a worktree whose current branch starts with `agent/`, the command SHALL reuse that existing branch and worktree by default instead of creating another timestamped branch/worktree from it. + +#### Scenario: Follow-up agent starts inside an existing sandbox +- **GIVEN** the current working tree is an agent worktree on branch `agent/codex/example` +- **WHEN** `gx branch start "continue work" "codex"` is run from that worktree +- **THEN** the command reports `Reusing existing branch: agent/codex/example` +- **AND** the reported worktree path is the current worktree +- **AND** no nested agent worktree is created. + +### Requirement: Branch start keeps an explicit new-lane escape hatch +When a caller intentionally needs a child or parallel lane from inside an existing agent worktree, the command SHALL provide an explicit option to bypass reuse and create a new branch/worktree. + +#### Scenario: Caller opts out of reuse +- **GIVEN** the current working tree is an agent worktree +- **WHEN** `gx branch start --new "parallel work" "codex"` is run +- **THEN** the command may create a new isolated branch/worktree using the existing startup behavior. diff --git a/openspec/changes/agent-codex-reuse-existing-agent-worktree-on-repeate-2026-04-27-18-17/tasks.md b/openspec/changes/agent-codex-reuse-existing-agent-worktree-on-repeate-2026-04-27-18-17/tasks.md new file mode 100644 index 0000000..add4047 --- /dev/null +++ b/openspec/changes/agent-codex-reuse-existing-agent-worktree-on-repeate-2026-04-27-18-17/tasks.md @@ -0,0 +1,32 @@ +## 1. Spec + +- [x] 1.1 Capture why branch start inside an active agent sandbox should attach instead of cloning the sandbox. +- [x] 1.2 Define the reuse behavior and explicit new-lane escape hatch. + +## 2. Implementation + +- [x] 2.1 Update branch-start runtime and template scripts to reuse the current `agent/*` worktree by default. +- [x] 2.2 Keep parser surfaces compatible with reused branch-start output. +- [x] 2.3 Add focused regression coverage for the existing-worktree reuse path. + +## 3. Verification + +- [x] 3.1 Run targeted branch-start regression tests. +- [x] 3.2 Run template parity and script syntax checks. +- [x] 3.3 Run `openspec validate agent-codex-reuse-existing-agent-worktree-on-repeate-2026-04-27-18-17 --type change --strict`. +- [x] 3.4 Run `openspec validate --specs`. + +Verification evidence: +- `node --test test/branch.test.js` (pass, 30/30) +- `node --test test/metadata.test.js` (pass, 24/24) +- `npm test` (pass, 288 passed, 1 skipped) +- `bash -n scripts/agent-branch-start.sh`, `bash -n templates/scripts/agent-branch-start.sh`, `bash -n scripts/agent-branch-merge.sh`, `bash -n templates/scripts/agent-branch-merge.sh` (pass) +- `git diff --check` (pass) +- `openspec validate agent-codex-reuse-existing-agent-worktree-on-repeate-2026-04-27-18-17 --type change --strict` (pass) +- `openspec validate --specs` (pass; no spec items found) + +## 4. Cleanup + +- [ ] 4.1 Commit, push, open/update PR, merge, and clean up the worktree. +- [ ] 4.2 Record PR URL + final `MERGED` state in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone. diff --git a/scripts/agent-branch-merge.sh b/scripts/agent-branch-merge.sh index ac47f6d..f3192b5 100755 --- a/scripts/agent-branch-merge.sh +++ b/scripts/agent-branch-merge.sh @@ -288,7 +288,7 @@ if [[ -z "$TARGET_BRANCH" ]]; then 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_BRANCH="$(printf '%s\n' "$start_output" | sed -n -E 's/^\[agent-branch-start\] (Created branch|Reusing existing 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 diff --git a/scripts/agent-branch-start.sh b/scripts/agent-branch-start.sh index bfeab2f..da4f484 100755 --- a/scripts/agent-branch-start.sh +++ b/scripts/agent-branch-start.sh @@ -15,6 +15,7 @@ OPENSPEC_CHANGE_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CHANGE_SLUG:-}" OPENSPEC_CAPABILITY_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CAPABILITY_SLUG:-}" OPENSPEC_MASTERPLAN_LABEL_RAW="${GUARDEX_OPENSPEC_MASTERPLAN_LABEL-masterplan}" OPENSPEC_TIER_RAW="${GUARDEX_OPENSPEC_TIER:-T3}" +REUSE_EXISTING_RAW="${GUARDEX_BRANCH_START_REUSE_EXISTING:-true}" PRINT_NAME_ONLY=0 POSITIONAL_ARGS=() @@ -58,6 +59,14 @@ while [[ $# -gt 0 ]]; do OPENSPEC_TIER_RAW="${2:-$OPENSPEC_TIER_RAW}" shift 2 ;; + --reuse-existing|--reuse) + REUSE_EXISTING_RAW="true" + shift + ;; + --new|--no-reuse|--no-reuse-existing) + REUSE_EXISTING_RAW="false" + shift + ;; --in-place|--allow-in-place) echo "[agent-branch-start] In-place branch mode is disabled." >&2 echo "[agent-branch-start] This command always creates an isolated worktree to keep your active checkout unchanged." >&2 @@ -78,7 +87,7 @@ while [[ $# -gt 0 ]]; do ;; -*) echo "[agent-branch-start] Unknown option: $1" >&2 - echo "Usage: $0 [task] [agent] [base] [--worktree-root ] [--print-name-only]" >&2 + echo "Usage: $0 [task] [agent] [base] [--worktree-root ] [--reuse-existing|--new] [--print-name-only]" >&2 exit 1 ;; *) @@ -90,7 +99,7 @@ done if [[ "${#POSITIONAL_ARGS[@]}" -gt 3 ]]; then echo "[agent-branch-start] Too many positional arguments." >&2 - echo "Usage: $0 [task] [agent] [base] [--worktree-root ]" >&2 + echo "Usage: $0 [task] [agent] [base] [--worktree-root ] [--reuse-existing|--new]" >&2 exit 1 fi @@ -254,6 +263,7 @@ normalize_bool() { } OPENSPEC_AUTO_INIT="$(normalize_bool "$OPENSPEC_AUTO_INIT_RAW" "1")" +REUSE_EXISTING_WORKTREE="$(normalize_bool "$REUSE_EXISTING_RAW" "1")" normalize_tier() { local raw="${1:-}" @@ -370,6 +380,22 @@ resolve_worktree_leaf() { printf '%s' "${branch_name//\//__}" } +print_reused_agent_worktree() { + local branch_name="$1" + local worktree_path="$2" + + echo "[agent-branch-start] Reusing existing branch: ${branch_name}" + echo "[agent-branch-start] Worktree: ${worktree_path}" + echo "[agent-branch-start] OpenSpec tier: ${OPENSPEC_TIER}" + echo "[agent-branch-start] OpenSpec change: existing worktree" + echo "[agent-branch-start] OpenSpec plan: existing worktree" + echo "[agent-branch-start] Next steps:" + echo " cd \"${worktree_path}\"" + echo " gx locks claim --branch \"${branch_name}\" " + echo " # continue work in this existing sandbox" + echo " gx branch finish --branch \"${branch_name}\" --via-pr --wait-for-merge" +} + has_local_changes() { local root="$1" if ! git -C "$root" diff --quiet; then @@ -550,6 +576,12 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then exit 1 fi +current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +if [[ "$REUSE_EXISTING_WORKTREE" -eq 1 && "$current_branch" == agent/* ]]; then + print_reused_agent_worktree "$current_branch" "$repo_root" + exit 0 +fi + task_slug="$(sanitize_slug "$TASK_NAME" "task")" agent_slug="$(normalize_role "$AGENT_NAME")" if [[ "$WORKTREE_ROOT_EXPLICIT" -eq 0 ]]; then diff --git a/src/cli/main.js b/src/cli/main.js index 7952faf..2bb68a1 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -408,8 +408,9 @@ function runSetupBootstrapInternal(options) { } function extractAgentBranchStartMetadata(output) { - const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m); - const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m); + const outputText = String(output || ''); + const branchMatch = outputText.match(/^\[agent-branch-start\] (?:Created branch|Reusing existing branch): (.+)$/m); + const worktreeMatch = outputText.match(/^\[agent-branch-start\] Worktree: (.+)$/m); return { branch: branchMatch ? branchMatch[1].trim() : '', worktreePath: worktreeMatch ? worktreeMatch[1].trim() : '', @@ -3466,7 +3467,7 @@ function pivot(rawArgs) { } const stdoutText = String(result.stdout || ''); const wtMatch = stdoutText.match(/^\[agent-branch-start\] Worktree:\s+(.+)$/m); - const branchMatch = stdoutText.match(/^\[agent-branch-start\] Created branch:\s+(.+)$/m); + const branchMatch = stdoutText.match(/^\[agent-branch-start\] (?:Created branch|Reusing existing branch):\s+(.+)$/m); if (wtMatch) { const wtPath = wtMatch[1].trim(); process.stdout.write('\n'); diff --git a/src/sandbox/index.js b/src/sandbox/index.js index 3fe15fd..0e665ab 100644 --- a/src/sandbox/index.js +++ b/src/sandbox/index.js @@ -65,8 +65,9 @@ function assertProtectedMainWriteAllowed(options, commandName) { } function extractAgentBranchStartMetadata(output) { - const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m); - const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m); + const outputText = String(output || ''); + const branchMatch = outputText.match(/^\[agent-branch-start\] (?:Created branch|Reusing existing branch): (.+)$/m); + const worktreeMatch = outputText.match(/^\[agent-branch-start\] Worktree: (.+)$/m); return { branch: branchMatch ? branchMatch[1].trim() : '', worktreePath: worktreeMatch ? worktreeMatch[1].trim() : '', diff --git a/templates/scripts/agent-branch-merge.sh b/templates/scripts/agent-branch-merge.sh index ac47f6d..f3192b5 100755 --- a/templates/scripts/agent-branch-merge.sh +++ b/templates/scripts/agent-branch-merge.sh @@ -288,7 +288,7 @@ if [[ -z "$TARGET_BRANCH" ]]; then 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_BRANCH="$(printf '%s\n' "$start_output" | sed -n -E 's/^\[agent-branch-start\] (Created branch|Reusing existing 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 diff --git a/templates/scripts/agent-branch-start.sh b/templates/scripts/agent-branch-start.sh index bfeab2f..da4f484 100755 --- a/templates/scripts/agent-branch-start.sh +++ b/templates/scripts/agent-branch-start.sh @@ -15,6 +15,7 @@ OPENSPEC_CHANGE_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CHANGE_SLUG:-}" OPENSPEC_CAPABILITY_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CAPABILITY_SLUG:-}" OPENSPEC_MASTERPLAN_LABEL_RAW="${GUARDEX_OPENSPEC_MASTERPLAN_LABEL-masterplan}" OPENSPEC_TIER_RAW="${GUARDEX_OPENSPEC_TIER:-T3}" +REUSE_EXISTING_RAW="${GUARDEX_BRANCH_START_REUSE_EXISTING:-true}" PRINT_NAME_ONLY=0 POSITIONAL_ARGS=() @@ -58,6 +59,14 @@ while [[ $# -gt 0 ]]; do OPENSPEC_TIER_RAW="${2:-$OPENSPEC_TIER_RAW}" shift 2 ;; + --reuse-existing|--reuse) + REUSE_EXISTING_RAW="true" + shift + ;; + --new|--no-reuse|--no-reuse-existing) + REUSE_EXISTING_RAW="false" + shift + ;; --in-place|--allow-in-place) echo "[agent-branch-start] In-place branch mode is disabled." >&2 echo "[agent-branch-start] This command always creates an isolated worktree to keep your active checkout unchanged." >&2 @@ -78,7 +87,7 @@ while [[ $# -gt 0 ]]; do ;; -*) echo "[agent-branch-start] Unknown option: $1" >&2 - echo "Usage: $0 [task] [agent] [base] [--worktree-root ] [--print-name-only]" >&2 + echo "Usage: $0 [task] [agent] [base] [--worktree-root ] [--reuse-existing|--new] [--print-name-only]" >&2 exit 1 ;; *) @@ -90,7 +99,7 @@ done if [[ "${#POSITIONAL_ARGS[@]}" -gt 3 ]]; then echo "[agent-branch-start] Too many positional arguments." >&2 - echo "Usage: $0 [task] [agent] [base] [--worktree-root ]" >&2 + echo "Usage: $0 [task] [agent] [base] [--worktree-root ] [--reuse-existing|--new]" >&2 exit 1 fi @@ -254,6 +263,7 @@ normalize_bool() { } OPENSPEC_AUTO_INIT="$(normalize_bool "$OPENSPEC_AUTO_INIT_RAW" "1")" +REUSE_EXISTING_WORKTREE="$(normalize_bool "$REUSE_EXISTING_RAW" "1")" normalize_tier() { local raw="${1:-}" @@ -370,6 +380,22 @@ resolve_worktree_leaf() { printf '%s' "${branch_name//\//__}" } +print_reused_agent_worktree() { + local branch_name="$1" + local worktree_path="$2" + + echo "[agent-branch-start] Reusing existing branch: ${branch_name}" + echo "[agent-branch-start] Worktree: ${worktree_path}" + echo "[agent-branch-start] OpenSpec tier: ${OPENSPEC_TIER}" + echo "[agent-branch-start] OpenSpec change: existing worktree" + echo "[agent-branch-start] OpenSpec plan: existing worktree" + echo "[agent-branch-start] Next steps:" + echo " cd \"${worktree_path}\"" + echo " gx locks claim --branch \"${branch_name}\" " + echo " # continue work in this existing sandbox" + echo " gx branch finish --branch \"${branch_name}\" --via-pr --wait-for-merge" +} + has_local_changes() { local root="$1" if ! git -C "$root" diff --quiet; then @@ -550,6 +576,12 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then exit 1 fi +current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +if [[ "$REUSE_EXISTING_WORKTREE" -eq 1 && "$current_branch" == agent/* ]]; then + print_reused_agent_worktree "$current_branch" "$repo_root" + exit 0 +fi + task_slug="$(sanitize_slug "$TASK_NAME" "task")" agent_slug="$(normalize_role "$AGENT_NAME")" if [[ "$WORKTREE_ROOT_EXPLICIT" -eq 0 ]]; then diff --git a/test/branch.test.js b/test/branch.test.js index beb87b5..05194ab 100644 --- a/test/branch.test.js +++ b/test/branch.test.js @@ -117,6 +117,38 @@ test('agent-branch-start prefers current protected branch over stale configured }); +test('agent-branch-start reuses the current agent worktree instead of cloning it', () => { + const { repoDir } = createBootstrappedRepo({ committed: true }); + + let result = runBranchStart(['--tier', 'T1', 'rust repair snapshot selection', 'bot'], repoDir, { + GUARDEX_OPENSPEC_AUTO_INIT: 'true', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const firstBranch = extractCreatedBranch(result.stdout); + const firstWorktree = extractCreatedWorktree(result.stdout); + + result = runBranchStart(['--tier', 'T1', 'continue rust worktree', 'bot'], firstWorktree, { + GUARDEX_OPENSPEC_AUTO_INIT: 'true', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, new RegExp(`Reusing existing branch: ${escapeRegexLiteral(firstBranch)}`)); + assert.equal(extractCreatedWorktree(result.stdout), firstWorktree); + assert.equal( + fs.existsSync(path.join(firstWorktree, '.omx', 'agent-worktrees')), + false, + 'branch start inside an agent worktree must not create nested worktrees', + ); + + const worktreeList = runCmd('git', ['worktree', 'list', '--porcelain'], repoDir); + assert.equal(worktreeList.status, 0, worktreeList.stderr || worktreeList.stdout); + assert.equal( + (worktreeList.stdout.match(/^branch refs\/heads\/agent\//gm) || []).length, + 1, + 'only the original agent branch should remain registered', + ); +}); + + test('agent-branch-start moves protected-branch local changes into the new agent worktree', () => { const repoDir = initRepoOnBranch('main'); seedCommit(repoDir);