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-06-24
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Why

- The primary-checkout post-checkout guard can currently auto-stash dirty work
as `guardex-auto-revert` and switch the visible checkout back to the protected
branch. In shared agent workflows that can move another agent's or the user's
uncommitted edits out from under them.

## What Changes

- Keep clean-primary auto-revert behavior for accidental branch switches during
agent sessions.
- For dirty primary checkouts, print the existing guard warning plus a manual
recovery hint, but do not run `git stash` and do not switch branches again.
- Update the agent-facing warning text and regression coverage for the dirty
primary checkout path.

## Impact

- Affected surfaces: `gx hook run post-checkout`, installed hook shims that
dispatch to it, MCP primary-checkout warnings, and the full AGENTS contract
template.
- Risk: a dirty primary checkout remains on the newly selected branch until the
user/agent recovers manually. This is intentional because preserving
uncommitted work is safer than moving it automatically.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## MODIFIED Requirements

### Requirement: Primary checkout cannot be silently switched during agent sessions
The `post-checkout` hook SHALL print a `[agent-primary-branch-guard]` warning
when the primary working tree (where `git-dir == git-common-dir`) is switched
AWAY from a protected branch (`main`, `dev`, `master`, or any branch listed
in `multiagent.protectedBranches`) during an agent session. Agent sessions
are detected via the presence of any of: `CLAUDECODE`,
`CLAUDE_CODE_SESSION_ID`, `CODEX_THREAD_ID`, `OMX_SESSION_ID`, or
`CODEX_CI=1`. If the working tree is clean, the hook SHALL auto-revert the
primary checkout to the previous protected branch. If the working tree is
dirty, the hook SHALL NOT stash changes and SHALL NOT switch branches again.

#### Scenario: Agent session triggers auto-revert on clean tree
- **GIVEN** the primary checkout is on `main` and the tree is clean
- **AND** `CLAUDECODE=1` is exported
- **WHEN** the user or an agent runs `git checkout -b feature/x`
- **THEN** the `[agent-primary-branch-guard]` warning appears on stderr
- **AND** the primary checkout is returned to `main`.

#### Scenario: Dirty tree skips auto-revert
- **GIVEN** the primary checkout is on `main` with uncommitted edits
- **AND** an agent session is detected
- **WHEN** `git checkout feature/x` runs
- **THEN** the hook prints a `Working tree dirty — auto-revert skipped`
message with a manual recovery hint
- **AND** the branch is NOT reverted so no uncommitted work is lost
- **AND** no `guardex-auto-revert` stash is created.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## 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-prevent-dirty-primary-auto-revert-2026-06-24-14-44`; branch=`agent/codex/prevent-dirty-primary-auto-revert-2026-06-24-14-44`; scope=`post-checkout dirty primary tree preservation`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`.
- Copy prompt: Continue `agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44` on branch `agent/codex/prevent-dirty-primary-auto-revert-2026-06-24-14-44`. Work inside the existing sandbox, review `openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/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/prevent-dirty-primary-auto-revert-2026-06-24-14-44 --base main --via-pr --wait-for-merge --cleanup`.

## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44`.
- [x] 1.2 Define normative requirements in `specs/primary-checkout-immutability/spec.md`.

## 2. Implementation

- [x] 2.1 Implement scoped behavior changes.
- [x] 2.2 Add/update focused regression coverage.

## 3. Verification

