diff --git a/.claude/hooks/skill_guard.py b/.claude/hooks/skill_guard.py index 7fbf732..c80cad1 100755 --- a/.claude/hooks/skill_guard.py +++ b/.claude/hooks/skill_guard.py @@ -34,16 +34,24 @@ def emit_event(*_a: object, **_k: object) -> None: SHELL_ENV_PREFIX_RE = re.compile(r"^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)+") SHELL_ALLOWED_SEGMENTS = ( re.compile(r"^(?:cd|pwd|true|false|echo|printf|export|unset|set(?:\s+-[A-Za-z-]+)?)\b"), - re.compile(r"^git\s+(?:status|rev-parse|symbolic-ref|branch|log|show|diff|fetch|remote|config\s+--get|worktree\s+list|ls-files|submodule\s+status)\b"), + re.compile(r"^git\s+(?:status|rev-parse|symbolic-ref|branch|log|show|diff|fetch|remote|config\s+--get|worktree\s+list|ls-files|submodule\s+status|stash\s+(?:list|show))\b"), + # Safe sync: fast-forward / rebase pulls cannot move primary onto a divergent state. + re.compile(r"^git\s+pull(?:\s+--ff-only|\s+--rebase|\s+origin\s+\S+)?\s*$"), + re.compile(r"^git\s+pull\s+--ff-only(?:\s+\S+){0,2}\s*$"), + re.compile(r"^git\s+pull\s+--rebase(?:\s+\S+){0,2}\s*$"), + # Pushing agent/* branches from any cwd is safe — guarded branch namespace. + re.compile(r"^git\s+push(?:\s+(?:-u|--set-upstream))?\s+\S+\s+agent/[^\s]+(?:\s|$)"), + re.compile(r"^git\s+push(?:\s+(?:-u|--set-upstream))?\s+\S+\s+HEAD:agent/[^\s]+(?:\s|$)"), re.compile( - r"^gh\s+(?:auth\s+status|repo\s+view|pr\s+(?:list|view|checks|status)|issue\s+(?:list|view|status)|run\s+(?:list|view))\b" + r"^gh\s+(?:auth\s+status|repo\s+view|pr\s+(?:list|view|checks|status|create|edit|comment|review|ready|reopen|merge)|issue\s+(?:list|view|status|create|comment)|run\s+(?:list|view|watch)|workflow\s+(?:list|view|run))\b" ), re.compile(r"^git\s+(?:checkout|switch)\s+agent/[^\s]+(?:\s|$)"), re.compile(r"^(?:ls|cat|head|tail|wc|nl|sed\s+-n|rg|find|stat|du|df|ps|ss|which|command\s+-v)\b"), - re.compile(r"^(?:guardex|guardex)\s+(?:status|scan)\b"), + # All gitguardex CLI subcommands are themselves safety-aware; trust them on protected branches. + re.compile(r"^(?:gx|guardex|gitguardex|multiagent-safety)\s+\S+\b"), re.compile(r"^python3?\s+scripts/(?:agent-file-locks\.py|main_rs_lock\.py)\s+(?:status|list|validate)\b"), re.compile( - r"^(?:bash\s+)?(?:(?:\.{1,2}/)?scripts|(?:/|~)[^\s]*/scripts)/(?:agent-branch-start\.sh|codex-agent\.sh|install-agent-git-hooks\.sh)\b" + r"^(?:bash\s+)?(?:(?:\.{1,2}/)?scripts|(?:/|~)[^\s]*/scripts)/(?:agent-branch-start\.sh|agent-branch-finish\.sh|agent-pivot\.sh|codex-agent\.sh|install-agent-git-hooks\.sh)\b" ), ) @@ -288,11 +296,13 @@ def ensure_protected_branch_edit_allowed(file_path: str) -> str | None: return ( f"BLOCKED: Agent edit attempted on {blocked_scope}.\n" - "Agent edits must run from isolated agent/* branches.\n" - "Create a sandbox branch/worktree first:\n" + "Auto-pivot to an isolated agent worktree (single command, dirty tree migrates with you):\n" + ' gx pivot "" ""\n' + "Then `cd` into the printed WORKTREE_PATH and retry the edit.\n" + "Equivalent legacy form:\n" ' bash scripts/agent-branch-start.sh "" ""\n' - "If you intentionally need a one-off protected-branch edit, set:\n" - f" {PROTECTED_BRANCH_EDIT_OVERRIDE_ENV}=1" + "Override (must be exported in the harness env, not as a command prefix):\n" + f" export {PROTECTED_BRANCH_EDIT_OVERRIDE_ENV}=1" ) @@ -392,11 +402,14 @@ def ensure_non_agent_shell_command_allowed(repo_root: Path, command: str) -> str preview = command.strip().splitlines()[0][:180] return ( f"BLOCKED: Shell command may mutate files on {blocked_scope}.\n" - "Start isolated agent work first:\n" + "Auto-pivot to an isolated agent worktree (single command, dirty tree migrates with you):\n" + ' gx pivot "" ""\n' + "Then `cd` into the printed WORKTREE_PATH and retry from there.\n" + "Equivalent legacy form:\n" ' bash scripts/agent-branch-start.sh "" ""\n' f"Command preview: {preview}\n" - "Temporary bypass (not recommended):\n" - f" {SHELL_GUARD_OVERRIDE_ENV}=1" + "Override (must be exported in the harness env, not as a command prefix):\n" + f" export {SHELL_GUARD_OVERRIDE_ENV}=1" ) diff --git a/.codex/hooks/skill_guard.py b/.codex/hooks/skill_guard.py index 7fbf732..c80cad1 100755 --- a/.codex/hooks/skill_guard.py +++ b/.codex/hooks/skill_guard.py @@ -34,16 +34,24 @@ def emit_event(*_a: object, **_k: object) -> None: SHELL_ENV_PREFIX_RE = re.compile(r"^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)+") SHELL_ALLOWED_SEGMENTS = ( re.compile(r"^(?:cd|pwd|true|false|echo|printf|export|unset|set(?:\s+-[A-Za-z-]+)?)\b"), - re.compile(r"^git\s+(?:status|rev-parse|symbolic-ref|branch|log|show|diff|fetch|remote|config\s+--get|worktree\s+list|ls-files|submodule\s+status)\b"), + re.compile(r"^git\s+(?:status|rev-parse|symbolic-ref|branch|log|show|diff|fetch|remote|config\s+--get|worktree\s+list|ls-files|submodule\s+status|stash\s+(?:list|show))\b"), + # Safe sync: fast-forward / rebase pulls cannot move primary onto a divergent state. + re.compile(r"^git\s+pull(?:\s+--ff-only|\s+--rebase|\s+origin\s+\S+)?\s*$"), + re.compile(r"^git\s+pull\s+--ff-only(?:\s+\S+){0,2}\s*$"), + re.compile(r"^git\s+pull\s+--rebase(?:\s+\S+){0,2}\s*$"), + # Pushing agent/* branches from any cwd is safe — guarded branch namespace. + re.compile(r"^git\s+push(?:\s+(?:-u|--set-upstream))?\s+\S+\s+agent/[^\s]+(?:\s|$)"), + re.compile(r"^git\s+push(?:\s+(?:-u|--set-upstream))?\s+\S+\s+HEAD:agent/[^\s]+(?:\s|$)"), re.compile( - r"^gh\s+(?:auth\s+status|repo\s+view|pr\s+(?:list|view|checks|status)|issue\s+(?:list|view|status)|run\s+(?:list|view))\b" + r"^gh\s+(?:auth\s+status|repo\s+view|pr\s+(?:list|view|checks|status|create|edit|comment|review|ready|reopen|merge)|issue\s+(?:list|view|status|create|comment)|run\s+(?:list|view|watch)|workflow\s+(?:list|view|run))\b" ), re.compile(r"^git\s+(?:checkout|switch)\s+agent/[^\s]+(?:\s|$)"), re.compile(r"^(?:ls|cat|head|tail|wc|nl|sed\s+-n|rg|find|stat|du|df|ps|ss|which|command\s+-v)\b"), - re.compile(r"^(?:guardex|guardex)\s+(?:status|scan)\b"), + # All gitguardex CLI subcommands are themselves safety-aware; trust them on protected branches. + re.compile(r"^(?:gx|guardex|gitguardex|multiagent-safety)\s+\S+\b"), re.compile(r"^python3?\s+scripts/(?:agent-file-locks\.py|main_rs_lock\.py)\s+(?:status|list|validate)\b"), re.compile( - r"^(?:bash\s+)?(?:(?:\.{1,2}/)?scripts|(?:/|~)[^\s]*/scripts)/(?:agent-branch-start\.sh|codex-agent\.sh|install-agent-git-hooks\.sh)\b" + r"^(?:bash\s+)?(?:(?:\.{1,2}/)?scripts|(?:/|~)[^\s]*/scripts)/(?:agent-branch-start\.sh|agent-branch-finish\.sh|agent-pivot\.sh|codex-agent\.sh|install-agent-git-hooks\.sh)\b" ), ) @@ -288,11 +296,13 @@ def ensure_protected_branch_edit_allowed(file_path: str) -> str | None: return ( f"BLOCKED: Agent edit attempted on {blocked_scope}.\n" - "Agent edits must run from isolated agent/* branches.\n" - "Create a sandbox branch/worktree first:\n" + "Auto-pivot to an isolated agent worktree (single command, dirty tree migrates with you):\n" + ' gx pivot "" ""\n' + "Then `cd` into the printed WORKTREE_PATH and retry the edit.\n" + "Equivalent legacy form:\n" ' bash scripts/agent-branch-start.sh "" ""\n' - "If you intentionally need a one-off protected-branch edit, set:\n" - f" {PROTECTED_BRANCH_EDIT_OVERRIDE_ENV}=1" + "Override (must be exported in the harness env, not as a command prefix):\n" + f" export {PROTECTED_BRANCH_EDIT_OVERRIDE_ENV}=1" ) @@ -392,11 +402,14 @@ def ensure_non_agent_shell_command_allowed(repo_root: Path, command: str) -> str preview = command.strip().splitlines()[0][:180] return ( f"BLOCKED: Shell command may mutate files on {blocked_scope}.\n" - "Start isolated agent work first:\n" + "Auto-pivot to an isolated agent worktree (single command, dirty tree migrates with you):\n" + ' gx pivot "" ""\n' + "Then `cd` into the printed WORKTREE_PATH and retry from there.\n" + "Equivalent legacy form:\n" ' bash scripts/agent-branch-start.sh "" ""\n' f"Command preview: {preview}\n" - "Temporary bypass (not recommended):\n" - f" {SHELL_GUARD_OVERRIDE_ENV}=1" + "Override (must be exported in the harness env, not as a command prefix):\n" + f" export {SHELL_GUARD_OVERRIDE_ENV}=1" ) diff --git a/openspec/changes/agent-claude-harden-claude-pivot-2026-04-27-09-28/.openspec.yaml b/openspec/changes/agent-claude-harden-claude-pivot-2026-04-27-09-28/.openspec.yaml new file mode 100644 index 0000000..1b4051e --- /dev/null +++ b/openspec/changes/agent-claude-harden-claude-pivot-2026-04-27-09-28/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-27 diff --git a/openspec/changes/agent-claude-harden-claude-pivot-2026-04-27-09-28/proposal.md b/openspec/changes/agent-claude-harden-claude-pivot-2026-04-27-09-28/proposal.md new file mode 100644 index 0000000..47d4a26 --- /dev/null +++ b/openspec/changes/agent-claude-harden-claude-pivot-2026-04-27-09-28/proposal.md @@ -0,0 +1,62 @@ +## Why + +Claude Code (and Codex) sessions could not recover from a protected-branch +mutation block on their own. The PreToolUse `skill_guard` hook blocks `Edit`, +`Write`, and most `Bash` mutations on `dev`/`main`/`master`, and the documented +escape was to set `ALLOW_BASH_ON_NON_AGENT_BRANCH=1` / +`ALLOW_CODE_EDIT_ON_PROTECTED_BRANCH=1` in the harness env. AI agents cannot +mutate harness env from inside a tool call, so they were forced to stop and ask +the user to run shell commands manually — every time the human pivoted between +`main`/`dev` and an agent branch, or wanted Claude to keep going across a +PR/merge cycle. + +The whitelist also rejected safe sync ops (`git pull --ff-only`, `git stash +list`, agent-only `git push`, `gh pr create/merge`, and direct `gx` +subcommands), which made even pure-read recovery commands fail. + +## What Changes + +- Widen `SHELL_ALLOWED_SEGMENTS` in `.claude/hooks/skill_guard.py` and + `.codex/hooks/skill_guard.py` to allow: + - `git pull` / `git pull --ff-only [...]` / `git pull --rebase [...]` (safe + fast-forward sync of the protected branch the user is on). + - `git stash list` and `git stash show` (read-only stash inspection). + - `git push [origin] agent/...` and `git push HEAD:agent/...` (only the + `agent/*` ref namespace is permitted from primary). + - The full `gh pr` / `gh issue` / `gh workflow` action surface (PR ops are + safe — they affect remote, not local files). + - Any `gx` / `guardex` / `gitguardex` / `multiagent-safety` subcommand (the + CLI itself enforces guardrails internally). + - Direct invocation of `scripts/agent-branch-finish.sh` and + `scripts/agent-pivot.sh`. +- Update both `BLOCKED` messages (`ensure_protected_branch_edit_allowed`, + `ensure_non_agent_shell_command_allowed`) to point Claude at a single + copy-pastable command (`gx pivot "" ""`) that does the + whole hop — branch + worktree creation, dirty-tree migration, and a clean + machine-parseable trailer (`WORKTREE_PATH=...`, `BRANCH=...`, `NEXT_STEP=cd + "..."`) the agent can parse to know exactly where to `cd`. +- Add `gx pivot "" "" [--tier T0|T1|T2|T3]`. On a protected + branch, it forwards to `agent-branch-start.sh` (which already migrates dirty + changes), then echoes the trailer. On an existing `agent/*` branch it + short-circuits with the current worktree path — safe to call as a no-op. +- Add `gx ship` — alias for `gx finish --via-pr --wait-for-merge --cleanup`, + injecting any of those flags the caller forgot. Encodes the + "Default Claude finish (non-negotiable)" rule from `AGENTS.md` so AI agents + cannot accidentally strand commits or worktrees. + +## Impact + +- Affects: PreToolUse hook regex + block messages (Claude + Codex variants), + `gx` CLI dispatch (new `pivot` and `ship` subcommands), help output, and + command-suggestion list. +- Risk: hook regex change is additive — it only widens the allow list. No + previously-allowed command becomes blocked. New writable patterns + (`git pull --ff-only`, `git push origin agent/...`, `gh pr create/merge`, + `gx `) are scoped so they cannot mutate protected branches + directly. +- Rollout: ship as a normal `gx` patch release; downstream repos pick the + hook change up via `gx setup --repair` (hooks live under `.claude/hooks/` + and `.codex/hooks/`, not in templates yet — follow-up: copy-on-setup). +- Coverage: new `test/pivot.test.js` covers protected-branch -> agent + worktree pivot and the existing-worktree short-circuit. Whitelist regex is + exercised inline by a Python self-test. diff --git a/openspec/changes/agent-claude-harden-claude-pivot-2026-04-27-09-28/specs/general-behavior/spec.md b/openspec/changes/agent-claude-harden-claude-pivot-2026-04-27-09-28/specs/general-behavior/spec.md new file mode 100644 index 0000000..4c4e262 --- /dev/null +++ b/openspec/changes/agent-claude-harden-claude-pivot-2026-04-27-09-28/specs/general-behavior/spec.md @@ -0,0 +1,85 @@ +## ADDED Requirements + +### Requirement: `gx pivot` provides a single-tool-call escape from a protected branch + +The system SHALL expose a `gx pivot "" "" [--tier T0|T1|T2|T3]` +subcommand that AI agents can call from a protected (`dev`/`main`/`master`) or +non-`agent/*` branch to obtain an isolated agent worktree. + +#### Scenario: Pivot from a protected branch + +- **WHEN** `gx pivot "" ""` is invoked on a non-`agent/*` branch +- **THEN** the command SHALL forward to the existing `agent-branch-start.sh` + flow (which migrates dirty primary-tree changes via auto-stash) and create a + new `agent//` branch + worktree +- **AND** stdout SHALL include three machine-parseable trailer lines: + `WORKTREE_PATH=`, `BRANCH=`, and + `NEXT_STEP=cd ""` +- **AND** the exit code SHALL be `0`. + +#### Scenario: Pivot is a no-op on an existing agent branch + +- **WHEN** `gx pivot` is invoked from inside an `agent/*` worktree +- **THEN** the command SHALL print `Already on agent branch ''.` plus + the same `WORKTREE_PATH=` / `BRANCH=` / `NEXT_STEP=cd "..."` trailer pointing + at the current worktree +- **AND** the command SHALL NOT create a new branch or worktree +- **AND** the exit code SHALL be `0`. + +### Requirement: `gx ship` defaults to the canonical "I am done" finish flags + +The system SHALL expose a `gx ship` subcommand that aliases `gx finish` while +ensuring `--via-pr`, `--wait-for-merge`, and `--cleanup` are always present. + +#### Scenario: Missing flags are injected + +- **WHEN** `gx ship --branch agent/claude/foo` is invoked +- **THEN** `gx finish` SHALL receive `--branch agent/claude/foo --via-pr + --wait-for-merge --cleanup` +- **AND** flags already supplied by the caller SHALL NOT be duplicated. + +### Requirement: `skill_guard` allows safe sync ops on protected branches + +The system SHALL allow the following commands to run from non-`agent/*` +branches without setting `ALLOW_BASH_ON_NON_AGENT_BRANCH=1`: + +- `git pull`, `git pull --ff-only [...]`, `git pull --rebase [...]` +- `git stash list`, `git stash show` +- `git push [origin] agent/` and `git push [origin] HEAD:agent/` + (only the `agent/*` ref namespace) +- `gh pr {list,view,checks,status,create,edit,comment,review,ready,reopen,merge}`, + `gh issue {list,view,status,create,comment}`, + `gh run {list,view,watch}`, `gh workflow {list,view,run}` +- Any subcommand of `gx`, `guardex`, `gitguardex`, or `multiagent-safety` +- `bash scripts/agent-branch-finish.sh ...`, + `bash scripts/agent-pivot.sh ...` + +#### Scenario: Pure sync command on protected branch is allowed + +- **WHEN** the current branch is `main` and `git pull --ff-only origin main` is + invoked through Claude Code's `Bash` tool +- **THEN** the `skill_guard` PreToolUse hook SHALL exit `0` without printing a + `BLOCKED:` message. + +#### Scenario: Destructive command on protected branch is still blocked + +- **WHEN** the current branch is `main` and `git reset --hard HEAD` is invoked + through Claude Code's `Bash` tool +- **THEN** the `skill_guard` PreToolUse hook SHALL exit `2` with a `BLOCKED:` + message that points the agent at `gx pivot "" ""`. + +### Requirement: BLOCKED messages name the auto-pivot escape first + +The system SHALL update both `ensure_protected_branch_edit_allowed` (Edit / +Write / patch tools) and `ensure_non_agent_shell_command_allowed` (Bash) to +mention `gx pivot "" ""` as the recommended single-tool-call +recovery, and clarify that the override env (`ALLOW_BASH_ON_NON_AGENT_BRANCH`, +`ALLOW_CODE_EDIT_ON_PROTECTED_BRANCH`) must be exported in the harness env, +not as a command prefix inside a tool call. + +#### Scenario: Block message instructs `gx pivot` + +- **WHEN** Claude Code attempts an `Edit` on a protected branch +- **THEN** the `BLOCKED:` message SHALL contain the literal substring + `gx pivot "" ""` and the literal substring `export ` + prefixing the override env name. diff --git a/openspec/changes/agent-claude-harden-claude-pivot-2026-04-27-09-28/tasks.md b/openspec/changes/agent-claude-harden-claude-pivot-2026-04-27-09-28/tasks.md new file mode 100644 index 0000000..d7058ec --- /dev/null +++ b/openspec/changes/agent-claude-harden-claude-pivot-2026-04-27-09-28/tasks.md @@ -0,0 +1,39 @@ +## 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-claude-harden-claude-pivot-2026-04-27-09-28`; branch=`agent/claude/harden-claude-pivot-2026-04-27-09-28`; scope=`Hook whitelist + gx pivot / gx ship`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-claude-harden-claude-pivot-2026-04-27-09-28` on branch `agent/claude/harden-claude-pivot-2026-04-27-09-28`. Work inside the existing sandbox, review `openspec/changes/agent-claude-harden-claude-pivot-2026-04-27-09-28/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/claude/harden-claude-pivot-2026-04-27-09-28 --base main --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-claude-harden-claude-pivot-2026-04-27-09-28`. +- [x] 1.2 Define normative requirements in `specs/general-behavior/spec.md`. + +## 2. Implementation + +- [x] 2.1 Widen `SHELL_ALLOWED_SEGMENTS` in `.claude/hooks/skill_guard.py` and `.codex/hooks/skill_guard.py` to allow safe sync ops, agent-only push, full `gh pr` surface, `gx `, and `agent-branch-finish.sh` / `agent-pivot.sh`. +- [x] 2.2 Update both BLOCKED messages to point at `gx pivot "" ""` as the single-tool-call escape and clarify the override env must be exported in the harness, not as a command prefix. +- [x] 2.3 Add `gx pivot` CLI command in `src/cli/main.js` (forwards to `branchStart`; emits `WORKTREE_PATH=` / `BRANCH=` / `NEXT_STEP=` trailer; short-circuits on existing `agent/*` branches). +- [x] 2.4 Add `gx ship` CLI command (alias for `gx finish --via-pr --wait-for-merge --cleanup`, injects missing flags). +- [x] 2.5 Register `pivot` + `ship` in `SUGGESTIBLE_COMMANDS` and the `Branch workflow` help group in `src/context.js`. + +## 3. Verification + +- [x] 3.1 Run `node --test test/pivot.test.js` (2 new tests: protected-branch pivot + agent-branch short-circuit). +- [x] 3.2 Run inline regex self-test (19/19 cases pass for whitelist allow/deny). +- [x] 3.3 Run `npm test` baseline (without `CLAUDECODE` env): 277 pass / 2 pre-existing failures unrelated to this change (`agent-branch-finish auto-commits parent gitlink after nested repo finish`, `setup refreshes initialized protected main through a sandbox and prunes it` — caused by submodule timing and system git's lack of `worktree --orphan`). +- [x] 3.4 Run `openspec validate agent-claude-harden-claude-pivot-2026-04-27-09-28 --type change --strict`. +- [x] 3.5 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/claude/harden-claude-pivot-2026-04-27-09-28 --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/src/cli/main.js b/src/cli/main.js index b009dcc..7060681 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -3380,6 +3380,64 @@ function branch(rawArgs) { ); } +// `gx pivot` — single-tool-call escape from a protected branch into an isolated +// agent worktree. AI agents (Claude Code / Codex) cannot set the bypass env +// vars from inside a tool call, so they need a whitelisted command that does +// the whole hop: branch+worktree creation, dirty-tree migration, and a clean +// trailer (`WORKTREE_PATH=...`, `BRANCH=...`, `NEXT_STEP=cd ...`) the agent can +// parse to know exactly where to `cd`. +// +// On an existing agent/* branch, `gx pivot` short-circuits and just prints the +// current worktree path — safe to call as a no-op. +function pivot(rawArgs) { + const { target, passthrough } = extractTargetedArgs(rawArgs); + const repoRoot = resolveRepoRoot(target); + const headProc = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoRoot }); + const currentBranch = String(headProc.stdout || '').trim(); + if (currentBranch.startsWith('agent/')) { + const wtProc = run('git', ['rev-parse', '--show-toplevel'], { cwd: repoRoot }); + const wtPath = String(wtProc.stdout || '').trim() || repoRoot; + process.stdout.write(`[${TOOL_NAME} pivot] Already on agent branch '${currentBranch}'.\n`); + process.stdout.write(`WORKTREE_PATH=${wtPath}\n`); + process.stdout.write(`BRANCH=${currentBranch}\n`); + process.stdout.write(`NEXT_STEP=cd "${wtPath}"\n`); + process.exitCode = 0; + return; + } + const result = runPackageAsset('branchStart', passthrough, { cwd: repoRoot }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + if (result.status !== 0) { + process.exitCode = result.status || 1; + return; + } + 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); + if (wtMatch) { + const wtPath = wtMatch[1].trim(); + process.stdout.write('\n'); + process.stdout.write(`WORKTREE_PATH=${wtPath}\n`); + if (branchMatch) process.stdout.write(`BRANCH=${branchMatch[1].trim()}\n`); + process.stdout.write(`NEXT_STEP=cd "${wtPath}"\n`); + } + process.exitCode = 0; +} + +// `gx ship` — alias for the canonical "I am done" command. Defaults to +// `finish --via-pr --wait-for-merge --cleanup` so AI agents don't strand +// commits or worktrees by accident. Any explicit user-supplied flags survive. +function ship(rawArgs) { + const args = Array.isArray(rawArgs) ? rawArgs.slice() : []; + const ensureFlag = (flag) => { + if (!args.includes(flag)) args.push(flag); + }; + ensureFlag('--via-pr'); + ensureFlag('--wait-for-merge'); + ensureFlag('--cleanup'); + return finish(args); +} + function locks(rawArgs) { const { target, passthrough } = extractTargetedArgs(rawArgs); const result = runPackageAsset('lockTool', passthrough, { cwd: resolveRepoRoot(target) }); @@ -3637,6 +3695,8 @@ async function main() { if (command === 'prompt') return prompt(rest); if (command === 'doctor') return doctor(rest); if (command === 'branch') return branch(rest); + if (command === 'pivot') return pivot(rest); + if (command === 'ship') return ship(rest); if (command === 'locks') return locks(rest); if (command === 'worktree') return worktree(rest); if (command === 'hook') return hook(rest); diff --git a/src/context.js b/src/context.js index 36b847b..181578b 100644 --- a/src/context.js +++ b/src/context.js @@ -338,6 +338,8 @@ const SUGGESTIBLE_COMMANDS = [ 'setup', 'doctor', 'branch', + 'pivot', + 'ship', 'locks', 'worktree', 'hook', @@ -383,7 +385,9 @@ const CLI_COMMAND_GROUPS = [ label: 'Branch workflow', description: 'The sandbox → commit → PR → merge loop for agent-owned branches.', commands: [ + ['pivot', 'Auto-pivot from a protected branch into a fresh agent worktree (single tool call for AI agents)'], ['branch', 'CLI-owned branch workflow surface (start/finish/merge)'], + ['ship', 'Stage + commit + push + PR + auto-merge + cleanup (alias for `finish --via-pr --wait-for-merge --cleanup`)'], ['finish', 'Commit + PR + merge completed agent branches (--all, --branch)'], ['merge', 'Create/reuse an integration lane and merge overlapping agent branches'], ['sync', 'Sync agent branches with origin/'], diff --git a/test/pivot.test.js b/test/pivot.test.js new file mode 100644 index 0000000..4e3ad67 --- /dev/null +++ b/test/pivot.test.js @@ -0,0 +1,91 @@ +const { + test, + assert, + fs, + path, + cp, + canSpawnChildProcesses, + spawnUnavailableReason, + runNode, + runCmd, + initRepoOnBranch, + attachOriginRemoteForBranch, + seedCommit, + extractCreatedWorktree, + extractCreatedBranch, +} = require('./helpers/install-test-helpers'); + +if (!canSpawnChildProcesses) { + test.skip(`pivot test skipped: ${spawnUnavailableReason}`, () => {}); +} else { + test('gx pivot from a protected branch creates an agent worktree and emits machine-parseable trailer', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + attachOriginRemoteForBranch(repoDir, 'main'); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runCmd('git', ['add', '.'], repoDir); + assert.equal(result.status, 0, result.stderr); + result = runCmd('git', ['commit', '-m', 'apply gx setup'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['push', 'origin', 'main'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runNode(['pivot', 'pivot-smoke', 'claude-test', '--target', repoDir], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const worktreePathMatch = result.stdout.match(/^WORKTREE_PATH=(.+)$/m); + const branchMatch = result.stdout.match(/^BRANCH=(.+)$/m); + const nextStepMatch = result.stdout.match(/^NEXT_STEP=cd "(.+)"$/m); + assert.ok(worktreePathMatch, `expected WORKTREE_PATH= trailer in output:\n${result.stdout}`); + assert.ok(branchMatch, `expected BRANCH= trailer in output:\n${result.stdout}`); + assert.ok(nextStepMatch, `expected NEXT_STEP= trailer in output:\n${result.stdout}`); + + const wtPath = worktreePathMatch[1].trim(); + const branchName = branchMatch[1].trim(); + assert.equal(nextStepMatch[1].trim(), wtPath); + assert.match(branchName, /^agent\//); + assert.equal(fs.existsSync(wtPath), true, `worktree path should exist: ${wtPath}`); + + const reportedWorktree = extractCreatedWorktree(result.stdout); + assert.equal(reportedWorktree, wtPath); + assert.equal(extractCreatedBranch(result.stdout), branchName); + }); + + test('gx pivot inside an existing agent worktree short-circuits without creating a new branch', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + attachOriginRemoteForBranch(repoDir, 'main'); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['add', '.'], repoDir); + assert.equal(result.status, 0, result.stderr); + result = runCmd('git', ['commit', '-m', 'apply gx setup'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['push', 'origin', 'main'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runNode(['pivot', 'pivot-existing', 'claude-test', '--target', repoDir], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + const wtPath = result.stdout.match(/^WORKTREE_PATH=(.+)$/m)[1].trim(); + const branchName = result.stdout.match(/^BRANCH=(.+)$/m)[1].trim(); + + // Re-invoke pivot from inside the new worktree — should short-circuit. + const second = runNode(['pivot'], wtPath); + assert.equal(second.status, 0, second.stderr || second.stdout); + assert.match(second.stdout, /Already on agent branch/); + const secondPath = second.stdout.match(/^WORKTREE_PATH=(.+)$/m); + const secondBranch = second.stdout.match(/^BRANCH=(.+)$/m); + assert.ok(secondPath); + assert.ok(secondBranch); + assert.equal(secondBranch[1].trim(), branchName); + assert.equal(path.resolve(secondPath[1].trim()), path.resolve(wtPath)); + }); +}