diff --git a/AGENTS.md b/AGENTS.md index 1a436d6..5889e0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,8 @@ When writing complex features or significant refactors, use an ExecPlan (as desc - Never edit, stage, or commit on `dev` / `main`. Open an `agent/*` branch + worktree first. - Claim files before edits: `gx locks claim --branch "" ` (or Colony `task_claim_file` on an active task). -- Finish completed work with `gx branch finish --branch "" --via-pr --wait-for-merge --cleanup`. Never stop at bare `--via-pr`. +- Finish completed work with `gx branch finish --branch "" --via-pr --wait-for-merge --cleanup`. Never stop at bare `--via-pr`. `gx ship` is the short alias for that exact gated finish. +- When work is complete, always offer to finish and merge it — never leave commits stranded in a worktree. Set `GUARDEX_AUTO_SHIP=1` to make a bare `gx finish` / `gx branch finish` default to that gated ship automatically (see Toggle). - Commit, push, and open/update a PR for completed work unless the user explicitly says to keep it local. - Use OpenSpec for change-driven work; create/update `openspec/changes//` before editing code (helper agent sub-branches excepted). - Keep outputs compact: less word, same proof. @@ -144,6 +145,8 @@ If a change publishes or bumps a package version, the same change must also upda Guardex is enabled by default. Disable via repo-root `.env` with `GUARDEX_ON=0|false|no|off`. Re-enable with `GUARDEX_ON=1`. +**Auto-ship** (opt-in, off by default): set `GUARDEX_AUTO_SHIP=1` to make a bare `gx finish` / `gx branch finish` behave like `gx ship` — open a PR from the worktree, wait for the merge into base, clean up the sandbox, and enforce the merge gate (clean AI review + green CI) before merging. Explicit flags still win (e.g. `--no-gate-review` opts back out of the gate, `--direct-only` out of the PR). via-pr / wait-for-merge / cleanup are already the finish defaults, so the toggle's net effect is turning the gate on so unattended merges stay safe. + ### Core rules - Work from an `agent/*` branch + worktree. **Never** edit the protected base directly. diff --git a/openspec/changes/agent-claude-add-guardex-auto-ship-opt-in-gated-auto-2026-06-29-08-28/.openspec.yaml b/openspec/changes/agent-claude-add-guardex-auto-ship-opt-in-gated-auto-2026-06-29-08-28/.openspec.yaml new file mode 100644 index 0000000..34f9314 --- /dev/null +++ b/openspec/changes/agent-claude-add-guardex-auto-ship-opt-in-gated-auto-2026-06-29-08-28/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/agent-claude-add-guardex-auto-ship-opt-in-gated-auto-2026-06-29-08-28/notes.md b/openspec/changes/agent-claude-add-guardex-auto-ship-opt-in-gated-auto-2026-06-29-08-28/notes.md new file mode 100644 index 0000000..49bb8b4 --- /dev/null +++ b/openspec/changes/agent-claude-add-guardex-auto-ship-opt-in-gated-auto-2026-06-29-08-28/notes.md @@ -0,0 +1,35 @@ +# agent-claude-add-guardex-auto-ship-opt-in-gated-auto-2026-06-29-08-28 (minimal / T1) + +Branch: `agent/claude/add-guardex-auto-ship-opt-in-gated-auto-2026-06-29-08-28` + +Add an opt-in `GUARDEX_AUTO_SHIP=1` env toggle so a bare `gx finish` / `gx branch finish` +defaults to the gated ship (open PR from worktree → wait for merge → cleanup, with the +clean-AI-review + green-CI merge gate). Lowers agent friction without weakening the guardrail: +the only default flipped is `gateReview` (via-pr / wait-for-merge / cleanup are already +defaults), and explicit flags (`--no-gate-review`, `--direct-only`) still override. + +## Files + +- `src/cli/args.js` — `parseFinishArgs` reads `GUARDEX_AUTO_SHIP` (via `envFlagIsTruthy`) and + flips the `gateReview` default to `true` when set. Single insertion point: `gx finish`, + `gx branch finish`, and `gx ship` all resolve options here. +- `test/auto-ship-toggle.test.js` — toggle on → resolves like `gx ship`; explicit `--no-gate-review` + / `--skip-review-gate` win; toggle unset/falsy → defaults unchanged. +- `AGENTS.md` — documents the toggle + `gx ship` short form, and adds the "always offer to + finish/merge" contract line. + +## Verification + +- `node --test test/auto-ship-toggle.test.js` → 4/4 pass. +- `npm test` → 718 pass / 27 fail / 1 skip; failing set byte-identical to base `main` (27 = 27, + zero new failures — repo's `test` job is baseline-red). + +## Handoff + +- Handoff: change=`agent-claude-add-guardex-auto-ship-opt-in-gated-auto-2026-06-29-08-28`; branch=`agent/claude/add-guardex-auto-ship-opt-in-gated-auto-2026-06-29-08-28`; scope=`opt-in GUARDEX_AUTO_SHIP gated-finish toggle + docs`; action=`finish via gated PR`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent/claude/add-guardex-auto-ship-opt-in-gated-auto-2026-06-29-08-28 --base main --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/src/cli/args.js b/src/cli/args.js index 7dcb964..d4424fe 100644 --- a/src/cli/args.js +++ b/src/cli/args.js @@ -2,6 +2,7 @@ const { path, DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES, TARGETED_FORCEABLE_MANAGED_PATHS, + envFlagIsTruthy, } = require('../context'); const { DEFAULT_NESTED_REPO_MAX_DEPTH } = require('../git'); @@ -1140,6 +1141,13 @@ function parseMergeArgs(rawArgs) { } function parseFinishArgs(rawArgs, defaults = {}) { + // `GUARDEX_AUTO_SHIP=1` (opt-in) makes a bare `gx finish` / `gx branch finish` + // behave like `gx ship`: open a PR from the worktree, wait for merge, clean up, + // and enforce the merge gate (clean AI review + green CI) before merging to base. + // via-pr / wait-for-merge / cleanup are already the defaults, so the only delta + // is flipping the gate-review default on. Explicit flags (e.g. --no-gate-review) + // still override below. + const autoShip = envFlagIsTruthy(process.env.GUARDEX_AUTO_SHIP); const options = { target: process.cwd(), base: '', @@ -1156,7 +1164,11 @@ function parseFinishArgs(rawArgs, defaults = {}) { commitMessage: '', mergeMode: defaults.mergeMode || 'pr', skipPreflight: false, - gateReview: defaults.gateReview ?? false, + // Precedence: explicit flag (set in the loop below) > defaults (caller) > + // GUARDEX_AUTO_SHIP env > hardcoded off. Note `defaults.gateReview === false` + // (not undefined) also wins over the env toggle, since `??` only falls + // through on null/undefined. + gateReview: defaults.gateReview ?? autoShip, reviewProvider: defaults.reviewProvider || 'codex', allowNoChecks: false, }; diff --git a/test/auto-ship-toggle.test.js b/test/auto-ship-toggle.test.js new file mode 100644 index 0000000..f7218a8 --- /dev/null +++ b/test/auto-ship-toggle.test.js @@ -0,0 +1,76 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { parseFinishArgs } = require('../src/cli/args'); + +// Run `fn` with GUARDEX_AUTO_SHIP forced to `value` (or deleted when null), +// restoring the prior value afterwards so tests stay isolated. +function withAutoShip(value, fn) { + const prev = process.env.GUARDEX_AUTO_SHIP; + if (value === null) { + delete process.env.GUARDEX_AUTO_SHIP; + } else { + process.env.GUARDEX_AUTO_SHIP = value; + } + try { + fn(); + } finally { + if (prev === undefined) { + delete process.env.GUARDEX_AUTO_SHIP; + } else { + process.env.GUARDEX_AUTO_SHIP = prev; + } + } +} + +test('GUARDEX_AUTO_SHIP=1 makes a bare finish resolve like `gx ship` (gated)', () => { + withAutoShip('1', () => { + const options = parseFinishArgs([]); + assert.equal(options.gateReview, true, 'gate-review default flips on under auto-ship'); + assert.equal(options.mergeMode, 'pr', 'merges via PR'); + assert.equal(options.waitForMerge, true, 'waits for merge'); + assert.equal(options.cleanup, true, 'cleans up the worktree'); + }); +}); + +test('explicit --no-gate-review wins over GUARDEX_AUTO_SHIP', () => { + withAutoShip('1', () => { + const options = parseFinishArgs(['--no-gate-review']); + assert.equal(options.gateReview, false, 'explicit opt-out overrides the toggle'); + }); + withAutoShip('1', () => { + const options = parseFinishArgs(['--skip-review-gate']); + assert.equal(options.gateReview, false, '--skip-review-gate also overrides the toggle'); + }); +}); + +test('without GUARDEX_AUTO_SHIP the finish defaults are unchanged', () => { + withAutoShip(null, () => { + const options = parseFinishArgs([]); + assert.equal(options.gateReview, false, 'gate-review stays opt-in by default'); + // The rest of the finish defaults are independent of the toggle. + assert.equal(options.mergeMode, 'pr'); + assert.equal(options.waitForMerge, true); + assert.equal(options.cleanup, true); + }); +}); + +test('caller defaults still drive gateReview independently of the env toggle', () => { + // defaults win over the env when set: explicit true enables the gate with the + // toggle off; explicit false suppresses it even with the toggle on. + withAutoShip(null, () => { + assert.equal(parseFinishArgs([], { gateReview: true }).gateReview, true); + }); + withAutoShip('1', () => { + assert.equal(parseFinishArgs([], { gateReview: false }).gateReview, false); + }); +}); + +test('GUARDEX_AUTO_SHIP falsy values are treated as off', () => { + for (const falsy of ['0', 'false', 'no', 'off', '']) { + withAutoShip(falsy, () => { + const options = parseFinishArgs([]); + assert.equal(options.gateReview, false, `"${falsy}" must not enable the gate`); + }); + } +});