- [x] 3.1 Run targeted project verification commands.
Evidence: `bash -n templates/githooks/post-checkout`; `node --check src/mcp/collect.js`; `node --test --test-name-pattern "post-checkout guard skips auto-revert" test/branch.test.js`.
- [x] 3.2 Run `openspec validate agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44 --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/prevent-dirty-primary-auto-revert-2026-06-24-14-44 --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).
2 changes: 1 addition & 1 deletion src/mcp/collect.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ function buildAgentRecord(mainRoot, wt, locks, prInfo, nowMs) {
&& record.dirty.length === 0;
if (wt.isPrimary) {
record.warning =
'on the PRIMARY checkout, not an isolated worktree — edits here risk auto-stash/revert when another lane switches branches. Use `gx branch start`.';
'on the PRIMARY checkout, not an isolated worktree — clean branch switches may be auto-reverted, while dirty work is left in place for manual recovery. Use `gx branch start`.';
}
return record;
}
Expand Down
4 changes: 1 addition & 3 deletions templates/AGENTS.multiagent-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,7 @@ If you are about to type `git checkout agent/...` from the primary checkout, **s

### Dirty-tree rule

Finish or stash edits inside the worktree they belong to before any branch switch on primary. The post-checkout guard may auto-stash a dirty primary tree as `guardex-auto-revert <ts> <prev>-><new>` — that is a safety net, not a workflow.

Recover: `git stash list | grep 'guardex-auto-revert'`.
Finish or stash edits inside the worktree they belong to before any branch switch on primary. The post-checkout guard auto-reverts only a clean primary tree. If the primary tree is dirty, it leaves the branch and files in place and prints a manual recovery hint instead of stashing or reverting someone else's work.

### Ownership

Expand Down
13 changes: 4 additions & 9 deletions templates/githooks/post-checkout
Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,15 @@ echo "[agent-primary-branch-guard] The primary working tree must stay on its bas
echo "[agent-primary-branch-guard] Use 'git worktree add' (or gx branch start) for feature work." >&2

if [[ "$is_agent" == "1" ]]; then
echo "[agent-primary-branch-guard] Agent session detected — reverting to '$prev_branch'." >&2
echo "[agent-primary-branch-guard] Agent session detected." >&2
echo "[agent-primary-branch-guard] Bypass with GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1 if truly intentional." >&2
if git diff --quiet && git diff --cached --quiet; then
echo "[agent-primary-branch-guard] Clean tree detected — reverting to '$prev_branch'." >&2
GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1 git checkout "$prev_branch" >/dev/null 2>&1 || true
echo "[agent-primary-branch-guard] Reverted to '$prev_branch'." >&2
else
stash_msg="guardex-auto-revert $(date +%s) ${prev_branch}->${new_branch}"
if git stash push --include-untracked -m "$stash_msg" >/dev/null 2>&1; then
GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1 git checkout "$prev_branch" >/dev/null 2>&1 || true
echo "[agent-primary-branch-guard] Dirty tree auto-stashed as '$stash_msg'; primary reverted to '$prev_branch'." >&2
echo "[agent-primary-branch-guard] Restore later with: git stash list | grep 'guardex-auto-revert'" >&2
else
echo "[agent-primary-branch-guard] Auto-stash failed; working tree left on '$new_branch'. Fix manually: git stash -u && git checkout $prev_branch" >&2
fi
echo "[agent-primary-branch-guard] Working tree dirty — auto-revert skipped to avoid moving uncommitted work." >&2
echo "[agent-primary-branch-guard] Primary checkout left on '$new_branch'. Commit or stash the changes in the correct worktree, then recover manually with: GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1 git checkout '$prev_branch'" >&2
fi
else
echo "[agent-primary-branch-guard] Bypass with GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1 if intentional." >&2
Expand Down
29 changes: 29 additions & 0 deletions test/branch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -954,6 +954,35 @@ test('repo .env GUARDEX_ON=false disables bootstrap scripts and git hook enforce
});


test('post-checkout guard skips auto-revert and auto-stash when primary tree is dirty', () => {
const { repoDir } = createBootstrappedRepo({ branch: 'main', committed: true });

const packageJsonPath = path.join(repoDir, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.description = 'dirty primary edit';
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8');
fs.writeFileSync(path.join(repoDir, 'untracked-primary.txt'), 'untracked primary work\n', 'utf8');

const checkoutResult = runCmd('git', ['checkout', '-b', 'feature/dirty-primary'], repoDir, {
CODEX_THREAD_ID: 'test-thread',
});
assert.equal(checkoutResult.status, 0, checkoutResult.stderr || checkoutResult.stdout);
assert.match(checkoutResult.stderr, /Working tree dirty .* auto-revert skipped/);

const currentBranch = runCmd('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repoDir);
assert.equal(currentBranch.status, 0, currentBranch.stderr || currentBranch.stdout);
assert.equal(currentBranch.stdout.trim(), 'feature/dirty-primary');

const currentPackageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
assert.equal(currentPackageJson.description, 'dirty primary edit');
assert.equal(fs.existsSync(path.join(repoDir, 'untracked-primary.txt')), true);

const stashList = runCmd('git', ['stash', 'list'], repoDir);
assert.equal(stashList.status, 0, stashList.stderr || stashList.stdout);
assert.doesNotMatch(stashList.stdout, /guardex-auto-revert/);
});


test('post-merge auto-runs cleanup on base branch and skips non-base branches', () => {
const repoDir = initRepo();
seedCommit(repoDir);
Expand Down
Loading