diff --git a/openspec/changes/agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56/.openspec.yaml b/openspec/changes/agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56/.openspec.yaml new file mode 100644 index 0000000..1b4051e --- /dev/null +++ b/openspec/changes/agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-27 diff --git a/openspec/changes/agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56/proposal.md b/openspec/changes/agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56/proposal.md new file mode 100644 index 0000000..ca26154 --- /dev/null +++ b/openspec/changes/agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56/proposal.md @@ -0,0 +1,17 @@ +## Why + +- A follow-up prompt can refer to an unfinished task whose dirty worktree was left behind by a blocked commit or interrupted session. +- Today `gx branch start --reuse-existing` only reuses the current agent worktree. From the protected checkout it can miss dirty managed worktrees that are visible in VS Code but absent from live notepad/handoff state, then create a fresh branch and duplicate copied changes. + +## What Changes + +- Teach branch start to scan managed `.omx/agent-worktrees` and `.omc/agent-worktrees` for dirty same-agent branches whose task tokens match the requested task. +- Reuse the single best matching dirty worktree before creating a fresh branch. +- Keep ambiguous matches conservative: create a fresh branch only when there is no unique matching dirty worktree. +- Cover the behavior in branch-start regression tests and keep the install template in sync. + +## Impact + +- Affects `gx branch start` and template-provisioned `scripts/agent-branch-start.sh`. +- Reduces duplicate worktree creation for continuation/takeover prompts. +- Matching stays limited to same-agent dirty managed worktrees to avoid stealing unrelated clean or completed lanes. diff --git a/openspec/changes/agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56/specs/reuse-dirty-matching-worktree-on-branch-start/spec.md b/openspec/changes/agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56/specs/reuse-dirty-matching-worktree-on-branch-start/spec.md new file mode 100644 index 0000000..f2f9ad1 --- /dev/null +++ b/openspec/changes/agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56/specs/reuse-dirty-matching-worktree-on-branch-start/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: Dirty matching managed worktree reuse +`gx branch start` SHALL, when reuse is enabled, inspect managed `.omx/agent-worktrees` and `.omc/agent-worktrees` before creating a fresh branch from the protected checkout. + +#### Scenario: A single dirty same-agent worktree matches the requested task +- **GIVEN** the current checkout is on a protected branch +- **AND** exactly one managed worktree is on an `agent//...` branch +- **AND** that worktree has local changes +- **AND** the requested task shares at least one meaningful token with that branch descriptor +- **WHEN** `gx branch start --reuse-existing "" ""` runs +- **THEN** the command reuses the existing dirty worktree +- **AND** it does not create a new `agent/*` branch. + +#### Scenario: No unique dirty same-agent match exists +- **GIVEN** reuse is enabled +- **WHEN** there is no dirty same-agent managed worktree with a meaningful task-token match +- **THEN** `gx branch start` creates a fresh branch using the existing branch-start flow. + +#### Scenario: More than one matching dirty same-agent worktree exists +- **GIVEN** reuse is enabled +- **WHEN** more than one dirty same-agent managed worktree has the same best token-match score +- **THEN** `gx branch start` does not auto-select one of them +- **AND** it creates a fresh branch using the existing branch-start flow. diff --git a/openspec/changes/agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56/tasks.md b/openspec/changes/agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56/tasks.md new file mode 100644 index 0000000..ba87ae0 --- /dev/null +++ b/openspec/changes/agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56/tasks.md @@ -0,0 +1,34 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56`; branch=`agent/codex/reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56`; scope=`scripts/agent-branch-start.sh`, `templates/scripts/agent-branch-start.sh`, `test/branch.test.js`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56` on branch `agent/codex/reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56`. Work inside the existing sandbox, review `openspec/changes/agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56 --base main --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56`. +- [x] 1.2 Define normative requirements in `specs/reuse-dirty-matching-worktree-on-branch-start/spec.md`. + +## 2. Implementation + +- [x] 2.1 Implement scoped behavior changes in branch-start script/template. +- [x] 2.2 Add/update focused branch-start regression coverage. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands. +- [x] 3.2 Run `openspec validate agent-codex-reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/reuse-dirty-matching-worktree-on-branch-2026-04-28-00-56 --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/scripts/agent-branch-start.sh b/scripts/agent-branch-start.sh index da4f484..14b36c0 100755 --- a/scripts/agent-branch-start.sh +++ b/scripts/agent-branch-start.sh @@ -410,6 +410,101 @@ has_local_changes() { return 1 } +meaningful_slug_tokens() { + local raw="$1" + printf '%s' "$raw" \ + | tr '[:upper:]' '[:lower:]' \ + | tr '/_' '--' \ + | tr '-' '\n' \ + | awk ' + length($0) < 4 { next } + $0 ~ /^[0-9]+$/ { next } + $0 ~ /^(agent|agents|branch|codex|claude|continue|dirty|existing|fix|from|implement|make|matching|reuse|start|task|that|this|update|with|worktree|worktrees)$/ { next } + !seen[$0]++ { print } + ' +} + +token_match_score() { + local task_slug="$1" + local branch_descriptor="$2" + local task_tokens branch_tokens token score + task_tokens="$(meaningful_slug_tokens "$task_slug")" + branch_tokens="$(meaningful_slug_tokens "$branch_descriptor")" + score=0 + + if [[ -z "$task_tokens" ]] || [[ -z "$branch_tokens" ]]; then + printf '0' + return 0 + fi + + while IFS= read -r token; do + if grep -Fxq "$token" <<<"$branch_tokens"; then + score=$((score + 1)) + fi + done <<<"$task_tokens" + + printf '%s' "$score" +} + +managed_worktree_roots() { + local repo="$1" + local explicit_root="$2" + local root + local seen_roots=$'\n' + + for root in \ + "${repo}/${explicit_root}" \ + "${repo}/.omx/agent-worktrees" \ + "${repo}/.omc/agent-worktrees"; do + if [[ -n "$root" && "$seen_roots" != *$'\n'"$root"$'\n'* ]]; then + seen_roots+="${root}"$'\n' + printf '%s\n' "$root" + fi + done +} + +find_matching_dirty_agent_worktree() { + local repo="$1" + local worktree_root_rel="$2" + local task_slug="$3" + local agent_slug="$4" + local best_score=0 + local best_branch="" + local best_worktree="" + local best_count=0 + local root entry branch descriptor score + + while IFS= read -r root; do + [[ -d "$root" ]] || continue + while IFS= read -r entry; do + [[ -d "$entry" ]] || continue + if ! branch="$(git -C "$entry" rev-parse --abbrev-ref HEAD 2>/dev/null)"; then + continue + fi + [[ "$branch" == "agent/${agent_slug}/"* ]] || continue + has_local_changes "$entry" || continue + + descriptor="${branch#agent/${agent_slug}/}" + score="$(token_match_score "$task_slug" "$descriptor")" + [[ "$score" =~ ^[0-9]+$ ]] || score=0 + [[ "$score" -gt 0 ]] || continue + + if [[ "$score" -gt "$best_score" ]]; then + best_score="$score" + best_branch="$branch" + best_worktree="$entry" + best_count=1 + elif [[ "$score" -eq "$best_score" ]]; then + best_count=$((best_count + 1)) + fi + done < <(find "$root" -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null | sort) + done < <(managed_worktree_roots "$repo" "$worktree_root_rel") + + if [[ "$best_score" -gt 0 && "$best_count" -eq 1 && -n "$best_branch" && -n "$best_worktree" ]]; then + printf '%s\t%s\n' "$best_branch" "$best_worktree" + fi +} + resolve_stash_ref_by_message() { local root="$1" local message="$2" @@ -597,6 +692,16 @@ if [[ "$PRINT_NAME_ONLY" -eq 1 ]]; then exit 0 fi +if [[ "$REUSE_EXISTING_WORKTREE" -eq 1 ]]; then + matching_dirty_worktree="$(find_matching_dirty_agent_worktree "$repo_root" "$WORKTREE_ROOT_REL" "$task_slug" "$agent_slug")" + if [[ -n "$matching_dirty_worktree" ]]; then + IFS=$'\t' read -r reused_branch reused_worktree <<<"$matching_dirty_worktree" + echo "[agent-branch-start] Matched dirty managed worktree for requested task." + print_reused_agent_worktree "$reused_branch" "$reused_worktree" + exit 0 + fi +fi + if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" protected_branches_raw="$(resolve_protected_branches "$repo_root")" diff --git a/templates/scripts/agent-branch-start.sh b/templates/scripts/agent-branch-start.sh index da4f484..14b36c0 100755 --- a/templates/scripts/agent-branch-start.sh +++ b/templates/scripts/agent-branch-start.sh @@ -410,6 +410,101 @@ has_local_changes() { return 1 } +meaningful_slug_tokens() { + local raw="$1" + printf '%s' "$raw" \ + | tr '[:upper:]' '[:lower:]' \ + | tr '/_' '--' \ + | tr '-' '\n' \ + | awk ' + length($0) < 4 { next } + $0 ~ /^[0-9]+$/ { next } + $0 ~ /^(agent|agents|branch|codex|claude|continue|dirty|existing|fix|from|implement|make|matching|reuse|start|task|that|this|update|with|worktree|worktrees)$/ { next } + !seen[$0]++ { print } + ' +} + +token_match_score() { + local task_slug="$1" + local branch_descriptor="$2" + local task_tokens branch_tokens token score + task_tokens="$(meaningful_slug_tokens "$task_slug")" + branch_tokens="$(meaningful_slug_tokens "$branch_descriptor")" + score=0 + + if [[ -z "$task_tokens" ]] || [[ -z "$branch_tokens" ]]; then + printf '0' + return 0 + fi + + while IFS= read -r token; do + if grep -Fxq "$token" <<<"$branch_tokens"; then + score=$((score + 1)) + fi + done <<<"$task_tokens" + + printf '%s' "$score" +} + +managed_worktree_roots() { + local repo="$1" + local explicit_root="$2" + local root + local seen_roots=$'\n' + + for root in \ + "${repo}/${explicit_root}" \ + "${repo}/.omx/agent-worktrees" \ + "${repo}/.omc/agent-worktrees"; do + if [[ -n "$root" && "$seen_roots" != *$'\n'"$root"$'\n'* ]]; then + seen_roots+="${root}"$'\n' + printf '%s\n' "$root" + fi + done +} + +find_matching_dirty_agent_worktree() { + local repo="$1" + local worktree_root_rel="$2" + local task_slug="$3" + local agent_slug="$4" + local best_score=0 + local best_branch="" + local best_worktree="" + local best_count=0 + local root entry branch descriptor score + + while IFS= read -r root; do + [[ -d "$root" ]] || continue + while IFS= read -r entry; do + [[ -d "$entry" ]] || continue + if ! branch="$(git -C "$entry" rev-parse --abbrev-ref HEAD 2>/dev/null)"; then + continue + fi + [[ "$branch" == "agent/${agent_slug}/"* ]] || continue + has_local_changes "$entry" || continue + + descriptor="${branch#agent/${agent_slug}/}" + score="$(token_match_score "$task_slug" "$descriptor")" + [[ "$score" =~ ^[0-9]+$ ]] || score=0 + [[ "$score" -gt 0 ]] || continue + + if [[ "$score" -gt "$best_score" ]]; then + best_score="$score" + best_branch="$branch" + best_worktree="$entry" + best_count=1 + elif [[ "$score" -eq "$best_score" ]]; then + best_count=$((best_count + 1)) + fi + done < <(find "$root" -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null | sort) + done < <(managed_worktree_roots "$repo" "$worktree_root_rel") + + if [[ "$best_score" -gt 0 && "$best_count" -eq 1 && -n "$best_branch" && -n "$best_worktree" ]]; then + printf '%s\t%s\n' "$best_branch" "$best_worktree" + fi +} + resolve_stash_ref_by_message() { local root="$1" local message="$2" @@ -597,6 +692,16 @@ if [[ "$PRINT_NAME_ONLY" -eq 1 ]]; then exit 0 fi +if [[ "$REUSE_EXISTING_WORKTREE" -eq 1 ]]; then + matching_dirty_worktree="$(find_matching_dirty_agent_worktree "$repo_root" "$WORKTREE_ROOT_REL" "$task_slug" "$agent_slug")" + if [[ -n "$matching_dirty_worktree" ]]; then + IFS=$'\t' read -r reused_branch reused_worktree <<<"$matching_dirty_worktree" + echo "[agent-branch-start] Matched dirty managed worktree for requested task." + print_reused_agent_worktree "$reused_branch" "$reused_worktree" + exit 0 + fi +fi + if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" protected_branches_raw="$(resolve_protected_branches "$repo_root")" diff --git a/test/branch.test.js b/test/branch.test.js index 05194ab..ba68ea1 100644 --- a/test/branch.test.js +++ b/test/branch.test.js @@ -148,6 +148,68 @@ test('agent-branch-start reuses the current agent worktree instead of cloning it ); }); +test('agent-branch-start reuses a single dirty matching managed worktree from the protected checkout', () => { + const { repoDir } = createBootstrappedRepo({ committed: true }); + + let result = runBranchStart(['--tier', 'T1', 'add agents recodee billing sections', '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); + fs.writeFileSync(path.join(firstWorktree, 'billing-note.txt'), 'unfinished billing work\n', 'utf8'); + + result = runBranchStart(['--tier', 'T1', 'continue per user saas billing replacement', 'bot'], repoDir, { + GUARDEX_OPENSPEC_AUTO_INIT: 'true', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Matched dirty managed worktree for requested task/); + assert.match(result.stdout, new RegExp(`Reusing existing branch: ${escapeRegexLiteral(firstBranch)}`)); + assert.equal(extractCreatedWorktree(result.stdout), firstWorktree); + + 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, + 'dirty continuation routing should not create a duplicate agent branch', + ); +}); + +test('agent-branch-start creates a fresh branch when dirty matching worktrees are ambiguous', () => { + const { repoDir } = createBootstrappedRepo({ committed: true }); + + let result = runBranchStart(['--tier', 'T1', 'billing alpha implementation', 'bot'], repoDir, { + GUARDEX_OPENSPEC_AUTO_INIT: 'true', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const alphaWorktree = extractCreatedWorktree(result.stdout); + fs.writeFileSync(path.join(alphaWorktree, 'alpha-billing-note.txt'), 'unfinished alpha billing work\n', 'utf8'); + + result = runBranchStart(['--tier', 'T1', 'billing beta implementation', 'bot'], repoDir, { + GUARDEX_OPENSPEC_AUTO_INIT: 'true', + GUARDEX_BRANCH_START_REUSE_EXISTING: 'false', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const betaWorktree = extractCreatedWorktree(result.stdout); + fs.writeFileSync(path.join(betaWorktree, 'beta-billing-note.txt'), 'unfinished beta billing work\n', 'utf8'); + + result = runBranchStart(['--tier', 'T1', 'continue billing implementation', 'bot'], repoDir, { + GUARDEX_OPENSPEC_AUTO_INIT: 'true', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.doesNotMatch(result.stdout, /Matched dirty managed worktree for requested task/); + assert.match(result.stdout, /Created branch: agent\/codex\/continue-billing-implementation-/); + + 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, + 3, + 'ambiguous dirty matches should leave both old branches and create a new explicit lane', + ); +}); + test('agent-branch-start moves protected-branch local changes into the new agent worktree', () => { const repoDir = initRepoOnBranch('main');