Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-27
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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/<same-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 "<task>" "<same-agent>"` 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.
Original file line number Diff line number Diff line change
@@ -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).
105 changes: 105 additions & 0 deletions scripts/agent-branch-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")"
Expand Down
105 changes: 105 additions & 0 deletions templates/scripts/agent-branch-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")"
Expand Down
62 changes: 62 additions & 0 deletions test/branch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading