From 109f3205ba6b7fa13cacbdee51db0067b155ac15 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 22 Apr 2026 09:16:27 +0200 Subject: [PATCH] Stop repo-local workflow shims from defining Guardex command surface Guardex already runs branch, lock, review, and cleanup flows from gx, but repo installs still taught and depended on scripts/ workflow shims. This change shrinks the required repo footprint to hook shims plus repo-local state, removes CLI checks that required repo-local workflow scripts, and updates shipped docs, AGENTS guidance, skill references, and lock/review helper output around the zero-copy surface. Constraint: Existing repos may still carry legacy workflow scripts during migration; direct gx commands must keep working without weakening branch and lock guardrails Rejected: Keep thin repo-local workflow shims as permanent entrypoints | still leaves a second command surface and drift noise Confidence: high Scope-risk: moderate Directive: Keep repo-local workflow commands optional compatibility only; install, doctor, status, and skills should teach gx subcommands first Tested: node --check bin/multiagent-safety.js; node --test test/install.test.js Not-tested: test/metadata.test.js parity gate for non-targeted local runtime/template sync --- .githooks/pre-commit | 37 +- AGENTS.md | 32 +- README.md | 39 +- bin/multiagent-safety.js | 194 +++--- .../proposal.md | 26 + .../zero-copy-cli-install-surface/spec.md | 66 +++ .../tasks.md | 31 + scripts/agent-branch-merge.sh | 29 +- scripts/agent-file-locks.py | 22 +- scripts/review-bot-watch.sh | 37 +- templates/codex/skills/gitguardex/SKILL.md | 2 +- .../guardex-merge-skills-to-dev/SKILL.md | 6 +- templates/githooks/pre-commit | 23 +- templates/scripts/agent-branch-finish.sh | 51 +- templates/scripts/agent-branch-merge.sh | 29 +- templates/scripts/agent-branch-start.sh | 52 +- templates/scripts/agent-file-locks.py | 22 +- templates/scripts/codex-agent.sh | 92 ++- templates/scripts/review-bot-watch.sh | 37 +- test/install.test.js | 559 ++++++++---------- 20 files changed, 750 insertions(+), 636 deletions(-) create mode 100644 openspec/changes/agent-codex-zero-copy-cli-install-surface-2026-04-22-01-28/proposal.md create mode 100644 openspec/changes/agent-codex-zero-copy-cli-install-surface-2026-04-22-01-28/specs/zero-copy-cli-install-surface/spec.md create mode 100644 openspec/changes/agent-codex-zero-copy-cli-install-surface-2026-04-22-01-28/tasks.md diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 89667bc..c2d751f 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -13,6 +13,8 @@ repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" if [[ -z "$repo_root" ]]; then exit 0 fi +NODE_BIN="${GUARDEX_NODE_BIN:-node}" +CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}" guardex_env_helper="${repo_root}/scripts/guardex-env.sh" if [[ -f "$guardex_env_helper" ]]; then # shellcheck source=/dev/null @@ -22,6 +24,23 @@ if declare -F guardex_repo_is_enabled >/dev/null 2>&1 && ! guardex_repo_is_enabl exit 0 fi +run_guardex_cli() { + if [[ -n "$CLI_ENTRY" ]]; then + "$NODE_BIN" "$CLI_ENTRY" "$@" + return $? + fi + if command -v gx >/dev/null 2>&1; then + gx "$@" + return $? + fi + if command -v gitguardex >/dev/null 2>&1; then + gitguardex "$@" + return $? + fi + echo "[agent-branch-guard] Guardex CLI entrypoint unavailable; rerun via gx." >&2 + return 127 +} + if [[ "${ALLOW_COMMIT_ON_PROTECTED_BRANCH:-0}" == "1" ]]; then exit 0 fi @@ -118,9 +137,9 @@ if [[ "$should_require_codex_agent_branch" == "1" && "${GUARDEX_ALLOW_CODEX_ON_N [guardex-preedit-guard] Codex edit/commit detected on a protected branch. GuardeX requires Codex work to run from an isolated agent/* branch. Start the sub-branch/worktree with: - bash scripts/codex-agent.sh "" "" + gx branch start "" "" Or manually: - bash scripts/agent-branch-start.sh "" "" + gx branch start "" "" Then commit from the created agent/* branch. Temporary bypass (not recommended): @@ -132,7 +151,7 @@ MSG cat >&2 <<'MSG' [codex-branch-guard] Codex agent commit blocked on non-agent branch. Use isolated branch/worktree first: - bash scripts/agent-branch-start.sh "" "" + gx branch start "" "" Then commit from the created agent/* branch. Temporary bypass (not recommended): @@ -163,9 +182,9 @@ if [[ "$is_protected_branch" == "1" ]]; then cat >&2 <<'MSG' [agent-branch-guard] Direct commits on protected branches are blocked. Use an agent branch first: - bash scripts/agent-branch-start.sh "" "" + gx branch start "" "" After finishing work: - bash scripts/agent-branch-finish.sh + gx branch finish Temporary bypass (not recommended): ALLOW_COMMIT_ON_PROTECTED_BRANCH=1 git commit ... @@ -177,7 +196,7 @@ if [[ "$is_agent_session" == "1" && "$branch" != agent/* ]]; then cat >&2 <<'MSG' [agent-branch-guard] Agent commits must run on dedicated agent/* branches. Start an agent branch first: - bash scripts/agent-branch-start.sh "" "" + gx branch start "" "" Then commit on that branch. Temporary bypass (not recommended): @@ -191,15 +210,15 @@ if [[ "$branch" == agent/* ]]; then while IFS= read -r staged_file; do [[ -z "$staged_file" ]] && continue [[ "$staged_file" == ".omx/state/agent-file-locks.json" ]] && continue - python3 scripts/agent-file-locks.py claim --branch "$branch" "$staged_file" >/dev/null 2>&1 || true + run_guardex_cli locks claim --branch "$branch" "$staged_file" >/dev/null 2>&1 || true done < <(git diff --cached --name-only --diff-filter=ACMRDTUXB) fi - if ! python3 scripts/agent-file-locks.py validate --branch "$branch" --staged; then + if ! run_guardex_cli locks validate --branch "$branch" --staged; then cat >&2 <<'MSG' [agent-branch-guard] Agent branch commits require file ownership locks. Claim files first: - python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" + gx locks claim --branch "$(git rev-parse --abbrev-ref HEAD)" MSG exit 1 fi diff --git a/AGENTS.md b/AGENTS.md index d177a49..63b5ed8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ When Guardex is enabled, Claude Code sessions use the same agent-worktree + Open ### Tiering (token-aware scaffolding) -`agent-branch-start.sh` and `agent-branch-finish.sh` accept `--tier {T0|T1|T2|T3}` to size the OpenSpec scaffolding to the change's blast radius. Default is `T3` (full scaffolding; current behavior). The tier is recorded in the bootstrap manifest so `finish` picks it up automatically. +`gx branch start` and `gx branch finish` accept `--tier {T0|T1|T2|T3}` to size the OpenSpec scaffolding to the change's blast radius. Default is `T3` (full scaffolding; current behavior). The tier is recorded in the bootstrap manifest so `finish` picks it up automatically. | Tier | Use for | Scaffolding on `start` | Gates on `finish` | |------|---------|------------------------|--------------------| @@ -67,16 +67,16 @@ Examples: ```bash # T0 (typo / trivial): fastest path, no OpenSpec artifacts -bash scripts/agent-branch-start.sh --tier T0 "fix-typo-in-readme" "claude-name" +gx branch start --tier T0 "fix-typo-in-readme" "claude-name" # T1 (small fix): notes-only scaffold, commit message is the spec of record -bash scripts/agent-branch-start.sh --tier T1 "tighten-retry-backoff" "claude-name" +gx branch start --tier T1 "tighten-retry-backoff" "claude-name" # T2 (default for real behavior changes): full change spec, no plan workspace -bash scripts/agent-branch-start.sh --tier T2 "add-oauth-endpoint" "claude-name" +gx branch start --tier T2 "add-oauth-endpoint" "claude-name" # T3 (current default if --tier is omitted): plan workspace + full OpenSpec -bash scripts/agent-branch-start.sh "refactor-payment-pipeline" "claude-name" +gx branch start "refactor-payment-pipeline" "claude-name" ``` `finish` reads the tier from the manifest automatically; passing `--tier` on finish is only needed to override (e.g., upgrading to a fuller gate). @@ -84,7 +84,7 @@ bash scripts/agent-branch-start.sh "refactor-payment-pipeline" "claude-name" 1. Start a sandbox worktree: ```bash - bash scripts/agent-branch-start.sh [--tier T0|T1|T2|T3] "" "claude-" + gx branch start [--tier T0|T1|T2|T3] "" "claude-" ``` Creates `agent/claude-/` under `.omc/agent-worktrees/`, scaffolds the OpenSpec change + plan workspaces (sized by tier), and records the bootstrap manifest. Codex sessions keep using `.omx/agent-worktrees/`. Missing `codex-auth` silently falls back to an empty snapshot slug (expected for Claude sessions). @@ -93,7 +93,7 @@ bash scripts/agent-branch-start.sh "refactor-payment-pipeline" "claude-name" ```bash cd .omc/agent-worktrees/agent__claude-__ - python3 scripts/agent-file-locks.py claim --branch "agent/claude-/" + gx locks claim --branch "agent/claude-/" # implement + commit inside this worktree ``` @@ -102,7 +102,7 @@ bash scripts/agent-branch-start.sh "refactor-payment-pipeline" "claude-name" 3. Finish via PR + cleanup: ```bash - bash scripts/agent-branch-finish.sh \ + gx branch finish \ --branch "agent/claude-/" \ --base dev --via-pr --wait-for-merge --cleanup ``` @@ -113,11 +113,11 @@ Notes: - Slash commands `/opsx:*` in `.claude/commands/opsx/` drive the OpenSpec artifact flow. - `.claude/settings.json` already wires the `skill_activation` / `skill_guard` hooks, so project-conventions enforcement runs automatically on edits. -- `skill_guard` blocks most Bash commands while the shell is on `dev`; run the start/claim/finish commands from within the worktree, or prefix the invocation with `ALLOW_BASH_ON_NON_AGENT_BRANCH=1` when calling from the primary checkout. +- `skill_guard` blocks most Bash commands while the shell is on `dev`; run the `gx branch ...`, `gx locks ...`, and `gx branch finish ...` commands from within the worktree, or prefix the invocation with `ALLOW_BASH_ON_NON_AGENT_BRANCH=1` when calling from the primary checkout. ### Stalled agent worktree recovery -`codex-agent.sh` auto-finishes a branch only when the codex CLI exits cleanly inside it. If the agent is killed, crashes, runs out of budget, or is started directly via `agent-branch-start.sh` (no `codex-agent.sh` wrapper), the worktree is left dirty with no commits and no PR — a "stalled" worktree. +The Guardex Codex launcher auto-finishes a branch only when the codex CLI exits cleanly inside it. If the agent is killed, crashes, runs out of budget, or is started directly via `gx branch start` without the launcher, the worktree is left dirty with no commits and no PR — a "stalled" worktree. `scripts/agent-stalled-report.sh` is a quiet wrapper around `scripts/agent-autofinish-watch.sh --once --dry-run` that surfaces stalled worktrees. It is wired as a `SessionStart` hook in `.claude/settings.json`, so each Claude Code session begins with a one-line summary per stalled branch (and is silent when nothing is stalled). @@ -144,7 +144,7 @@ Apply these repo-specific supplements in addition to that canonical contract: - `agent-branch-start` and `agent-branch-finish` must fast-forward local `dev` from `origin/dev` before branch creation/merge. 2. Ownership and lock discipline -- Claim owned files before edits: `python3 scripts/agent-file-locks.py claim --branch "" `. +- Claim owned files before edits: `gx locks claim --branch "" `. - If `main.rs` is in scope, claim lock first: `python3 scripts/main_rs_lock.py claim --owner "" --branch ""`. - Non-integrator branches must not edit `main.rs` unless explicit emergency override is approved. - Pre-commit blocks `agent/*` commits with unclaimed files or missing valid `main.rs` lock. @@ -181,7 +181,7 @@ When Guardex is enabled, this repo uses **OpenSpec as the primary workflow and S 3. Keep artifacts editable throughout implementation (proposal/spec/design/tasks are living docs, not rigid phase gates). 4. Implement from `tasks.md`; keep code and specs in sync (update `spec.md` as behavior changes). 5. Keep `tasks.md` checkpoint status updated continuously during execution; mark items as soon as they complete (do not batch-update at the end). -6. Default `tasks.md` scaffolds and manual task edits must include a final completion/cleanup section that ends with PR merge + sandbox cleanup (`gx finish --via-pr --wait-for-merge --cleanup` or `scripts/agent-branch-finish.sh ... --cleanup`) and captures PR URL + final `MERGED` handoff evidence. +6. Default `tasks.md` scaffolds and manual task edits must include a final completion/cleanup section that ends with PR merge + sandbox cleanup (`gx branch finish ... --cleanup` or `gx finish --all`) and captures PR URL + final `MERGED` handoff evidence. 7. Validate specs locally: `openspec validate --specs`. 8. Verify before archiving (`/opsx:verify ` when applicable); never archive unverified changes. @@ -261,22 +261,22 @@ scripts/openspec/init-plan-workspace.sh `GUARDEX_ON=0` disables Guardex for that repo. `GUARDEX_ON=1` explicitly enables Guardex for that repo again. -**Isolation.** Every task runs on a dedicated `agent/*` branch + worktree. Start with `scripts/agent-branch-start.sh "" ""`. Treat the base branch (`main`/`dev`) as read-only while an agent branch is active. Never `git checkout ` on a primary working tree (including nested repos); use `git worktree add` instead. The `.githooks/post-checkout` hook auto-reverts primary-branch switches during agent sessions - bypass only with `GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1`. +**Isolation.** Every task runs on a dedicated `agent/*` branch + worktree. Start with `gx branch start "" ""`. Treat the base branch (`main`/`dev`) as read-only while an agent branch is active. Never `git checkout ` on a primary working tree (including nested repos); use `git worktree add` instead. The `.githooks/post-checkout` hook auto-reverts primary-branch switches during agent sessions - bypass only with `GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1`. For every new task, including follow-up work in the same chat/session, if an assigned agent sub-branch/worktree is already open, continue in that sub-branch instead of creating a fresh lane unless the user explicitly redirects scope. Never implement directly on the local/base branch checkout; keep it unchanged and perform all edits in the agent sub-branch/worktree. -**Ownership.** Before editing, claim files: `scripts/agent-file-locks.py claim --branch "" `. Before deleting, confirm the path is in your claim. Don't edit outside your scope unless reassigned. +**Ownership.** Before editing, claim files: `gx locks claim --branch "" `. Before deleting, confirm the path is in your claim. Don't edit outside your scope unless reassigned. **Handoff gate.** Post a one-line handoff note (plan/change, owned scope, intended action) before editing. Re-read the latest handoffs before replacing others' code. -**Completion.** Finish with `scripts/agent-branch-finish.sh --branch "" --via-pr --wait-for-merge --cleanup` (or `gx finish --all`). Task is only complete when: commit pushed, PR URL recorded, state = `MERGED`, sandbox worktree pruned. If anything blocks, append a `BLOCKED:` note and stop - don't half-finish. +**Completion.** Finish with `gx branch finish --branch "" --via-pr --wait-for-merge --cleanup` (or `gx finish --all`). Task is only complete when: commit pushed, PR URL recorded, state = `MERGED`, sandbox worktree pruned. If anything blocks, append a `BLOCKED:` note and stop - don't half-finish. OMX completion policy: when a task is done, the agent must commit the task changes, push the agent branch, and create/update a PR before considering the branch complete. **Parallel safety.** Assume other agents edit nearby. Never revert unrelated changes. Report conflicts in the handoff. **Reporting.** Every completion handoff includes: files changed, behavior touched, verification commands + results, risks/follow-ups. -**OpenSpec (when change-driven).** Keep `openspec/changes//tasks.md` checkboxes current during work, not batched at the end. Task scaffolds and manual task edits must include an explicit final completion/cleanup section that ends with PR merge + sandbox cleanup (`gx finish --via-pr --wait-for-merge --cleanup` or `scripts/agent-branch-finish.sh ... --cleanup`) and records PR URL + final `MERGED` evidence. Verify specs with `openspec validate --specs` before archive. Don't archive unverified. +**OpenSpec (when change-driven).** Keep `openspec/changes//tasks.md` checkboxes current during work, not batched at the end. Task scaffolds and manual task edits must include an explicit final completion/cleanup section that ends with PR merge + sandbox cleanup (`gx branch finish ... --cleanup` or `gx finish --all`) and records PR URL + final `MERGED` evidence. Verify specs with `openspec validate --specs` before archive. Don't archive unverified. **Version bumps.** If a change bumps a published version, the same PR updates release notes/changelog. diff --git a/README.md b/README.md index 551c9ab..1b14fff 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ npm i -g @imdeadpool/guardex

- Then cd into your repo and run gx setup — hook shims, workflow shims, repo state, + Then cd into your repo and run gx setup — hook shims, repo state, and OMX / OpenSpec / caveman wiring all scaffold in one go.

@@ -88,7 +88,7 @@ cd /path/to/your/repo gx setup ``` -That's it. Install and update via `@imdeadpool/guardex`. Setup installs the minimal repo footprint: managed hook/workflow shims, lock state, AGENTS wiring, and OpenSpec/caveman/OMX scaffolding. Aliases: `gx` (preferred), `gitguardex` (full), `guardex` (legacy compatibility). +That's it. Install and update via `@imdeadpool/guardex`. Setup installs the minimal repo footprint: managed hook shims, repo-local state, AGENTS wiring, OpenSpec/caveman/OMX scaffolding, and a small set of repo-local helper assets. Aliases: `gx` (preferred), `gitguardex` (full), `guardex` (legacy compatibility). --- @@ -178,7 +178,7 @@ Before you branch, repair, or start agents, run plain `gx`. It gives you a one-s ![GitGuardex terminal status output](https://raw.githubusercontent.com/recodeee/gitguardex/main/docs/images/workflow-gx-terminal-status.svg) -Use `gx setup` the first time you wire GitGuardex into a repo. It bootstraps the managed hook/workflow shims, repo state, and optional workspace/OpenSpec wiring. If the repo drifts later, use `gx doctor` as the repair path: it reapplies the managed safety files, verifies the setup, and on protected `main` it auto-sandboxes the repair so your visible base branch stays clean. +Use `gx setup` the first time you wire GitGuardex into a repo. It bootstraps the managed hook shims, repo-local state, and optional workspace/OpenSpec wiring. If the repo drifts later, use `gx doctor` as the repair path: it reapplies the managed safety files, verifies the setup, and on protected `main` it auto-sandboxes the repair so your visible base branch stays clean. --- @@ -203,7 +203,7 @@ gx branch finish \ --base main --via-pr --wait-for-merge --cleanup ``` -If you use the managed Codex launcher shim, the finish flow runs automatically when the Codex session exits — it auto-commits, retries once after syncing if the base moved during the run, then pushes and opens the PR. +If you launch Codex through Guardex, the finish flow runs automatically when the Codex session exits — it auto-commits, retries once after syncing if the base moved during the run, then pushes and opens the PR. GitGuardex normally prunes merged sandboxes for you as part of the finish flow. If you simply do not want a local sandbox/worktree anymore, remove that worktree directly; delete the branch too only if you are intentionally abandoning that lane: @@ -542,7 +542,7 @@ Expanded flow: ### OpenSpec in agent sub-branches -- The managed Codex launcher shim enforces OpenSpec workspaces before launching Codex. +- The Guardex Codex launcher enforces OpenSpec workspaces before launching Codex. - `gx branch start` can scaffold both `openspec/changes//` and `openspec/plan//` when `GUARDEX_OPENSPEC_AUTO_INIT=true`. - The collaboration section in `tasks.md` is there for real cleanup handoffs too. If the first Codex/Claude session finishes the implementation work but hits a usage limit before `agent-branch-finish --cleanup`, hand the same sandbox to another agent, let that agent finish cleanup, and record the join/handoff in the change task. @@ -560,26 +560,29 @@ Environment variables: ## Files installed by setup ```text +AGENTS.md # managed multi-agent block appended/refreshed in place .githooks/pre-commit # shim -> gx hook run pre-commit .githooks/pre-push # shim -> gx hook run pre-push .githooks/post-merge # shim -> gx hook run post-merge .githooks/post-checkout # shim -> gx hook run post-checkout -scripts/agent-branch-start.sh # shim -> gx branch start -scripts/agent-branch-finish.sh # shim -> gx branch finish -scripts/agent-branch-merge.sh # shim -> gx branch merge -scripts/agent-worktree-prune.sh # shim -> gx worktree prune -scripts/agent-file-locks.py # shim -> gx locks ... -scripts/codex-agent.sh # shim -> CLI-owned Codex launcher -scripts/review-bot-watch.sh # shim -> CLI-owned review bot -scripts/openspec/init-plan-workspace.sh # shim -> CLI-owned OpenSpec init -scripts/openspec/init-change-workspace.sh # shim -> CLI-owned OpenSpec init -.omc/agent-worktrees -.omx/state/agent-file-locks.json +scripts/guardex-env.sh # repo toggle + hook/helper env bridge +scripts/guardex-docker-loader.sh # compose env/loader helper +scripts/agent-session-state.js # active-session state helper +scripts/install-vscode-active-agents-extension.js +.omc/agent-worktrees # Claude sandbox root +.omx/agent-worktrees # Codex sandbox root +.omx/state/agent-file-locks.json # file-lock registry .github/pull.yml.example .github/workflows/cr.yml +vscode/guardex-active-agents/package.json +vscode/guardex-active-agents/extension.js +vscode/guardex-active-agents/session-schema.js +vscode/guardex-active-agents/README.md ``` -Repo-local Codex/Claude helper files are no longer copied into the repo. Install the optional user-level companions with `gx install-agent-skills`. +Legacy compatibility note: older repos may still contain repo-local workflow scripts under `scripts/`. Direct `gx branch ...`, `gx locks ...`, `gx finish`, `gx cleanup`, `gx merge`, and `gx migrate` do not require them. `gx migrate` removes those leftover workflow shims by default. The CLI still honors repo-local `scripts/review-bot-watch.sh` and `scripts/codex-agent.sh` when they are already present so older repos can keep working during migration. + +Optional Codex/Claude user-level companions still install with `gx install-agent-skills`; they are not copied into each repo. --- @@ -645,7 +648,7 @@ npm pack --dry-run ### v7.0.18 - GitGuardex now keeps the install workflow in `gx` itself: `gx branch ...`, `gx locks ...`, `gx worktree prune`, `gx migrate`, and user-level agent-skill install now own the agent lifecycle instead of teaching pasted repo scripts as the primary surface. - Fresh installs switch repo hooks to tiny `gx hook run ...` shims, stop copying repo-local workflow implementations and repo-local skills, and stop injecting Guardex-managed `agent:*` package scripts into consumer repos. -- `gx migrate` can move older repos onto the smaller CLI-owned install surface while preserving the managed AGENTS block, lock registry state, repo-local dispatch shims, and required gitignore entries. +- `gx migrate` can move older repos onto the smaller CLI-owned install surface while preserving the managed AGENTS block, lock registry state, hook shims, required gitignore entries, and the repo-local helper assets that still carry local state. - Bumped the release from `7.0.17` → `7.0.18` so the shipped CLI-owned install-surface changes land on a fresh publishable npm version. ### v7.0.17 diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index f30a943..6f57748 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -109,33 +109,30 @@ const TEMPLATE_FILES = [ 'vscode/guardex-active-agents/README.md', ]; -const SCRIPT_SHIMS = [ - { relativePath: 'scripts/agent-branch-start.sh', kind: 'shell', command: ['branch', 'start'] }, - { relativePath: 'scripts/agent-branch-finish.sh', kind: 'shell', command: ['branch', 'finish'] }, - { relativePath: 'scripts/agent-branch-merge.sh', kind: 'shell', command: ['branch', 'merge'] }, - { relativePath: 'scripts/codex-agent.sh', kind: 'shell', command: ['internal', 'run-shell', 'codexAgent'] }, - { relativePath: 'scripts/review-bot-watch.sh', kind: 'shell', command: ['internal', 'run-shell', 'reviewBot'] }, - { relativePath: 'scripts/agent-worktree-prune.sh', kind: 'shell', command: ['worktree', 'prune'] }, - { relativePath: 'scripts/agent-file-locks.py', kind: 'python', command: ['locks'] }, - { relativePath: 'scripts/openspec/init-plan-workspace.sh', kind: 'shell', command: ['internal', 'run-shell', 'planInit'] }, - { relativePath: 'scripts/openspec/init-change-workspace.sh', kind: 'shell', command: ['internal', 'run-shell', 'changeInit'] }, -]; - -const LEGACY_MANAGED_REPO_FILES = [ +const LEGACY_WORKFLOW_SHIMS = [ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', 'scripts/agent-branch-merge.sh', - 'scripts/agent-session-state.js', 'scripts/codex-agent.sh', - 'scripts/guardex-docker-loader.sh', - 'scripts/install-vscode-active-agents-extension.js', 'scripts/review-bot-watch.sh', 'scripts/agent-worktree-prune.sh', 'scripts/agent-file-locks.py', - 'scripts/guardex-env.sh', - 'scripts/install-agent-git-hooks.sh', 'scripts/openspec/init-plan-workspace.sh', 'scripts/openspec/init-change-workspace.sh', +]; + +const MANAGED_TEMPLATE_DESTINATIONS = TEMPLATE_FILES.map((entry) => toDestinationPath(entry)); +const MANAGED_TEMPLATE_SCRIPT_FILES = MANAGED_TEMPLATE_DESTINATIONS.filter((entry) => + entry.startsWith('scripts/'), +); + +const LEGACY_MANAGED_REPO_FILES = [ + ...LEGACY_WORKFLOW_SHIMS, + 'scripts/agent-session-state.js', + 'scripts/guardex-docker-loader.sh', + 'scripts/install-vscode-active-agents-extension.js', + 'scripts/guardex-env.sh', + 'scripts/install-agent-git-hooks.sh', '.githooks/pre-commit', '.githooks/pre-push', '.githooks/post-merge', @@ -145,9 +142,8 @@ const LEGACY_MANAGED_REPO_FILES = [ '.claude/commands/gitguardex.md', ]; -const REQUIRED_WORKFLOW_FILES = [ - ...TEMPLATE_FILES.map((entry) => toDestinationPath(entry)), - ...SCRIPT_SHIMS.map((entry) => entry.relativePath), +const REQUIRED_MANAGED_REPO_FILES = [ + ...MANAGED_TEMPLATE_DESTINATIONS, ...HOOK_NAMES.map((entry) => path.posix.join('.githooks', entry)), '.omx/state/agent-file-locks.json', ]; @@ -205,22 +201,13 @@ const USER_LEVEL_SKILL_ASSETS = [ ]; const EXECUTABLE_RELATIVE_PATHS = new Set([ - 'scripts/agent-session-state.js', - 'scripts/guardex-docker-loader.sh', - 'scripts/install-vscode-active-agents-extension.js', - ...SCRIPT_SHIMS.map((entry) => entry.relativePath), + ...MANAGED_TEMPLATE_SCRIPT_FILES, ...HOOK_NAMES.map((entry) => path.posix.join('.githooks', entry)), ]); const CRITICAL_GUARDRAIL_PATHS = new Set([ 'AGENTS.md', ...HOOK_NAMES.map((entry) => path.posix.join('.githooks', entry)), - 'scripts/agent-branch-start.sh', - 'scripts/agent-branch-finish.sh', - 'scripts/agent-branch-merge.sh', - 'scripts/agent-worktree-prune.sh', - 'scripts/codex-agent.sh', - 'scripts/agent-file-locks.py', 'scripts/guardex-env.sh', ]); @@ -239,9 +226,10 @@ const AGENT_WORKTREE_RELATIVE_DIRS = [ const MANAGED_GITIGNORE_PATHS = [ '.omx/', '.omc/', - 'scripts/*', - 'scripts/agent-branch-start.sh', - 'scripts/agent-file-locks.py', + 'scripts/agent-session-state.js', + 'scripts/guardex-docker-loader.sh', + 'scripts/guardex-env.sh', + 'scripts/install-vscode-active-agents-extension.js', '.githooks', 'oh-my-codex/', LOCK_FILE_RELATIVE, @@ -311,7 +299,7 @@ const CLI_COMMAND_DESCRIPTIONS = [ ['locks', 'CLI-owned file lock surface (claim/allow-delete/release/status/validate)'], ['worktree', 'CLI-owned worktree cleanup surface (prune)'], ['hook', 'Hook dispatch/install surface used by managed shims'], - ['migrate', 'Convert legacy repo-local installs to the new shim-based CLI-owned surface'], + ['migrate', 'Convert legacy repo-local installs to the zero-copy CLI-owned surface'], ['install-agent-skills', 'Install Guardex Codex/Claude skills into the user home'], ['protect', 'Manage protected branches (list/add/remove/set/reset)'], ['merge', 'Create/reuse an integration lane and merge overlapping agent branches'], @@ -680,6 +668,27 @@ function runPackageAsset(assetKey, rawArgs, options = {}) { }); } +function repoLocalLegacyScriptPath(repoRoot, relativePath) { + const assetPath = path.join(repoRoot, relativePath); + return fs.existsSync(assetPath) ? assetPath : null; +} + +function runReviewBotCommand(repoRoot, rawArgs, options = {}) { + const legacyScript = repoLocalLegacyScriptPath(repoRoot, 'scripts/review-bot-watch.sh'); + if (legacyScript) { + return run('bash', [legacyScript, ...rawArgs], { + cwd: repoRoot, + stdio: options.stdio || 'pipe', + timeout: options.timeout, + env: packageAssetEnv(options.env), + }); + } + return runPackageAsset('reviewBot', rawArgs, { + ...options, + cwd: repoRoot, + }); +} + function invokePackageAsset(assetKey, rawArgs, options = {}) { const result = runPackageAsset(assetKey, rawArgs, options); if (result.stdout) process.stdout.write(result.stdout); @@ -1670,7 +1679,6 @@ function ensureParentWorkspaceView(repoRoot, dryRun) { function hasGuardexBootstrapFiles(repoRoot) { const required = [ 'AGENTS.md', - 'scripts/agent-branch-start.sh', '.githooks/pre-commit', '.githooks/pre-push', LOCK_FILE_RELATIVE, @@ -1934,11 +1942,6 @@ function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) { return startProtectedBaseSandboxFallback(blocked, sandboxSuffix); } - const startScript = path.join(blocked.repoRoot, 'scripts', 'agent-branch-start.sh'); - if (!fs.existsSync(startScript)) { - return startProtectedBaseSandboxFallback(blocked, sandboxSuffix); - } - const startResult = runPackageAsset('branchStart', [ '--task', taskName, @@ -2088,7 +2091,7 @@ function collectWorktreeDirtyPaths(worktreePath) { } function collectDoctorForceAddPaths(worktreePath) { - return REQUIRED_WORKFLOW_FILES + return REQUIRED_MANAGED_REPO_FILES .filter((relativePath) => relativePath.startsWith('scripts/') || relativePath.startsWith('.githooks/')) .filter((relativePath) => fs.existsSync(path.join(worktreePath, relativePath))); } @@ -2124,11 +2127,10 @@ function stripDoctorSandboxLocks(rawContent, branchName) { } function claimDoctorChangedLocks(metadata) { - const lockScript = path.join(metadata.worktreePath, 'scripts', 'agent-file-locks.py'); - if (!fs.existsSync(lockScript) || !metadata.branch) { + if (!metadata.branch) { return { status: 'skipped', - note: 'lock helper unavailable in sandbox', + note: 'missing sandbox branch metadata', changedCount: 0, deletedCount: 0, }; @@ -2247,13 +2249,6 @@ function doctorFinishFlowIsPending(output) { } function finishDoctorSandboxBranch(blocked, metadata, options = {}) { - const finishScript = path.join(metadata.worktreePath, 'scripts', 'agent-branch-finish.sh'); - if (!fs.existsSync(finishScript)) { - return { - status: 'skipped', - note: `${path.relative(metadata.worktreePath, finishScript)} missing in sandbox`, - }; - } if (!hasOriginRemote(blocked.repoRoot)) { return { status: 'skipped', @@ -2290,9 +2285,9 @@ function finishDoctorSandboxBranch(blocked, metadata, options = {}) { const finishTimeoutMs = Math.max(180_000, (waitTimeoutSeconds + 60) * 1000); const waitForMergeArg = options.waitForMerge === false ? '--no-wait-for-merge' : '--wait-for-merge'; - const finishResult = run( - 'bash', - [finishScript, '--branch', metadata.branch, '--base', blocked.branch, '--via-pr', waitForMergeArg, '--cleanup'], + const finishResult = runPackageAsset( + 'branchFinish', + ['--branch', metadata.branch, '--base', blocked.branch, '--via-pr', waitForMergeArg, '--cleanup'], { cwd: metadata.worktreePath, timeout: finishTimeoutMs }, ); if (isSpawnFailure(finishResult)) { @@ -2363,7 +2358,7 @@ function mergeDoctorSandboxRepairsBackToProtectedBase(options, blocked, metadata ...(autoCommitResult.stagedFiles || []), ...OMX_SCAFFOLD_DIRECTORIES, ...Array.from(OMX_SCAFFOLD_FILES.keys()), - ...REQUIRED_WORKFLOW_FILES, + ...REQUIRED_MANAGED_REPO_FILES, 'bin', 'package.json', '.gitignore', @@ -3387,13 +3382,6 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) { return summary; } - const finishScript = path.join(repoRoot, 'scripts', 'agent-branch-finish.sh'); - if (!fs.existsSync(finishScript)) { - summary.enabled = false; - summary.details.push(`Skipped auto-finish sweep (missing ${path.relative(repoRoot, finishScript)}).`); - return summary; - } - const hasOrigin = gitRun(repoRoot, ['remote', 'get-url', 'origin'], { allowFailure: true }).status === 0; if (!hasOrigin) { summary.enabled = false; @@ -4212,11 +4200,6 @@ function gitOutputLines(worktreePath, args) { } function claimLocksForAutoCommit(repoRoot, worktreePath, branch) { - const lockScript = path.join(repoRoot, 'scripts', 'agent-file-locks.py'); - if (!fs.existsSync(lockScript)) { - return; - } - const changedFiles = uniquePreserveOrder([ ...gitOutputLines(worktreePath, ['diff', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']), ...gitOutputLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']), @@ -5173,9 +5156,6 @@ function runInstallInternal(options) { for (const templateFile of TEMPLATE_FILES) { operations.push(copyTemplateFile(repoRoot, templateFile, Boolean(options.force), Boolean(options.dryRun))); } - for (const shim of SCRIPT_SHIMS) { - operations.push(ensureGeneratedScriptShim(repoRoot, shim, options)); - } for (const hookName of HOOK_NAMES) { operations.push(ensureHookShim(repoRoot, hookName, options)); } @@ -5220,9 +5200,6 @@ function runFixInternal(options) { for (const templateFile of TEMPLATE_FILES) { operations.push(ensureTemplateFilePresent(repoRoot, templateFile, Boolean(options.dryRun))); } - for (const shim of SCRIPT_SHIMS) { - operations.push(ensureGeneratedScriptShim(repoRoot, shim, options)); - } for (const hookName of HOOK_NAMES) { operations.push(ensureHookShim(repoRoot, hookName, options)); } @@ -5284,7 +5261,7 @@ function runScanInternal(options) { const requiredPaths = [ ...OMX_SCAFFOLD_DIRECTORIES, ...Array.from(OMX_SCAFFOLD_FILES.keys()), - ...REQUIRED_WORKFLOW_FILES, + ...REQUIRED_MANAGED_REPO_FILES, ]; for (const relativePath of requiredPaths) { @@ -5294,7 +5271,7 @@ function runScanInternal(options) { level: 'error', code: 'missing-managed-file', path: relativePath, - message: `Missing managed workflow file: ${relativePath}`, + message: `Missing managed repo file: ${relativePath}`, }); } } @@ -5902,15 +5879,7 @@ function doctor(rawArgs) { function review(rawArgs) { const options = parseReviewArgs(rawArgs); const repoRoot = resolveRepoRoot(options.target); - const reviewScriptPath = path.join(repoRoot, 'scripts', 'review-bot-watch.sh'); - if (!fs.existsSync(reviewScriptPath)) { - throw new Error( - `Missing review bot script: ${reviewScriptPath}\n` + - `Run '${SHORT_TOOL_NAME} setup --target ${repoRoot}' then '${SHORT_TOOL_NAME} doctor --target ${repoRoot}'.`, - ); - } - - const result = run('bash', [reviewScriptPath, ...options.passthroughArgs], { cwd: repoRoot }); + const result = runReviewBotCommand(repoRoot, options.passthroughArgs); if (isSpawnFailure(result)) { throw result.error; } @@ -6049,24 +6018,9 @@ function spawnDetachedAgentProcess({ command, args, cwd, logPath }) { function agents(rawArgs) { const options = parseAgentsArgs(rawArgs); const repoRoot = resolveRepoRoot(options.target); - const reviewScriptPath = path.join(repoRoot, 'scripts', 'review-bot-watch.sh'); - const pruneScriptPath = path.join(repoRoot, 'scripts', 'agent-worktree-prune.sh'); const statePath = agentsStatePathForRepo(repoRoot); if (options.subcommand === 'start') { - if (!fs.existsSync(reviewScriptPath)) { - throw new Error( - `Missing review bot script: ${reviewScriptPath}\n` + - `Run '${SHORT_TOOL_NAME} setup --target ${repoRoot}' then '${SHORT_TOOL_NAME} doctor --target ${repoRoot}'.`, - ); - } - if (!fs.existsSync(pruneScriptPath)) { - throw new Error( - `Missing cleanup script: ${pruneScriptPath}\n` + - `Run '${SHORT_TOOL_NAME} setup --target ${repoRoot}' then '${SHORT_TOOL_NAME} doctor --target ${repoRoot}'.`, - ); - } - const existingState = readAgentsState(repoRoot); const existingReviewPid = Number.parseInt(String(existingState?.review?.pid || ''), 10); const existingCleanupPid = Number.parseInt(String(existingState?.cleanup?.pid || ''), 10); @@ -6091,8 +6045,17 @@ function agents(rawArgs) { if (!reviewRunning) { reviewPid = spawnDetachedAgentProcess({ - command: 'bash', - args: [reviewScriptPath, '--interval', String(options.reviewIntervalSeconds)], + command: process.execPath, + args: [ + path.resolve(__filename), + 'internal', + 'run-shell', + 'reviewBot', + '--target', + repoRoot, + '--interval', + String(options.reviewIntervalSeconds), + ], cwd: repoRoot, logPath: reviewLogPath, }); @@ -6143,7 +6106,7 @@ function agents(rawArgs) { review: { pid: reviewPid, intervalSeconds: reviewIntervalSeconds, - script: reviewScriptPath, + script: path.resolve(__filename), logPath: reviewLogPath, }, cleanup: { @@ -6174,7 +6137,7 @@ function agents(rawArgs) { return; } - const reviewStop = stopAgentProcessByPid(existingState?.review?.pid, 'review-bot-watch.sh'); + const reviewStop = stopAgentProcessByPid(existingState?.review?.pid, 'internal run-shell reviewBot'); const cleanupStop = stopAgentProcessByPid(existingState?.cleanup?.pid, `${path.basename(__filename)} cleanup`); if (fs.existsSync(statePath)) { @@ -6870,7 +6833,7 @@ function doctorAudit(rawArgs) { ok('git core.hooksPath is .githooks'); } - for (const relativePath of REQUIRED_WORKFLOW_FILES) { + for (const relativePath of REQUIRED_MANAGED_REPO_FILES) { const absolutePath = path.join(repoRoot, relativePath); if (!fs.existsSync(absolutePath)) { fail(`missing ${relativePath}`); @@ -7084,7 +7047,10 @@ function internal(rawArgs) { throw new Error(`Unknown internal command: ${subcommand || '(missing)'}`); } const { target, passthrough } = extractTargetedArgs(rest); - const result = runPackageAsset(assetKey, passthrough, { cwd: resolveRepoRoot(target) }); + const repoRoot = resolveRepoRoot(target); + const result = assetKey === 'reviewBot' + ? runReviewBotCommand(repoRoot, passthrough) + : runPackageAsset(assetKey, passthrough, { cwd: repoRoot }); if (result.stdout) process.stdout.write(result.stdout); if (result.stderr) process.stderr.write(result.stderr); process.exitCode = result.status; @@ -7149,7 +7115,7 @@ function migrate(rawArgs) { } const removableLegacyFiles = LEGACY_MANAGED_REPO_FILES.filter( - (relativePath) => !REQUIRED_WORKFLOW_FILES.includes(relativePath), + (relativePath) => !REQUIRED_MANAGED_REPO_FILES.includes(relativePath), ); const removalOps = removableLegacyFiles.map((relativePath) => removeLegacyManagedRepoFile(repoRoot, relativePath, { dryRun, force })); removalOps.push(removeLegacyPackageScripts(repoRoot, dryRun)); @@ -7160,10 +7126,6 @@ function migrate(rawArgs) { function cleanup(rawArgs) { const options = parseCleanupArgs(rawArgs); const repoRoot = resolveRepoRoot(options.target); - const pruneScript = path.join(repoRoot, 'scripts', 'agent-worktree-prune.sh'); - if (!fs.existsSync(pruneScript)) { - throw new Error(`Missing cleanup script: ${pruneScript}. Run '${SHORT_TOOL_NAME} setup' first.`); - } const args = []; if (options.base) { @@ -7229,11 +7191,6 @@ function cleanup(rawArgs) { function merge(rawArgs) { const options = parseMergeArgs(rawArgs); const repoRoot = resolveRepoRoot(options.target); - const mergeScript = path.join(repoRoot, 'scripts', 'agent-branch-merge.sh'); - - if (!fs.existsSync(mergeScript)) { - throw new Error(`Missing merge script: ${mergeScript}. Run '${SHORT_TOOL_NAME} setup' first.`); - } const args = []; if (options.base) { @@ -7269,11 +7226,6 @@ function merge(rawArgs) { function finish(rawArgs, defaults = {}) { const options = parseFinishArgs(rawArgs, defaults); const repoRoot = resolveRepoRoot(options.target); - const finishScript = path.join(repoRoot, 'scripts', 'agent-branch-finish.sh'); - - if (!fs.existsSync(finishScript)) { - throw new Error(`Missing finish script: ${finishScript}. Run '${SHORT_TOOL_NAME} setup' first.`); - } const worktreeEntries = listAgentWorktrees(repoRoot); const worktreeByBranch = new Map(worktreeEntries.map((entry) => [entry.branch, entry.worktreePath])); diff --git a/openspec/changes/agent-codex-zero-copy-cli-install-surface-2026-04-22-01-28/proposal.md b/openspec/changes/agent-codex-zero-copy-cli-install-surface-2026-04-22-01-28/proposal.md new file mode 100644 index 0000000..dd34cfe --- /dev/null +++ b/openspec/changes/agent-codex-zero-copy-cli-install-surface-2026-04-22-01-28/proposal.md @@ -0,0 +1,26 @@ +## Why + +- `gx` already owns the branch, lock, hook, and cleanup behavior, but fresh installs still leave repo-local `scripts/*` workflow shims behind as a second command surface. +- That remaining shim layer keeps `doctor`, `setup`, status checks, docs, and templates coupled to files that do not hold repo-specific state. +- The result is still a distributed install model: the CLI is authoritative, but repos are treated as a file-distribution medium for command entrypoints and presence markers. + +## What Changes + +- Remove repo-local workflow shims from the managed install surface and make `gx` subcommands the only canonical workflow entrypoints for branch, finish, merge, lock, cleanup, review, and OpenSpec bootstrap flows. +- Keep repo-local hook shims only; each installed hook stays a tiny `gx hook run ...` dispatcher. +- Shrink the managed repo footprint to only repo-local state and guidance: managed AGENTS block, hook shims, `.omx/.omc` scaffold, lock registry, and managed `.gitignore`. +- Teach `gx migrate` to remove leftover repo-local workflow shims and legacy command-script injections while preserving real repo-local state. +- Simplify `gx doctor` and related health checks so they validate the smaller install surface and stop treating missing repo-local workflow shims as drift. + +## Scope + +- `bin/multiagent-safety.js` +- managed install/doctor/migrate/status logic +- hook templates and docs/templates that still mention repo-local workflow shims +- install/migrate/status tests + +## Risks + +- Existing repos may still rely on `scripts/*` paths in local habits, CI, or agent prompts; migration and docs need a clear deprecation path. +- `gx finish`, `gx merge`, and `gx cleanup` currently still use repo-local shim presence as an install marker; removing that check must not weaken repo-root validation. +- `doctor` can shrink materially, but protected-branch AGENTS/hook repair still needs a deliberate posture instead of silently regressing branch-safety guarantees. diff --git a/openspec/changes/agent-codex-zero-copy-cli-install-surface-2026-04-22-01-28/specs/zero-copy-cli-install-surface/spec.md b/openspec/changes/agent-codex-zero-copy-cli-install-surface-2026-04-22-01-28/specs/zero-copy-cli-install-surface/spec.md new file mode 100644 index 0000000..c00f439 --- /dev/null +++ b/openspec/changes/agent-codex-zero-copy-cli-install-surface-2026-04-22-01-28/specs/zero-copy-cli-install-surface/spec.md @@ -0,0 +1,66 @@ +## ADDED Requirements + +### Requirement: Setup installs only repo-local state plus hook shims + +`gx setup` and `gx doctor` SHALL keep the managed repo footprint limited to repo-specific state, guidance, and hook dispatch shims. + +#### Scenario: setup installs the zero-copy footprint + +- **GIVEN** a repo opts into Guardex +- **WHEN** `gx setup` runs +- **THEN** it installs the managed AGENTS block, `.githooks/*` dispatch shims, `.omx/.omc` scaffold, lock registry state, and the managed `.gitignore` block +- **AND** it does not install repo-local workflow command shims under `scripts/` +- **AND** it does not copy workflow implementations, repo-local Codex/Claude skills, or inject Guardex-managed `agent:*` helper scripts into `package.json` + +#### Scenario: doctor repairs the zero-copy footprint without restoring workflow shims + +- **GIVEN** a repo already uses the zero-copy Guardex surface +- **WHEN** `gx doctor` repairs drift +- **THEN** it restores the managed AGENTS block, hook shims, lock registry, `.omx/.omc` scaffold, and managed `.gitignore` entries as needed +- **AND** it does not recreate repo-local workflow command shims, copied workflow implementations, repo-local skills, or Guardex-managed `agent:*` package scripts + +### Requirement: Workflow commands run directly from the CLI + +The CLI SHALL expose the Guardex workflow directly without requiring repo-local command shims to exist. + +#### Scenario: direct CLI workflow commands succeed in a zero-copy repo + +- **GIVEN** a repo with the zero-copy Guardex install surface +- **WHEN** a user runs `gx branch start`, `gx branch finish`, `gx branch merge`, `gx locks claim`, `gx worktree prune`, `gx finish`, or `gx cleanup` +- **THEN** the command executes using package-owned logic +- **AND** it does not require `scripts/agent-branch-*.sh`, `scripts/agent-file-locks.py`, `scripts/review-bot-watch.sh`, `scripts/codex-agent.sh`, or `scripts/openspec/*.sh` to exist in the repo + +### Requirement: Hook shims remain tiny dispatchers + +Installed repo hooks SHALL continue delegating to CLI-owned hook logic instead of embedding guard behavior inline. + +#### Scenario: pre-commit hook is a shim + +- **GIVEN** `gx setup` installed repo hooks +- **WHEN** `.githooks/pre-commit` is inspected or executed +- **THEN** it delegates to `gx hook run pre-commit` +- **AND** the guarded pre-commit behavior still enforces the same branch and lock rules + +### Requirement: Migration removes repo-local workflow shims + +The CLI SHALL provide a migration path from the partial CLI-owned surface to the zero-copy surface. + +#### Scenario: migrate removes leftover workflow shims + +- **GIVEN** a repo still contains Guardex-managed workflow command shims under `scripts/`, copied repo-local skills, or injected `agent:*` package scripts +- **WHEN** `gx migrate` runs +- **THEN** it replaces hooks with dispatch shims when needed +- **AND** it removes the leftover repo-local workflow command shims and managed `agent:*` script injections +- **AND** it removes repo-local Guardex skill copies when matching user-level installs are present +- **AND** it leaves the AGENTS block, `.omx/.omc` scaffold, lock registry, and managed `.gitignore` in the zero-copy form + +### Requirement: Status and doctor ignore removed workflow shims + +Guardex health checks SHALL treat the zero-copy footprint as authoritative. + +#### Scenario: status reports healthy without repo-local workflow shims + +- **GIVEN** a repo is fully migrated to the zero-copy Guardex surface +- **WHEN** `gx status --strict` or `gx doctor` inspects the repo +- **THEN** missing repo-local workflow command shims do not count as drift +- **AND** health reporting focuses on the managed AGENTS block, hook shims, `.omx/.omc` scaffold, lock registry, and managed `.gitignore` diff --git a/openspec/changes/agent-codex-zero-copy-cli-install-surface-2026-04-22-01-28/tasks.md b/openspec/changes/agent-codex-zero-copy-cli-install-surface-2026-04-22-01-28/tasks.md new file mode 100644 index 0000000..915ab2a --- /dev/null +++ b/openspec/changes/agent-codex-zero-copy-cli-install-surface-2026-04-22-01-28/tasks.md @@ -0,0 +1,31 @@ +## 1. Spec + +- [x] 1.1 Capture the zero-copy repo footprint and direct-CLI workflow requirements in `specs/zero-copy-cli-install-surface/spec.md`. +- [x] 1.2 Record the rationale, migration posture, and doctor simplification target in `proposal.md`. + +## 2. Tests + +- [x] 2.1 Update install/setup/doctor/status coverage so zero-copy repos stay healthy without any Guardex-managed workflow shims under `scripts/`. +- [x] 2.2 Add regression coverage proving direct CLI commands (`gx branch ...`, `gx locks ...`, `gx finish`, `gx cleanup`, `gx migrate`) work without repo-local workflow shims present. +- [x] 2.3 Add migration coverage that removes leftover `scripts/*` command shims while preserving hook shims, AGENTS, `.omx/.omc`, lock registry, and managed `.gitignore`. + +## 3. Implementation + +- [x] 3.1 Remove repo-local workflow command shims from the managed install/repair footprint (`SCRIPT_SHIMS`, related required-path lists, critical-path lists, and docs/templates). +- [x] 3.2 Remove CLI runtime checks that still require repo-local workflow shims to exist before `gx finish`, `gx merge`, `gx cleanup`, or related direct commands run. +- [x] 3.3 Update `gx migrate` cleanup to delete leftover workflow command shims by default and keep only the zero-copy footprint. +- [x] 3.4 Simplify status/doctor/install output and drift detection so missing repo-local workflow shims no longer trigger repair noise. +- [x] 3.5 Update README, managed AGENTS guidance, and skill/prompt references to teach `gx ...` as the only workflow command surface. + +## 4. Verification + +- [x] 4.1 Run `node --check bin/multiagent-safety.js`. Result: passed on `2026-04-22`. +- [x] 4.2 Run the focused install/migrate/status suite: `node --test test/install.test.js`. Result: passed `135/135` on `2026-04-22`. +- [x] 4.3 Run `openspec validate agent-codex-zero-copy-cli-install-surface-2026-04-22-01-28 --type change --strict`. +- [x] 4.4 Run `openspec validate --specs`. + +## 5. Cleanup + +- [x] 5.1 Reconcile the shipped README/install docs with the zero-copy repo footprint and note any intentional compatibility leftovers. README now documents the hook-only install footprint and notes that `gx migrate` removes leftover workflow shims while the CLI still honors repo-local `scripts/review-bot-watch.sh` / `scripts/codex-agent.sh` during migration. +- [ ] 5.2 Finish the agent branch via PR merge + cleanup (`gx finish --via-pr --wait-for-merge --cleanup` or `bash scripts/agent-branch-finish.sh --branch --base --via-pr --wait-for-merge --cleanup`). +- [ ] 5.3 Record PR URL + final `MERGED` evidence in the completion handoff. diff --git a/scripts/agent-branch-merge.sh b/scripts/agent-branch-merge.sh index c8ab622..ac47f6d 100755 --- a/scripts/agent-branch-merge.sh +++ b/scripts/agent-branch-merge.sh @@ -6,18 +6,37 @@ BASE_BRANCH_EXPLICIT=0 TARGET_BRANCH="" TASK_NAME="" AGENT_NAME="${GUARDEX_MERGE_AGENT_NAME:-codex}" +NODE_BIN="${GUARDEX_NODE_BIN:-node}" +CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}" declare -a SOURCE_BRANCHES=() usage() { cat <<'EOF' -Usage: scripts/agent-branch-merge.sh --branch [--branch ...] [--into ] [--task ] [--agent ] [--base ] +Usage: gx branch merge --branch [--branch ...] [--into ] [--task ] [--agent ] [--base ] Examples: - bash scripts/agent-branch-merge.sh --branch agent/codex/ui-a --branch agent/codex/ui-b - bash scripts/agent-branch-merge.sh --into agent/codex/owner-lane --branch agent/codex/helper-a --branch agent/codex/helper-b + gx branch merge --branch agent/codex/ui-a --branch agent/codex/ui-b + gx branch merge --into agent/codex/owner-lane --branch agent/codex/helper-a --branch agent/codex/helper-b EOF } +run_guardex_cli() { + if [[ -n "$CLI_ENTRY" ]]; then + "$NODE_BIN" "$CLI_ENTRY" "$@" + return $? + fi + if command -v gx >/dev/null 2>&1; then + gx "$@" + return $? + fi + if command -v gitguardex >/dev/null 2>&1; then + gitguardex "$@" + return $? + fi + echo "[agent-branch-merge] Guardex CLI entrypoint unavailable; rerun via gx." >&2 + return 127 +} + sanitize_slug() { local raw="$1" local fallback="${2:-merge-agent-branches}" @@ -262,7 +281,7 @@ if [[ -z "$TARGET_BRANCH" ]]; then start_output="" if ! start_output="$( cd "$repo_root" - env GUARDEX_OPENSPEC_AUTO_INIT=1 bash "scripts/agent-branch-start.sh" "$TASK_NAME" "$AGENT_NAME" "$BASE_BRANCH" 2>&1 + GUARDEX_OPENSPEC_AUTO_INIT=1 run_guardex_cli branch start "$TASK_NAME" "$AGENT_NAME" "$BASE_BRANCH" 2>&1 )"; then printf '%s\n' "$start_output" >&2 exit 1 @@ -418,4 +437,4 @@ echo "[agent-branch-merge] Merge sequence complete for '${TARGET_BRANCH}'." if [[ "$target_created" -eq 1 ]]; then echo "[agent-branch-merge] Review and verify in '${target_worktree}', then finish the integration branch when ready." fi -echo "[agent-branch-merge] Next step: bash scripts/agent-branch-finish.sh --branch \"${TARGET_BRANCH}\" --base \"${BASE_BRANCH}\" --via-pr --wait-for-merge --cleanup" +echo "[agent-branch-merge] Next step: gx branch finish --branch \"${TARGET_BRANCH}\" --base \"${BASE_BRANCH}\" --via-pr --wait-for-merge --cleanup" diff --git a/scripts/agent-file-locks.py b/scripts/agent-file-locks.py index 06cdd7a..2abaa53 100755 --- a/scripts/agent-file-locks.py +++ b/scripts/agent-file-locks.py @@ -2,11 +2,11 @@ """Per-file lock registry for concurrent agent branches. Usage examples: - python3 scripts/agent-file-locks.py claim --branch agent/a path/to/file1 path/to/file2 - python3 scripts/agent-file-locks.py claim --branch agent/a --allow-delete path/to/obsolete-file - python3 scripts/agent-file-locks.py allow-delete --branch agent/a path/to/obsolete-file - python3 scripts/agent-file-locks.py validate --branch agent/a --staged - python3 scripts/agent-file-locks.py release --branch agent/a + gx locks claim --branch agent/a path/to/file1 path/to/file2 + gx locks claim --branch agent/a --allow-delete path/to/obsolete-file + gx locks allow-delete --branch agent/a path/to/obsolete-file + gx locks validate --branch agent/a --staged + gx locks release --branch agent/a """ from __future__ import annotations @@ -27,9 +27,9 @@ 'AGENTS.md', '.githooks/pre-commit', '.githooks/pre-push', - 'scripts/agent-branch-start.sh', - 'scripts/agent-branch-finish.sh', - 'scripts/agent-file-locks.py', + '.githooks/post-merge', + '.githooks/post-checkout', + 'scripts/guardex-env.sh', } ALLOW_GUARDRAIL_DELETE_ENV = 'AGENT_ALLOW_GUARDRAIL_DELETE' @@ -326,11 +326,11 @@ def cmd_validate(args: argparse.Namespace, repo_root: Path) -> int: print(f' - {file_path}', file=sys.stderr) print(' Approve explicit deletions with one of:', file=sys.stderr) print( - f' python3 scripts/agent-file-locks.py claim --branch "{args.branch}" --allow-delete ', + f' gx locks claim --branch "{args.branch}" --allow-delete ', file=sys.stderr, ) print( - f' python3 scripts/agent-file-locks.py allow-delete --branch "{args.branch}" ', + f' gx locks allow-delete --branch "{args.branch}" ', file=sys.stderr, ) if guardrail_delete_blocked: @@ -343,7 +343,7 @@ def cmd_validate(args: argparse.Namespace, repo_root: Path) -> int: ) print('\nClaim files with:', file=sys.stderr) - print(f' python3 scripts/agent-file-locks.py claim --branch "{args.branch}" ', file=sys.stderr) + print(f' gx locks claim --branch "{args.branch}" ', file=sys.stderr) return 1 diff --git a/scripts/review-bot-watch.sh b/scripts/review-bot-watch.sh index f98d0ef..064a71a 100755 --- a/scripts/review-bot-watch.sh +++ b/scripts/review-bot-watch.sh @@ -9,10 +9,12 @@ BASE_BRANCH="${GUARDEX_REVIEW_BOT_BASE_BRANCH:-}" ONLY_PR="${GUARDEX_REVIEW_BOT_ONLY_PR:-}" RETRY_FAILED_RAW="${GUARDEX_REVIEW_BOT_RETRY_FAILED:-false}" INCLUDE_DRAFT_RAW="${GUARDEX_REVIEW_BOT_INCLUDE_DRAFT:-false}" +NODE_BIN="${GUARDEX_NODE_BIN:-node}" +CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}" usage() { cat <<'USAGE' -Usage: bash scripts/review-bot-watch.sh [options] +Usage: gx review [options] Continuously monitor GitHub pull requests targeting a base branch and dispatch one Codex-agent task per newly opened/updated PR. @@ -34,6 +36,23 @@ Environment overrides: USAGE } +run_guardex_cli() { + if [[ -n "$CLI_ENTRY" ]]; then + "$NODE_BIN" "$CLI_ENTRY" "$@" + return $? + fi + if command -v gx >/dev/null 2>&1; then + gx "$@" + return $? + fi + if command -v gitguardex >/dev/null 2>&1; then + gitguardex "$@" + return $? + fi + echo "[review-bot-watch] Guardex CLI entrypoint unavailable; rerun via gx." >&2 + return 127 +} + normalize_bool() { local raw="${1:-}" local fallback="${2:-0}" @@ -134,16 +153,20 @@ if ! command -v codex >/dev/null 2>&1; then exit 127 fi -if [[ ! -x "$repo_root/scripts/codex-agent.sh" ]]; then - echo "[review-bot-watch] Missing scripts/codex-agent.sh. Run: gx setup" >&2 - exit 1 -fi - if ! gh auth status >/dev/null 2>&1; then echo "[review-bot-watch] gh is not authenticated. Run: gh auth login" >&2 exit 1 fi +run_codex_agent() { + local local_script="$repo_root/scripts/codex-agent.sh" + if [[ -x "$local_script" ]]; then + bash "$local_script" "$@" + return $? + fi + run_guardex_cli internal run-shell codexAgent --target "$repo_root" "$@" +} + sanitize_slug() { local raw="$1" local fallback="$2" @@ -262,7 +285,7 @@ process_one_pr() { echo "[review-bot-watch] Dispatching Codex agent for PR #${pr} (${head_branch})" set +e - bash "$repo_root/scripts/codex-agent.sh" \ + run_codex_agent \ --task "$task_name" \ --agent "$AGENT_NAME" \ --base "$BASE_BRANCH" \ diff --git a/templates/codex/skills/gitguardex/SKILL.md b/templates/codex/skills/gitguardex/SKILL.md index 0d3c6a6..e156736 100644 --- a/templates/codex/skills/gitguardex/SKILL.md +++ b/templates/codex/skills/gitguardex/SKILL.md @@ -8,4 +8,4 @@ Use when repo safety may be broken. `gx status` -> `gx doctor` -> `gx status --strict` Bootstrap: `gx setup` -Ops: `bash scripts/codex-agent.sh "" ""`, `gx finish --all`, `gx cleanup` +Ops: `gx branch start "" ""`, `gx locks claim --branch "" `, `gx branch finish --branch "" --base --via-pr --wait-for-merge --cleanup`, `gx finish --all`, `gx cleanup` diff --git a/templates/codex/skills/guardex-merge-skills-to-dev/SKILL.md b/templates/codex/skills/guardex-merge-skills-to-dev/SKILL.md index 7e2d13e..dc2920f 100644 --- a/templates/codex/skills/guardex-merge-skills-to-dev/SKILL.md +++ b/templates/codex/skills/guardex-merge-skills-to-dev/SKILL.md @@ -24,7 +24,7 @@ echo "$BASE_BRANCH" 2. Start a dedicated integration sandbox from base: ```sh -bash scripts/agent-branch-start.sh "merge-skill-files-to-${BASE_BRANCH}" "skill-merge" "$BASE_BRANCH" +gx branch start "merge-skill-files-to-${BASE_BRANCH}" "skill-merge" "$BASE_BRANCH" ``` 3. Enter the sandbox worktree printed by the command above. @@ -48,11 +48,11 @@ git diff --name-only ```sh git add .codex/skills templates/codex/skills git commit -m "Merge skill file updates into ${BASE_BRANCH}" -bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)" --base "$BASE_BRANCH" --via-pr --wait-for-merge --cleanup +gx branch finish --branch "$(git rev-parse --abbrev-ref HEAD)" --base "$BASE_BRANCH" --via-pr --wait-for-merge --cleanup ``` ## Notes - If a source branch has non-skill changes, this runbook keeps them out of the merge. -- If merge conflicts occur, resolve only within the skill files, then rerun `agent-branch-finish.sh`. +- If merge conflicts occur, resolve only within the skill files, then rerun `gx branch finish`. - Do not commit directly on `dev`/`main`; always merge through an agent branch/worktree. diff --git a/templates/githooks/pre-commit b/templates/githooks/pre-commit index 444cb3e..b34c919 100755 --- a/templates/githooks/pre-commit +++ b/templates/githooks/pre-commit @@ -13,6 +13,8 @@ repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" if [[ -z "$repo_root" ]]; then exit 0 fi +NODE_BIN="${GUARDEX_NODE_BIN:-node}" +CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}" guardex_env_helper="${repo_root}/scripts/guardex-env.sh" if [[ -f "$guardex_env_helper" ]]; then # shellcheck source=/dev/null @@ -22,6 +24,23 @@ if declare -F guardex_repo_is_enabled >/dev/null 2>&1 && ! guardex_repo_is_enabl exit 0 fi +run_guardex_cli() { + if [[ -n "$CLI_ENTRY" ]]; then + "$NODE_BIN" "$CLI_ENTRY" "$@" + return $? + fi + if command -v gx >/dev/null 2>&1; then + gx "$@" + return $? + fi + if command -v gitguardex >/dev/null 2>&1; then + gitguardex "$@" + return $? + fi + echo "[agent-branch-guard] Guardex CLI entrypoint unavailable; rerun via gx." >&2 + return 127 +} + if [[ "${ALLOW_COMMIT_ON_PROTECTED_BRANCH:-0}" == "1" ]]; then exit 0 fi @@ -191,11 +210,11 @@ if [[ "$branch" == agent/* ]]; then while IFS= read -r staged_file; do [[ -z "$staged_file" ]] && continue [[ "$staged_file" == ".omx/state/agent-file-locks.json" ]] && continue - python3 scripts/agent-file-locks.py claim --branch "$branch" "$staged_file" >/dev/null 2>&1 || true + run_guardex_cli locks claim --branch "$branch" "$staged_file" >/dev/null 2>&1 || true done < <(git diff --cached --name-only --diff-filter=ACMRDTUXB) fi - if ! python3 scripts/agent-file-locks.py validate --branch "$branch" --staged; then + if ! run_guardex_cli locks validate --branch "$branch" --staged; then cat >&2 <<'MSG' [agent-branch-guard] Agent branch commits require file ownership locks. Claim files first: diff --git a/templates/scripts/agent-branch-finish.sh b/templates/scripts/agent-branch-finish.sh index fb528e1..fa67866 100755 --- a/templates/scripts/agent-branch-finish.sh +++ b/templates/scripts/agent-branch-finish.sh @@ -9,11 +9,30 @@ DELETE_REMOTE_BRANCH=0 DELETE_REMOTE_BRANCH_EXPLICIT=0 MERGE_MODE="auto" GH_BIN="${GUARDEX_GH_BIN:-gh}" +NODE_BIN="${GUARDEX_NODE_BIN:-node}" +CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}" CLEANUP_AFTER_MERGE_RAW="${GUARDEX_FINISH_CLEANUP:-false}" WAIT_FOR_MERGE_RAW="${GUARDEX_FINISH_WAIT_FOR_MERGE:-false}" WAIT_TIMEOUT_SECONDS_RAW="${GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS:-1800}" WAIT_POLL_SECONDS_RAW="${GUARDEX_FINISH_WAIT_POLL_SECONDS:-10}" +run_guardex_cli() { + if [[ -n "$CLI_ENTRY" ]]; then + "$NODE_BIN" "$CLI_ENTRY" "$@" + return $? + fi + if command -v gx >/dev/null 2>&1; then + gx "$@" + return $? + fi + if command -v gitguardex >/dev/null 2>&1; then + gitguardex "$@" + return $? + fi + echo "[agent-branch-finish] Guardex CLI entrypoint unavailable; rerun via gx." >&2 + return 127 +} + normalize_bool() { local raw="${1:-}" local fallback="${2:-0}" @@ -431,7 +450,7 @@ run_pr_flow() { if [[ -z "$pr_title" ]]; then pr_title="Merge ${SOURCE_BRANCH} into ${BASE_BRANCH}" fi - pr_body="Automated by scripts/agent-branch-finish.sh (PR flow)." + pr_body="Automated by gx branch finish (PR flow)." "$GH_BIN" pr create \ --base "$BASE_BRANCH" \ @@ -517,9 +536,7 @@ if [[ "$PUSH_ENABLED" -eq 1 ]]; then fi fi -if [[ -x "${repo_root}/scripts/agent-file-locks.py" ]]; then - python3 "${repo_root}/scripts/agent-file-locks.py" release --branch "$SOURCE_BRANCH" >/dev/null 2>&1 || true -fi +run_guardex_cli locks release --branch "$SOURCE_BRANCH" >/dev/null 2>&1 || true base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")" if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then @@ -555,29 +572,25 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then fi fi - if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then - prune_args=(--base "$BASE_BRANCH" --only-dirty-worktrees --delete-branches) - if [[ "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then - prune_args+=(--delete-remote-branches) - fi - if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then - echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2 - echo "[agent-branch-finish] You can run manual cleanup: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches" >&2 - fi + prune_args=(--base "$BASE_BRANCH" --only-dirty-worktrees --delete-branches) + if [[ "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then + prune_args+=(--delete-remote-branches) + fi + if ! run_guardex_cli worktree prune "${prune_args[@]}"; then + echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2 + echo "[agent-branch-finish] You can run manual cleanup: gx cleanup --base ${BASE_BRANCH}" >&2 fi echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and cleaned source branch/worktree." if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${agent_worktree_root}"/* ]]; then echo "[agent-branch-finish] Current worktree '${source_worktree}' still exists because it is the active shell cwd." >&2 - echo "[agent-branch-finish] Leave this directory, then run: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches" >&2 + echo "[agent-branch-finish] Leave this directory, then run: gx cleanup --base ${BASE_BRANCH}" >&2 fi else - if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then - if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" --base "$BASE_BRANCH"; then - echo "[agent-branch-finish] Warning: temporary worktree prune failed." >&2 - fi + if ! run_guardex_cli worktree prune --base "$BASE_BRANCH"; then + echo "[agent-branch-finish] Warning: temporary worktree prune failed." >&2 fi echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and kept source branch/worktree." - echo "[agent-branch-finish] Cleanup later with: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches --delete-remote-branches" + echo "[agent-branch-finish] Cleanup later with: gx cleanup --base ${BASE_BRANCH}" fi diff --git a/templates/scripts/agent-branch-merge.sh b/templates/scripts/agent-branch-merge.sh index c8ab622..ac47f6d 100755 --- a/templates/scripts/agent-branch-merge.sh +++ b/templates/scripts/agent-branch-merge.sh @@ -6,18 +6,37 @@ BASE_BRANCH_EXPLICIT=0 TARGET_BRANCH="" TASK_NAME="" AGENT_NAME="${GUARDEX_MERGE_AGENT_NAME:-codex}" +NODE_BIN="${GUARDEX_NODE_BIN:-node}" +CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}" declare -a SOURCE_BRANCHES=() usage() { cat <<'EOF' -Usage: scripts/agent-branch-merge.sh --branch [--branch ...] [--into ] [--task ] [--agent ] [--base ] +Usage: gx branch merge --branch [--branch ...] [--into ] [--task ] [--agent ] [--base ] Examples: - bash scripts/agent-branch-merge.sh --branch agent/codex/ui-a --branch agent/codex/ui-b - bash scripts/agent-branch-merge.sh --into agent/codex/owner-lane --branch agent/codex/helper-a --branch agent/codex/helper-b + gx branch merge --branch agent/codex/ui-a --branch agent/codex/ui-b + gx branch merge --into agent/codex/owner-lane --branch agent/codex/helper-a --branch agent/codex/helper-b EOF } +run_guardex_cli() { + if [[ -n "$CLI_ENTRY" ]]; then + "$NODE_BIN" "$CLI_ENTRY" "$@" + return $? + fi + if command -v gx >/dev/null 2>&1; then + gx "$@" + return $? + fi + if command -v gitguardex >/dev/null 2>&1; then + gitguardex "$@" + return $? + fi + echo "[agent-branch-merge] Guardex CLI entrypoint unavailable; rerun via gx." >&2 + return 127 +} + sanitize_slug() { local raw="$1" local fallback="${2:-merge-agent-branches}" @@ -262,7 +281,7 @@ if [[ -z "$TARGET_BRANCH" ]]; then start_output="" if ! start_output="$( cd "$repo_root" - env GUARDEX_OPENSPEC_AUTO_INIT=1 bash "scripts/agent-branch-start.sh" "$TASK_NAME" "$AGENT_NAME" "$BASE_BRANCH" 2>&1 + GUARDEX_OPENSPEC_AUTO_INIT=1 run_guardex_cli branch start "$TASK_NAME" "$AGENT_NAME" "$BASE_BRANCH" 2>&1 )"; then printf '%s\n' "$start_output" >&2 exit 1 @@ -418,4 +437,4 @@ echo "[agent-branch-merge] Merge sequence complete for '${TARGET_BRANCH}'." if [[ "$target_created" -eq 1 ]]; then echo "[agent-branch-merge] Review and verify in '${target_worktree}', then finish the integration branch when ready." fi -echo "[agent-branch-merge] Next step: bash scripts/agent-branch-finish.sh --branch \"${TARGET_BRANCH}\" --base \"${BASE_BRANCH}\" --via-pr --wait-for-merge --cleanup" +echo "[agent-branch-merge] Next step: gx branch finish --branch \"${TARGET_BRANCH}\" --base \"${BASE_BRANCH}\" --via-pr --wait-for-merge --cleanup" diff --git a/templates/scripts/agent-branch-start.sh b/templates/scripts/agent-branch-start.sh index ef8cc11..c871372 100755 --- a/templates/scripts/agent-branch-start.sh +++ b/templates/scripts/agent-branch-start.sh @@ -7,6 +7,8 @@ BASE_BRANCH="" BASE_BRANCH_EXPLICIT=0 WORKTREE_ROOT_REL="" WORKTREE_ROOT_EXPLICIT=0 +NODE_BIN="${GUARDEX_NODE_BIN:-node}" +CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}" OPENSPEC_AUTO_INIT_RAW="${GUARDEX_OPENSPEC_AUTO_INIT:-false}" OPENSPEC_PLAN_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_PLAN_SLUG:-}" OPENSPEC_CHANGE_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CHANGE_SLUG:-}" @@ -15,6 +17,23 @@ OPENSPEC_MASTERPLAN_LABEL_RAW="${GUARDEX_OPENSPEC_MASTERPLAN_LABEL-masterplan}" PRINT_NAME_ONLY=0 POSITIONAL_ARGS=() +run_guardex_cli() { + if [[ -n "$CLI_ENTRY" ]]; then + "$NODE_BIN" "$CLI_ENTRY" "$@" + return $? + fi + if command -v gx >/dev/null 2>&1; then + gx "$@" + return $? + fi + if command -v gitguardex >/dev/null 2>&1; then + gitguardex "$@" + return $? + fi + echo "[agent-branch-start] Guardex CLI entrypoint unavailable; rerun via gx." >&2 + return 127 +} + while [[ $# -gt 0 ]]; do case "$1" in --task) @@ -385,26 +404,14 @@ initialize_openspec_plan_workspace() { local worktree="$2" local plan_slug="$3" - hydrate_local_helper_in_worktree "$repo" "$worktree" "scripts/openspec/init-plan-workspace.sh" - if [[ "$OPENSPEC_AUTO_INIT" -ne 1 ]]; then return 0 fi - local openspec_script="${worktree}/scripts/openspec/init-plan-workspace.sh" - if [[ ! -f "$openspec_script" ]]; then - echo "[agent-branch-start] OpenSpec init script is missing in sandbox worktree." >&2 - echo "[agent-branch-start] Run 'gx setup --target \"$repo\"' to repair templates, then retry." >&2 - return 1 - fi - if [[ ! -x "$openspec_script" ]]; then - chmod +x "$openspec_script" 2>/dev/null || true - fi - local init_output="" if ! init_output="$( cd "$worktree" - bash "scripts/openspec/init-plan-workspace.sh" "$plan_slug" 2>&1 + run_guardex_cli internal run-shell planInit "$plan_slug" 2>&1 )"; then printf '%s\n' "$init_output" >&2 echo "[agent-branch-start] OpenSpec workspace initialization failed for plan '${plan_slug}'." >&2 @@ -423,26 +430,14 @@ initialize_openspec_change_workspace() { local change_slug="$3" local capability_slug="$4" - hydrate_local_helper_in_worktree "$repo" "$worktree" "scripts/openspec/init-change-workspace.sh" - if [[ "$OPENSPEC_AUTO_INIT" -ne 1 ]]; then return 0 fi - local openspec_script="${worktree}/scripts/openspec/init-change-workspace.sh" - if [[ ! -f "$openspec_script" ]]; then - echo "[agent-branch-start] OpenSpec change init script is missing in sandbox worktree." >&2 - echo "[agent-branch-start] Run 'gx setup --target \"$repo\"' to repair templates, then retry." >&2 - return 1 - fi - if [[ ! -x "$openspec_script" ]]; then - chmod +x "$openspec_script" 2>/dev/null || true - fi - local init_output="" if ! init_output="$( cd "$worktree" - bash "scripts/openspec/init-change-workspace.sh" "$change_slug" "$capability_slug" 2>&1 + run_guardex_cli internal run-shell changeInit "$change_slug" "$capability_slug" 2>&1 )"; then printf '%s\n' "$init_output" >&2 echo "[agent-branch-start] OpenSpec workspace initialization failed for change '${change_slug}'." >&2 @@ -592,7 +587,6 @@ if [[ -n "$auto_transfer_stash_ref" ]]; then fi fi -hydrate_local_helper_in_worktree "$repo_root" "$worktree_path" "scripts/codex-agent.sh" hydrate_dependency_dir_symlink_in_worktree "$repo_root" "$worktree_path" "node_modules" hydrate_dependency_dir_symlink_in_worktree "$repo_root" "$worktree_path" "apps/frontend/node_modules" hydrate_dependency_dir_symlink_in_worktree "$repo_root" "$worktree_path" "apps/backend/node_modules" @@ -609,6 +603,6 @@ echo "[agent-branch-start] OpenSpec change: openspec/changes/${openspec_change_s echo "[agent-branch-start] OpenSpec plan: openspec/plan/${openspec_plan_slug}" echo "[agent-branch-start] Next steps:" echo " cd \"${worktree_path}\"" -echo " python3 scripts/agent-file-locks.py claim --branch \"${branch_name}\" " +echo " gx locks claim --branch \"${branch_name}\" " echo " # implement + commit" -echo " bash scripts/agent-branch-finish.sh --branch \"${branch_name}\" --base ${BASE_BRANCH} --via-pr --wait-for-merge" +echo " gx branch finish --branch \"${branch_name}\" --base ${BASE_BRANCH} --via-pr --wait-for-merge" diff --git a/templates/scripts/agent-file-locks.py b/templates/scripts/agent-file-locks.py index 06cdd7a..2abaa53 100755 --- a/templates/scripts/agent-file-locks.py +++ b/templates/scripts/agent-file-locks.py @@ -2,11 +2,11 @@ """Per-file lock registry for concurrent agent branches. Usage examples: - python3 scripts/agent-file-locks.py claim --branch agent/a path/to/file1 path/to/file2 - python3 scripts/agent-file-locks.py claim --branch agent/a --allow-delete path/to/obsolete-file - python3 scripts/agent-file-locks.py allow-delete --branch agent/a path/to/obsolete-file - python3 scripts/agent-file-locks.py validate --branch agent/a --staged - python3 scripts/agent-file-locks.py release --branch agent/a + gx locks claim --branch agent/a path/to/file1 path/to/file2 + gx locks claim --branch agent/a --allow-delete path/to/obsolete-file + gx locks allow-delete --branch agent/a path/to/obsolete-file + gx locks validate --branch agent/a --staged + gx locks release --branch agent/a """ from __future__ import annotations @@ -27,9 +27,9 @@ 'AGENTS.md', '.githooks/pre-commit', '.githooks/pre-push', - 'scripts/agent-branch-start.sh', - 'scripts/agent-branch-finish.sh', - 'scripts/agent-file-locks.py', + '.githooks/post-merge', + '.githooks/post-checkout', + 'scripts/guardex-env.sh', } ALLOW_GUARDRAIL_DELETE_ENV = 'AGENT_ALLOW_GUARDRAIL_DELETE' @@ -326,11 +326,11 @@ def cmd_validate(args: argparse.Namespace, repo_root: Path) -> int: print(f' - {file_path}', file=sys.stderr) print(' Approve explicit deletions with one of:', file=sys.stderr) print( - f' python3 scripts/agent-file-locks.py claim --branch "{args.branch}" --allow-delete ', + f' gx locks claim --branch "{args.branch}" --allow-delete ', file=sys.stderr, ) print( - f' python3 scripts/agent-file-locks.py allow-delete --branch "{args.branch}" ', + f' gx locks allow-delete --branch "{args.branch}" ', file=sys.stderr, ) if guardrail_delete_blocked: @@ -343,7 +343,7 @@ def cmd_validate(args: argparse.Namespace, repo_root: Path) -> int: ) print('\nClaim files with:', file=sys.stderr) - print(f' python3 scripts/agent-file-locks.py claim --branch "{args.branch}" ', file=sys.stderr) + print(f' gx locks claim --branch "{args.branch}" ', file=sys.stderr) return 1 diff --git a/templates/scripts/codex-agent.sh b/templates/scripts/codex-agent.sh index 1829d92..6a05817 100755 --- a/templates/scripts/codex-agent.sh +++ b/templates/scripts/codex-agent.sh @@ -7,6 +7,7 @@ BASE_BRANCH="${GUARDEX_BASE_BRANCH:-}" BASE_BRANCH_EXPLICIT=0 CODEX_BIN="${GUARDEX_CODEX_BIN:-codex}" NODE_BIN="${GUARDEX_NODE_BIN:-node}" +CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}" AUTO_FINISH_RAW="${GUARDEX_CODEX_AUTO_FINISH:-true}" AUTO_REVIEW_ON_CONFLICT_RAW="${GUARDEX_CODEX_AUTO_REVIEW_ON_CONFLICT:-true}" AUTO_CLEANUP_RAW="${GUARDEX_CODEX_AUTO_CLEANUP:-true}" @@ -17,6 +18,23 @@ OPENSPEC_CHANGE_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CHANGE_SLUG:-}" OPENSPEC_CAPABILITY_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CAPABILITY_SLUG:-}" OPENSPEC_MASTERPLAN_LABEL_RAW="${GUARDEX_OPENSPEC_MASTERPLAN_LABEL-masterplan}" +run_guardex_cli() { + if [[ -n "$CLI_ENTRY" ]]; then + "$NODE_BIN" "$CLI_ENTRY" "$@" + return $? + fi + if command -v gx >/dev/null 2>&1; then + gx "$@" + return $? + fi + if command -v gitguardex >/dev/null 2>&1; then + gitguardex "$@" + return $? + fi + echo "[codex-agent] Guardex CLI entrypoint unavailable; rerun via gx." >&2 + return 127 +} + normalize_bool() { local raw="${1:-}" local fallback="${2:-0}" @@ -375,11 +393,6 @@ start_sandbox_fallback() { printf '[agent-branch-start] Worktree: %s\n' "$worktree_path" } -if [[ ! -x "${repo_root}/scripts/agent-branch-start.sh" ]]; then - echo "[codex-agent] Missing scripts/agent-branch-start.sh. Run: gx setup" >&2 - exit 1 -fi - start_args=("$TASK_NAME" "$AGENT_NAME") if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then start_args+=("$BASE_BRANCH") @@ -392,7 +405,7 @@ set +e start_output="$( GUARDEX_OPENSPEC_AUTO_INIT="$OPENSPEC_AUTO_INIT" \ GUARDEX_OPENSPEC_MASTERPLAN_LABEL="$OPENSPEC_MASTERPLAN_LABEL_RAW" \ - bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}" 2>&1 + run_guardex_cli branch start "${start_args[@]}" 2>&1 )" start_status=$? set -e @@ -529,7 +542,7 @@ print_takeover_prompt() { change_artifact="openspec/changes/${change_slug}/" fi - finish_cmd="bash scripts/agent-branch-finish.sh --branch \"${branch}\" --base ${base_branch} --via-pr --wait-for-merge --cleanup" + finish_cmd="gx branch finish --branch \"${branch}\" --base ${base_branch} --via-pr --wait-for-merge --cleanup" echo "[codex-agent] Takeover sandbox: ${wt}" echo "[codex-agent] Takeover prompt: Continue \`${change_slug}\` on branch \`${branch}\`. Work inside \`${wt}\`, review \`${change_artifact}\`, continue from the current state instead of creating a new sandbox, and when the work is done run \`${finish_cmd}\`." @@ -585,24 +598,12 @@ ensure_openspec_plan_workspace() { return 0 fi - hydrate_local_helper_in_worktree "$wt" "scripts/openspec/init-plan-workspace.sh" - - local openspec_script="${wt}/scripts/openspec/init-plan-workspace.sh" - if [[ ! -f "$openspec_script" ]]; then - echo "[codex-agent] Missing OpenSpec init script in sandbox: ${openspec_script}" >&2 - echo "[codex-agent] Run 'gx setup --target ${repo_root}' and retry." >&2 - return 1 - fi - if [[ ! -x "$openspec_script" ]]; then - chmod +x "$openspec_script" 2>/dev/null || true - fi - local plan_slug plan_slug="$(resolve_openspec_plan_slug "$branch")" local init_output="" if ! init_output="$( cd "$wt" - bash "scripts/openspec/init-plan-workspace.sh" "$plan_slug" 2>&1 + run_guardex_cli internal run-shell planInit "$plan_slug" 2>&1 )"; then printf '%s\n' "$init_output" >&2 echo "[codex-agent] OpenSpec workspace initialization failed for plan '${plan_slug}'." >&2 @@ -622,24 +623,12 @@ ensure_openspec_change_workspace() { return 0 fi - hydrate_local_helper_in_worktree "$wt" "scripts/openspec/init-change-workspace.sh" - - local openspec_script="${wt}/scripts/openspec/init-change-workspace.sh" - if [[ ! -f "$openspec_script" ]]; then - echo "[codex-agent] Missing OpenSpec change init script in sandbox: ${openspec_script}" >&2 - echo "[codex-agent] Run 'gx setup --target ${repo_root}' and retry." >&2 - return 1 - fi - if [[ ! -x "$openspec_script" ]]; then - chmod +x "$openspec_script" 2>/dev/null || true - fi - local change_slug capability_slug init_output="" change_slug="$(resolve_openspec_change_slug "$branch")" capability_slug="$(resolve_openspec_capability_slug)" if ! init_output="$( cd "$wt" - bash "scripts/openspec/init-change-workspace.sh" "$change_slug" "$capability_slug" 2>&1 + run_guardex_cli internal run-shell changeInit "$change_slug" "$capability_slug" 2>&1 )"; then printf '%s\n' "$init_output" >&2 echo "[codex-agent] OpenSpec workspace initialization failed for change '${change_slug}'." >&2 @@ -668,11 +657,6 @@ worktree_has_changes() { claim_changed_files() { local wt="$1" local branch="$2" - local lock_script="${repo_root}/scripts/agent-file-locks.py" - - if [[ ! -x "$lock_script" ]]; then - return 0 - fi local changed_raw deleted_raw changed_raw="$({ @@ -683,7 +667,7 @@ claim_changed_files() { if [[ -n "$changed_raw" ]]; then mapfile -t changed_files < <(printf '%s\n' "$changed_raw") - python3 "$lock_script" claim --branch "$branch" "${changed_files[@]}" >/dev/null 2>&1 || true + run_guardex_cli locks claim --branch "$branch" "${changed_files[@]}" >/dev/null 2>&1 || true fi deleted_raw="$({ @@ -693,7 +677,7 @@ claim_changed_files() { if [[ -n "$deleted_raw" ]]; then mapfile -t deleted_files < <(printf '%s\n' "$deleted_raw") - python3 "$lock_script" allow-delete --branch "$branch" "${deleted_files[@]}" >/dev/null 2>&1 || true + run_guardex_cli locks allow-delete --branch "$branch" "${deleted_files[@]}" >/dev/null 2>&1 || true fi } @@ -842,7 +826,7 @@ run_finish_flow() { return 2 fi - if finish_output="$(bash "${repo_root}/scripts/agent-branch-finish.sh" "${finish_args[@]}" 2>&1)"; then + if finish_output="$(run_guardex_cli branch finish "${finish_args[@]}" 2>&1)"; then printf '%s\n' "$finish_output" return 0 fi @@ -865,7 +849,7 @@ run_finish_flow() { fi ) - if finish_output="$(bash "${repo_root}/scripts/agent-branch-finish.sh" "${finish_args[@]}" 2>&1)"; then + if finish_output="$(run_guardex_cli branch finish "${finish_args[@]}" 2>&1)"; then printf '%s\n' "$finish_output" return 0 fi @@ -954,18 +938,16 @@ if [[ "$AUTO_FINISH" -eq 1 && -n "$worktree_branch" && "$worktree_branch" != "HE fi fi -if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then - echo "[codex-agent] Session ended (exit=${codex_exit}). Running worktree cleanup..." - prune_args=() - if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then - prune_args+=(--base "$BASE_BRANCH") - fi - if [[ "$AUTO_CLEANUP" -eq 1 && "$auto_finish_completed" -eq 1 ]]; then - prune_args+=(--only-dirty-worktrees --delete-branches --delete-remote-branches) - fi - if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then - echo "[codex-agent] Warning: automatic worktree cleanup failed." >&2 - fi +echo "[codex-agent] Session ended (exit=${codex_exit}). Running worktree cleanup..." +prune_args=() +if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then + prune_args+=(--base "$BASE_BRANCH") +fi +if [[ "$AUTO_CLEANUP" -eq 1 && "$auto_finish_completed" -eq 1 ]]; then + prune_args+=(--only-dirty-worktrees --delete-branches --delete-remote-branches) +fi +if ! run_guardex_cli worktree prune "${prune_args[@]}"; then + echo "[codex-agent] Warning: automatic worktree cleanup failed." >&2 fi if [[ ! -d "$worktree_path" ]]; then @@ -978,7 +960,7 @@ else echo "[codex-agent] Branch kept intentionally. Cleanup on demand: gx cleanup --branch \"${worktree_branch}\"" else print_takeover_prompt "$worktree_path" "$worktree_branch" - echo "[codex-agent] If finished, merge with: bash scripts/agent-branch-finish.sh --branch \"${worktree_branch}\" --base dev --via-pr --wait-for-merge" + echo "[codex-agent] If finished, merge with: gx branch finish --branch \"${worktree_branch}\" --base dev --via-pr --wait-for-merge" echo "[codex-agent] Cleanup on demand: gx cleanup --branch \"${worktree_branch}\"" fi fi diff --git a/templates/scripts/review-bot-watch.sh b/templates/scripts/review-bot-watch.sh index f98d0ef..064a71a 100755 --- a/templates/scripts/review-bot-watch.sh +++ b/templates/scripts/review-bot-watch.sh @@ -9,10 +9,12 @@ BASE_BRANCH="${GUARDEX_REVIEW_BOT_BASE_BRANCH:-}" ONLY_PR="${GUARDEX_REVIEW_BOT_ONLY_PR:-}" RETRY_FAILED_RAW="${GUARDEX_REVIEW_BOT_RETRY_FAILED:-false}" INCLUDE_DRAFT_RAW="${GUARDEX_REVIEW_BOT_INCLUDE_DRAFT:-false}" +NODE_BIN="${GUARDEX_NODE_BIN:-node}" +CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}" usage() { cat <<'USAGE' -Usage: bash scripts/review-bot-watch.sh [options] +Usage: gx review [options] Continuously monitor GitHub pull requests targeting a base branch and dispatch one Codex-agent task per newly opened/updated PR. @@ -34,6 +36,23 @@ Environment overrides: USAGE } +run_guardex_cli() { + if [[ -n "$CLI_ENTRY" ]]; then + "$NODE_BIN" "$CLI_ENTRY" "$@" + return $? + fi + if command -v gx >/dev/null 2>&1; then + gx "$@" + return $? + fi + if command -v gitguardex >/dev/null 2>&1; then + gitguardex "$@" + return $? + fi + echo "[review-bot-watch] Guardex CLI entrypoint unavailable; rerun via gx." >&2 + return 127 +} + normalize_bool() { local raw="${1:-}" local fallback="${2:-0}" @@ -134,16 +153,20 @@ if ! command -v codex >/dev/null 2>&1; then exit 127 fi -if [[ ! -x "$repo_root/scripts/codex-agent.sh" ]]; then - echo "[review-bot-watch] Missing scripts/codex-agent.sh. Run: gx setup" >&2 - exit 1 -fi - if ! gh auth status >/dev/null 2>&1; then echo "[review-bot-watch] gh is not authenticated. Run: gh auth login" >&2 exit 1 fi +run_codex_agent() { + local local_script="$repo_root/scripts/codex-agent.sh" + if [[ -x "$local_script" ]]; then + bash "$local_script" "$@" + return $? + fi + run_guardex_cli internal run-shell codexAgent --target "$repo_root" "$@" +} + sanitize_slug() { local raw="$1" local fallback="$2" @@ -262,7 +285,7 @@ process_one_pr() { echo "[review-bot-watch] Dispatching Codex agent for PR #${pr} (${head_branch})" set +e - bash "$repo_root/scripts/codex-agent.sh" \ + run_codex_agent \ --task "$task_name" \ --agent "$AGENT_NAME" \ --base "$BASE_BRANCH" \ diff --git a/test/install.test.js b/test/install.test.js index 942918a..58e7ab5 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -36,6 +36,42 @@ function runNodeWithEnv(args, cwd, extraEnv) { }); } +function runBranchStart(args, cwd, extraEnv = {}) { + return runNodeWithEnv(['branch', 'start', ...args], cwd, extraEnv); +} + +function runBranchFinish(args, cwd, extraEnv = {}) { + return runNodeWithEnv(['branch', 'finish', ...args], cwd, extraEnv); +} + +function runWorktreePrune(args, cwd, extraEnv = {}) { + return runNodeWithEnv(['worktree', 'prune', ...args], cwd, extraEnv); +} + +function runLockTool(args, cwd, extraEnv = {}) { + return runNodeWithEnv(['locks', ...args], cwd, extraEnv); +} + +function runInternalShell(assetKey, args, cwd, extraEnv = {}) { + return runNodeWithEnv(['internal', 'run-shell', assetKey, ...args], cwd, extraEnv); +} + +function runCodexAgent(args, cwd, extraEnv = {}) { + return runInternalShell('codexAgent', args, cwd, extraEnv); +} + +function runReviewBot(args, cwd, extraEnv = {}) { + return runInternalShell('reviewBot', args, cwd, extraEnv); +} + +function runPlanInit(args, cwd, extraEnv = {}) { + return runInternalShell('planInit', args, cwd, extraEnv); +} + +function runChangeInit(args, cwd, extraEnv = {}) { + return runInternalShell('changeInit', args, cwd, extraEnv); +} + function runCmd(cmd, args, cwd, options = {}) { const sanitizedEnv = { ...process.env }; delete sanitizedEnv.CODEX_THREAD_ID; @@ -65,6 +101,19 @@ function runCmd(cmd, args, cwd, options = {}) { }); } +function assertZeroCopyManagedGitignore(content) { + assert.match(content, /# multiagent-safety:START/); + assert.match(content, /^scripts\/agent-session-state\.js$/m); + assert.match(content, /^scripts\/guardex-docker-loader\.sh$/m); + assert.match(content, /^scripts\/guardex-env\.sh$/m); + assert.match(content, /^scripts\/install-vscode-active-agents-extension\.js$/m); + assert.doesNotMatch(content, /^scripts\/\*$/m); + assert.doesNotMatch(content, /^scripts\/agent-branch-start\.sh$/m); + assert.doesNotMatch(content, /^scripts\/agent-file-locks\.py$/m); + assert.match(content, /^\.githooks$/m); + assert.match(content, /# multiagent-safety:END/); +} + function createFakeNpmScript(scriptBody) { const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-fake-npm-')); const fakeNpmPath = path.join(fakeBin, 'npm'); @@ -225,11 +274,7 @@ function prepareDoctorAutoFinishReadyBranch(repoDir, options = {}) { result = runCmd('git', ['push', 'origin', baseBranch], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); - result = runCmd( - 'bash', - ['scripts/agent-branch-start.sh', taskName, agentName, baseBranch], - repoDir, - ); + result = runBranchStart([taskName, agentName, baseBranch], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); const readyBranch = extractCreatedBranch(result.stdout); const readyWorktree = extractCreatedWorktree(result.stdout); @@ -275,13 +320,8 @@ function commitFile(repoDir, relativePath, contents, message) { const currentBranch = runCmd('git', ['branch', '--show-current'], repoDir); assert.equal(currentBranch.status, 0, currentBranch.stderr); const branchName = currentBranch.stdout.trim(); - const lockScriptPath = path.join(repoDir, 'scripts', 'agent-file-locks.py'); - if (branchName.startsWith('agent/') && fs.existsSync(lockScriptPath)) { - const claim = runCmd( - 'python3', - ['scripts/agent-file-locks.py', 'claim', '--branch', branchName, relativePath], - repoDir, - ); + if (branchName.startsWith('agent/')) { + const claim = runLockTool(['claim', '--branch', branchName, relativePath], repoDir); assert.equal(claim.status, 0, claim.stderr || claim.stdout); } @@ -433,16 +473,10 @@ test('setup provisions workflow files and repo config', () => { '.omc/agent-worktrees', '.omx/notepad.md', '.omx/project-memory.json', - 'scripts/agent-branch-start.sh', - 'scripts/agent-branch-finish.sh', - 'scripts/codex-agent.sh', + 'scripts/agent-session-state.js', 'scripts/guardex-docker-loader.sh', - 'scripts/review-bot-watch.sh', - 'scripts/agent-worktree-prune.sh', - 'scripts/agent-file-locks.py', 'scripts/guardex-env.sh', - 'scripts/openspec/init-plan-workspace.sh', - 'scripts/openspec/init-change-workspace.sh', + 'scripts/install-vscode-active-agents-extension.js', '.githooks/pre-commit', '.githooks/pre-push', '.githooks/post-merge', @@ -458,9 +492,20 @@ test('setup provisions workflow files and repo config', () => { assert.equal(fs.existsSync(path.join(repoDir, relativePath)), true, `${relativePath} missing`); } - const branchStartShim = fs.readFileSync(path.join(repoDir, 'scripts', 'agent-branch-start.sh'), 'utf8'); - assert.match(branchStartShim, /exec "\$node_bin" "\$GUARDEX_CLI_ENTRY" 'branch' 'start' "\$@"/); - assert.match(branchStartShim, /exec "\$cli_bin" 'branch' 'start' "\$@"/); + const removedWorkflowShims = [ + 'scripts/agent-branch-start.sh', + 'scripts/agent-branch-finish.sh', + 'scripts/agent-branch-merge.sh', + 'scripts/codex-agent.sh', + 'scripts/review-bot-watch.sh', + 'scripts/agent-worktree-prune.sh', + 'scripts/agent-file-locks.py', + 'scripts/openspec/init-plan-workspace.sh', + 'scripts/openspec/init-change-workspace.sh', + ]; + for (const relativePath of removedWorkflowShims) { + assert.equal(fs.existsSync(path.join(repoDir, relativePath)), false, `${relativePath} should not be installed`); + } const preCommitShim = fs.readFileSync(path.join(repoDir, '.githooks', 'pre-commit'), 'utf8'); assert.match(preCommitShim, /exec "\$node_bin" "\$GUARDEX_CLI_ENTRY" 'hook' 'run' 'pre-commit' "\$@"/); @@ -488,9 +533,13 @@ test('setup provisions workflow files and repo config', () => { const gitignoreContent = fs.readFileSync(path.join(repoDir, '.gitignore'), 'utf8'); assert.match(gitignoreContent, /# multiagent-safety:START/); - assert.match(gitignoreContent, /^scripts\/\*$/m); - assert.match(gitignoreContent, /^scripts\/agent-branch-start\.sh$/m); - assert.match(gitignoreContent, /^scripts\/agent-file-locks\.py$/m); + assert.match(gitignoreContent, /^scripts\/agent-session-state\.js$/m); + assert.match(gitignoreContent, /^scripts\/guardex-docker-loader\.sh$/m); + assert.match(gitignoreContent, /^scripts\/guardex-env\.sh$/m); + assert.match(gitignoreContent, /^scripts\/install-vscode-active-agents-extension\.js$/m); + assert.doesNotMatch(gitignoreContent, /^scripts\/\*$/m); + assert.doesNotMatch(gitignoreContent, /^scripts\/agent-branch-start\.sh$/m); + assert.doesNotMatch(gitignoreContent, /^scripts\/agent-file-locks\.py$/m); assert.match(gitignoreContent, /^\.githooks$/m); assert.doesNotMatch(gitignoreContent, /^\.githooks\/pre-commit$/m); assert.match(gitignoreContent, /\.omx\//); @@ -591,10 +640,7 @@ test('setup and doctor explain .githooks file conflicts and still write managed assert.match(combined, /\.githooks\/pre-commit needs it to be a directory/); let gitignoreContent = fs.readFileSync(path.join(repoDir, '.gitignore'), 'utf8'); - assert.match(gitignoreContent, /# multiagent-safety:START/); - assert.match(gitignoreContent, /scripts\/agent-branch-start\.sh/); - assert.match(gitignoreContent, /scripts\/agent-file-locks\.py/); - assert.match(gitignoreContent, /^\.githooks$/m); + assertZeroCopyManagedGitignore(gitignoreContent); result = runNode(['doctor', '--target', repoDir], repoDir); assert.notEqual(result.status, 0, 'doctor should fail when .githooks is a file'); @@ -602,7 +648,7 @@ test('setup and doctor explain .githooks file conflicts and still write managed assert.match(combined, /Path conflict: \.githooks exists as a file/); gitignoreContent = fs.readFileSync(path.join(repoDir, '.gitignore'), 'utf8'); - assert.match(gitignoreContent, /scripts\/agent-file-locks\.py/); + assertZeroCopyManagedGitignore(gitignoreContent); }); test('setup and doctor skip repo bootstrap when repo .env disables Guardex', () => { @@ -832,8 +878,7 @@ test('migrate removes legacy copied assets and installs user-level skills on req assert.equal(fs.existsSync(path.join(guardexHomeDir, '.codex', 'skills', 'gitguardex', 'SKILL.md')), true); assert.equal(fs.existsSync(path.join(guardexHomeDir, '.claude', 'commands', 'gitguardex.md')), true); - const branchStartShim = fs.readFileSync(path.join(repoDir, 'scripts', 'agent-branch-start.sh'), 'utf8'); - assert.match(branchStartShim, /exec "\$cli_bin" 'branch' 'start' "\$@"/); + assert.equal(fs.existsSync(path.join(repoDir, 'scripts', 'agent-branch-start.sh')), false); const preCommitShim = fs.readFileSync(path.join(repoDir, '.githooks', 'pre-commit'), 'utf8'); assert.match(preCommitShim, /exec "\$cli_bin" 'hook' 'run' 'pre-commit' "\$@"/); }); @@ -940,8 +985,8 @@ test('init aliases setup and provisions workflow files', () => { const result = runNode(['init', '--target', repoDir, '--no-global-install'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); - assert.equal(fs.existsSync(path.join(repoDir, 'scripts', 'agent-branch-start.sh')), true); - assert.equal(fs.existsSync(path.join(repoDir, 'scripts', 'agent-branch-finish.sh')), true); + assert.equal(fs.existsSync(path.join(repoDir, 'scripts', 'guardex-env.sh')), true); + assert.equal(fs.existsSync(path.join(repoDir, '.githooks', 'pre-commit')), true); assert.equal(fs.existsSync(path.join(repoDir, 'AGENTS.md')), true); }); @@ -973,9 +1018,9 @@ test('setup recursively installs into nested git repos, skipping node_modules/wo for (const repo of [topDir, nestedA, nestedB]) { assert.equal(fs.existsSync(path.join(repo, 'AGENTS.md')), true, `AGENTS.md missing in ${repo}`); assert.equal( - fs.existsSync(path.join(repo, 'scripts', 'agent-branch-start.sh')), + fs.existsSync(path.join(repo, 'scripts', 'guardex-env.sh')), true, - `agent-branch-start.sh missing in ${repo}`, + `guardex-env.sh missing in ${repo}`, ); assert.equal( fs.existsSync(path.join(repo, '.githooks', 'pre-commit')), @@ -1025,13 +1070,13 @@ test('setup --no-recursive limits install to the top-level repo', () => { ); }); -test('review-bot-watch script prints help after setup', () => { +test('review bot helper prints help after setup', () => { const repoDir = initRepo(); const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); - const helpResult = runCmd('bash', ['scripts/review-bot-watch.sh', '--help'], repoDir); + const helpResult = runReviewBot(['--help'], repoDir); assert.equal(helpResult.status, 0, helpResult.stderr || helpResult.stdout); assert.match(helpResult.stdout, /Continuously monitor GitHub pull requests targeting a base branch/); }); @@ -1052,7 +1097,11 @@ test('setup refreshes initialized protected main through a sandbox and prunes it assert.equal(result.status, 0, result.stderr || result.stdout); const initialGitignore = fs.readFileSync(gitignorePath, 'utf8'); - fs.writeFileSync(gitignorePath, initialGitignore.replace(/^scripts\/\*\n/m, ''), 'utf8'); + fs.writeFileSync( + gitignorePath, + initialGitignore.replace(/^scripts\/agent-session-state\.js\n/m, ''), + 'utf8', + ); result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); @@ -1072,7 +1121,7 @@ test('setup refreshes initialized protected main through a sandbox and prunes it assert.equal(sandboxBranchCheck.stdout.trim(), '', 'setup sandbox branch should be pruned'); const refreshedGitignore = fs.readFileSync(gitignorePath, 'utf8'); - assert.match(refreshedGitignore, /^scripts\/\*$/m); + assert.match(refreshedGitignore, /^scripts\/agent-session-state\.js$/m); }); test('setup allows explicit protected-main override for in-place maintenance', () => { @@ -1132,19 +1181,14 @@ test('doctor on protected main auto-runs in a sandbox branch/worktree', () => { result = runCmd('git', ['push', 'origin', 'main'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); - fs.rmSync(path.join(repoDir, 'scripts', 'agent-branch-finish.sh')); + assert.equal(fs.existsSync(path.join(repoDir, 'scripts', 'agent-branch-finish.sh')), false); result = runNode(['doctor', '--target', repoDir], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); assert.match(result.stdout, /doctor detected protected branch 'main'/); const createdBranch = extractCreatedBranch(result.stdout); - const createdWorktree = extractCreatedWorktree(result.stdout); assert.match(createdBranch, /^agent\/gx\/.+-gx-doctor$/); - assert.equal( - fs.existsSync(path.join(repoDir, 'scripts', 'agent-branch-finish.sh')), - true, - 'protected main checkout should regain finish script', - ); + assert.equal(fs.existsSync(path.join(repoDir, 'scripts', 'agent-branch-finish.sh')), false); const rootStatus = runCmd('git', ['status', '--short', '--untracked-files=no'], repoDir); assert.equal(rootStatus.status, 0, rootStatus.stderr || rootStatus.stdout); @@ -1269,9 +1313,9 @@ test('doctor on protected main bootstraps sandbox branch even before setup exist const createdWorktree = extractCreatedWorktree(result.stdout); assert.match(createdBranch, /^agent\/gx\/.+-gx-doctor$/); assert.equal( - fs.existsSync(path.join(repoDir, 'scripts', 'agent-branch-start.sh')), + fs.existsSync(path.join(repoDir, 'scripts', 'guardex-env.sh')), true, - 'protected main checkout should regain agent-branch-start.sh', + 'protected main checkout should regain zero-copy managed scripts', ); assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'state')), true); assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'logs')), true); @@ -1352,8 +1396,7 @@ exit 1 'protected main checkout should stay untouched while sandbox finish flow delivers the repair', ); const repairedRootGitignore = fs.readFileSync(path.join(repoDir, '.gitignore'), 'utf8'); - assert.match(repairedRootGitignore, /^scripts\/\*$/m); - assert.match(repairedRootGitignore, /^\.githooks$/m); + assertZeroCopyManagedGitignore(repairedRootGitignore); const createdBranch = extractCreatedBranch(result.stdout); result = runCmd('git', ['show-ref', '--verify', '--quiet', `refs/heads/${createdBranch}`], repoDir); @@ -1745,12 +1788,12 @@ test('setup agent-branch-start rejects in-place flags to keep local checkout unc seedCommit(repoDir); - result = runCmd('bash', ['scripts/agent-branch-start.sh', 'demo', 'bot', 'dev', '--in-place'], repoDir); + result = runBranchStart(['demo', 'bot', 'dev', '--in-place'], repoDir); assert.notEqual(result.status, 0, result.stdout); assert.match(result.stderr, /In-place branch mode is disabled/); assert.match(result.stderr, /always creates an isolated worktree/); - result = runCmd('bash', ['scripts/agent-branch-start.sh', 'demo', 'bot', 'dev', '--allow-in-place'], repoDir); + result = runBranchStart(['demo', 'bot', 'dev', '--allow-in-place'], repoDir); assert.notEqual(result.status, 0, result.stdout); assert.match(result.stderr, /In-place branch mode is disabled/); }); @@ -1774,17 +1817,10 @@ cat <<'OUT' OUT `); - result = runCmd( - 'bash', - ['scripts/agent-branch-start.sh', 'restore-snapshot', 'planner', 'dev'], - repoDir, - { - env: { - PATH: `${fakeBin}:${process.env.PATH || ''}`, - GUARDEX_AGENT_TYPE: 'planner', - }, - }, - ); + result = runBranchStart(['restore-snapshot', 'planner', 'dev'], repoDir, { + PATH: `${fakeBin}:${process.env.PATH || ''}`, + GUARDEX_AGENT_TYPE: 'planner', + }); assert.equal(result.status, 0, result.stderr || result.stdout); assert.match( result.stdout, @@ -1801,12 +1837,10 @@ test('setup agent-branch-start ignores GUARDEX_CODEX_AUTH_SNAPSHOT for branch na assert.equal(result.status, 0, result.stderr || result.stdout); seedCommit(repoDir); - result = runCmd( - 'bash', - ['scripts/agent-branch-start.sh', 'ship-fix', 'bot', 'dev'], - repoDir, - { env: { GUARDEX_CODEX_AUTH_SNAPSHOT: 'Prod Snapshot One', CLAUDECODE: '0' } }, - ); + result = runBranchStart(['ship-fix', 'bot', 'dev'], repoDir, { + GUARDEX_CODEX_AUTH_SNAPSHOT: 'Prod Snapshot One', + CLAUDECODE: '0', + }); assert.equal(result.status, 0, result.stderr || result.stdout); // 'bot' has no claude/codex substring and no CLAUDECODE sentinel → role falls back to 'codex'. assert.match( @@ -1824,16 +1858,14 @@ test('setup agent-branch-start keeps role-datetime branch labels compact (v7.0.3 assert.equal(result.status, 0, result.stderr || result.stdout); seedCommit(repoDir); - result = runCmd( - 'bash', + result = runBranchStart( [ - 'scripts/agent-branch-start.sh', 'rust-layer-phase7-dashboard-read-name-columns-and-badges', 'codex-admin-recodee-com', 'dev', ], repoDir, - { env: { GUARDEX_CODEX_AUTH_SNAPSHOT: 'Zeus Portasmosonmagyarovar Hu Snapshot' } }, + { GUARDEX_CODEX_AUTH_SNAPSHOT: 'Zeus Portasmosonmagyarovar Hu Snapshot' }, ); assert.equal(result.status, 0, result.stderr || result.stdout); const createdBranch = extractCreatedBranch(result.stdout); @@ -1853,17 +1885,10 @@ test('setup agent-branch-start routes Claude sessions into .omc worktrees and st assert.equal(result.status, 0, result.stderr || result.stdout); seedCommit(repoDir); - result = runCmd( - 'bash', - ['scripts/agent-branch-start.sh', 'claude-session-task', 'bot', 'dev'], - repoDir, - { - env: { - CLAUDECODE: '1', - GUARDEX_AGENT_TYPE: 'planner', - }, - }, - ); + result = runBranchStart(['claude-session-task', 'bot', 'dev'], repoDir, { + CLAUDECODE: '1', + GUARDEX_AGENT_TYPE: 'planner', + }); assert.equal(result.status, 0, result.stderr || result.stdout); const createdBranch = extractCreatedBranch(result.stdout); @@ -1896,12 +1921,9 @@ test('setup agent-branch-start supports optional OpenSpec auto-bootstrap toggles assert.equal(result.status, 0, result.stderr || result.stdout); seedCommit(repoDir); - result = runCmd( - 'bash', - ['scripts/agent-branch-start.sh', 'openspec-default', 'bot', 'dev'], - repoDir, - { env: { GUARDEX_OPENSPEC_AUTO_INIT: 'true' } }, - ); + result = runBranchStart(['openspec-default', 'bot', 'dev'], repoDir, { + GUARDEX_OPENSPEC_AUTO_INIT: 'true', + }); assert.equal(result.status, 0, result.stderr || result.stdout); const defaultBranch = extractCreatedBranch(result.stdout); const defaultWorktree = extractCreatedWorktree(result.stdout); @@ -1940,12 +1962,9 @@ test('setup agent-branch-start supports optional OpenSpec auto-bootstrap toggles 'default branch start should scaffold OpenSpec change spec', ); - result = runCmd( - 'bash', - ['scripts/agent-branch-start.sh', 'openspec-disabled', 'bot', 'dev'], - repoDir, - { env: { GUARDEX_OPENSPEC_AUTO_INIT: 'false' } }, - ); + result = runBranchStart(['openspec-disabled', 'bot', 'dev'], repoDir, { + GUARDEX_OPENSPEC_AUTO_INIT: 'false', + }); assert.equal(result.status, 0, result.stderr || result.stdout); const disabledWorktree = extractCreatedWorktree(result.stdout); const disabledPlanSlug = extractOpenSpecPlanSlug(result.stdout); @@ -1979,7 +1998,7 @@ test('setup agent-branch-start defaults base to current branch, stores base meta result = runCmd('git', ['push', 'origin', 'main'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); - result = runCmd('bash', ['scripts/agent-branch-start.sh', 'auto-base', 'bot'], repoDir); + result = runBranchStart(['auto-base', 'bot'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /set up to track/i); const agentBranch = extractCreatedBranch(result.stdout); @@ -2027,7 +2046,7 @@ test('agent-branch-start prefers current protected branch over stale configured fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8'); fs.writeFileSync(path.join(repoDir, 'dev-untracked.txt'), 'dev untracked change\n', 'utf8'); - result = runCmd('bash', ['scripts/agent-branch-start.sh', 'prefer-dev', 'bot'], repoDir); + result = runBranchStart(['prefer-dev', 'bot'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); assert.match(result.stdout, /Moved local changes from 'dev' into 'agent\/codex\//); @@ -2075,7 +2094,7 @@ test('agent-branch-start moves protected-branch local changes into the new agent fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8'); fs.writeFileSync(path.join(repoDir, 'scratch-note.txt'), 'untracked change\n', 'utf8'); - result = runCmd('bash', ['scripts/agent-branch-start.sh', 'move-readme', 'bot'], repoDir); + result = runBranchStart(['move-readme', 'bot'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); const agentWorktree = extractCreatedWorktree(result.stdout); assert.match(result.stdout, /Moved local changes from 'main' into 'agent\/codex\//); @@ -2092,7 +2111,7 @@ test('agent-branch-start moves protected-branch local changes into the new agent assert.doesNotMatch(stashList.stdout, /guardex-auto-transfer-/); }); -test('agent-branch-start hydrates codex-agent helper into new worktrees when missing locally', () => { +test('agent-branch-start leaves removed workflow helpers out of new worktrees', () => { const repoDir = initRepo(); seedCommit(repoDir); @@ -2107,17 +2126,15 @@ test('agent-branch-start hydrates codex-agent helper into new worktrees when mis assert.equal(result.status, 0, result.stderr || result.stdout); const localCodexAgent = path.join(repoDir, 'scripts', 'codex-agent.sh'); - assert.equal(fs.existsSync(localCodexAgent), true, 'setup should provision local codex-agent helper'); + assert.equal(fs.existsSync(localCodexAgent), false, 'zero-copy setup should not provision local codex-agent helper'); - result = runCmd('bash', ['scripts/agent-branch-start.sh', 'hydrate-codex', 'bot'], repoDir); + result = runBranchStart(['hydrate-codex', 'bot'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); - assert.match(result.stdout, /Hydrated local helper in worktree: scripts\/codex-agent\.sh/); + assert.doesNotMatch(result.stdout, /Hydrated local helper in worktree: scripts\/codex-agent\.sh/); const createdWorktree = extractCreatedWorktree(result.stdout); const worktreeCodexAgent = path.join(createdWorktree, 'scripts', 'codex-agent.sh'); - assert.equal(fs.existsSync(worktreeCodexAgent), true, 'worktree should receive codex-agent helper'); - const mode = fs.statSync(worktreeCodexAgent).mode; - assert.equal((mode & 0o111) !== 0, true, 'hydrated codex-agent helper should be executable'); + assert.equal(fs.existsSync(worktreeCodexAgent), false, 'worktree should stay zero-copy for codex-agent helper'); }); test('agent-branch-start links dependency node_modules directories into new worktrees when present', () => { @@ -2144,7 +2161,7 @@ test('agent-branch-start links dependency node_modules directories into new work fs.writeFileSync(path.join(sourceDir, '.guardex-link-marker'), 'present\n', 'utf8'); } - result = runCmd('bash', ['scripts/agent-branch-start.sh', 'hydrate-deps', 'bot'], repoDir, { + result = runBranchStart(['hydrate-deps', 'bot'], repoDir, { GUARDEX_PROTECTED_BRANCHES: 'main', }); assert.equal(result.status, 0, result.stderr || result.stdout); @@ -2183,9 +2200,7 @@ test('agent-branch-finish handles Claude-root worktrees when inferring base from result = runCmd('git', ['push', 'origin', 'main'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); - result = runCmd('bash', ['scripts/agent-branch-start.sh', 'finish-from-dev', 'bot'], repoDir, { - env: { CLAUDECODE: '1' }, - }); + result = runBranchStart(['finish-from-dev', 'bot'], repoDir, { CLAUDECODE: '1' }); assert.equal(result.status, 0, result.stderr || result.stdout); const agentBranch = extractCreatedBranch(result.stdout); const agentWorktree = extractCreatedWorktree(result.stdout); @@ -2199,7 +2214,7 @@ test('agent-branch-finish handles Claude-root worktrees when inferring base from result = runCmd('git', ['worktree', 'add', auxWorktree, 'main'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); - const finish = runCmd('bash', ['scripts/agent-branch-finish.sh', '--branch', agentBranch], repoDir); + const finish = runBranchFinish(['--branch', agentBranch], repoDir); assert.equal(finish.status, 0, finish.stderr || finish.stdout); assert.match(finish.stdout, new RegExp(`Merged '${escapeRegexLiteral(agentBranch)}' into 'main'`)); @@ -2267,15 +2282,32 @@ test('review command launches local review-bot script and accepts legacy start t assert.equal(fs.readFileSync(markerArgs, 'utf8').trim(), '--interval 45 --once'); }); -test('review command explains setup + doctor steps when script is missing in target repo', () => { +test('review command falls back to the package review bot when the repo has no local helper', () => { const repoDir = initRepo(); - - const result = runNode(['review', '--target', repoDir], repoDir); - assert.equal(result.status, 1, result.stderr || result.stdout); - assert.match( - result.stderr, - new RegExp(`Run 'gx setup --target ${escapeRegexLiteral(repoDir)}' then 'gx doctor --target ${escapeRegexLiteral(repoDir)}'`), + seedCommit(repoDir); + const { fakeBin: fakeGhBin } = createFakeGhScript( + 'if [[ "$1" == "auth" && "$2" == "status" ]]; then\n' + + ' exit 0\n' + + 'fi\n' + + 'if [[ "$1" == "pr" && "$2" == "list" ]]; then\n' + + ' exit 0\n' + + 'fi\n' + + 'echo "unexpected gh args: $*" >&2\n' + + 'exit 1\n', ); + const fakeCodexBin = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-fake-codex-review-')); + const fakeCodexPath = path.join(fakeCodexBin, 'codex'); + fs.writeFileSync(fakeCodexPath, '#!/usr/bin/env bash\nset -e\nexit 0\n', 'utf8'); + fs.chmodSync(fakeCodexPath, 0o755); + + const result = runNodeWithEnv(['review', '--target', repoDir, '--once'], repoDir, { + PATH: `${fakeGhBin}:${fakeCodexBin}:${process.env.PATH}`, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(path.join(repoDir, 'scripts', 'review-bot-watch.sh')), false); + assert.equal(fs.existsSync(path.join(repoDir, 'scripts', 'codex-agent.sh')), false); + assert.match(result.stdout, /\[review-bot-watch\] Starting monitor/); + assert.match(result.stdout, /\[review-bot-watch\] No open PRs for base 'dev'\./); }); test('agents command starts review+cleanup bots for the target repo and stops them', () => { @@ -2442,7 +2474,7 @@ test('finish command auto-commits dirty agent worktree and runs PR finish flow f result = runCmd('git', ['push', 'origin', 'main'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); - result = runCmd('bash', ['scripts/agent-branch-start.sh', 'finish-all', 'bot'], repoDir); + result = runBranchStart(['finish-all', 'bot'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); const agentBranch = extractCreatedBranch(result.stdout); const agentWorktree = extractCreatedWorktree(result.stdout); @@ -2809,10 +2841,7 @@ test('setup appends managed gitignore block without clobbering existing entries' const first = fs.readFileSync(path.join(repoDir, '.gitignore'), 'utf8'); assert.match(first, /node_modules\//); - assert.match(first, /# multiagent-safety:START/); - assert.match(first, /^scripts\/\*$/m); - assert.match(first, /^\.githooks$/m); - assert.match(first, /# multiagent-safety:END/); + assertZeroCopyManagedGitignore(first); result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); @@ -3133,7 +3162,7 @@ test('repo .env GUARDEX_ON=false disables bootstrap scripts and git hook enforce fs.writeFileSync(path.join(repoDir, '.env'), 'GUARDEX_ON=false\n', 'utf8'); - result = runCmd('bash', ['scripts/agent-branch-start.sh', 'disabled-toggle', 'bot', 'dev'], repoDir); + result = runBranchStart(['disabled-toggle', 'bot', 'dev'], repoDir); assert.notEqual(result.status, 0, result.stderr || result.stdout); assert.match(result.stderr, /Guardex is disabled for this repo/); @@ -3244,16 +3273,11 @@ test('codex-agent launches codex inside a fresh sandbox worktree and keeps branc const cwdMarker = path.join(repoDir, '.codex-agent-cwd'); const argsMarker = path.join(repoDir, '.codex-agent-args'); - const launch = runCmd( - 'bash', - ['scripts/codex-agent.sh', 'launch-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], - repoDir, - { - PATH: `${fakeBin}:${process.env.PATH}`, - GUARDEX_TEST_CODEX_CWD: cwdMarker, - GUARDEX_TEST_CODEX_ARGS: argsMarker, - }, - ); + const launch = runCodexAgent(['launch-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], repoDir, { + PATH: `${fakeBin}:${process.env.PATH}`, + GUARDEX_TEST_CODEX_CWD: cwdMarker, + GUARDEX_TEST_CODEX_ARGS: argsMarker, + }); assert.equal(launch.status, 0, launch.stderr || launch.stdout); assert.match(launch.stdout, /\[codex-agent\] Launching codex in sandbox:/); assert.match(launch.stdout, /\[codex-agent\] Session ended \(exit=0\)\. Running worktree cleanup\.\.\./); @@ -3300,7 +3324,7 @@ test('codex-agent launches codex inside a fresh sandbox worktree and keeps branc ); }); -test('codex-agent restores local branch and falls back to safe worktree start when starter script switches in-place', () => { +test('codex-agent ignores stale repo-local starter shims and keeps the visible checkout stable', () => { const repoDir = initRepo(); seedCommit(repoDir); attachOriginRemote(repoDir); @@ -3338,21 +3362,16 @@ test('codex-agent restores local branch and falls back to safe worktree start wh const cwdMarker = path.join(repoDir, '.codex-agent-cwd-fallback'); const argsMarker = path.join(repoDir, '.codex-agent-args-fallback'); - const launch = runCmd( - 'bash', - ['scripts/codex-agent.sh', 'fallback-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], - repoDir, - { - PATH: `${fakeBin}:${process.env.PATH}`, - GUARDEX_TEST_CODEX_CWD: cwdMarker, - GUARDEX_TEST_CODEX_ARGS: argsMarker, - }, - ); + const launch = runCodexAgent(['fallback-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], repoDir, { + PATH: `${fakeBin}:${process.env.PATH}`, + GUARDEX_TEST_CODEX_CWD: cwdMarker, + GUARDEX_TEST_CODEX_ARGS: argsMarker, + }); assert.equal(launch.status, 0, launch.stderr || launch.stdout); const combinedOutput = `${launch.stdout}\n${launch.stderr}`; - assert.match(combinedOutput, /Unsafe starter output/); assert.match(combinedOutput, /\[agent-branch-start\] Created branch: agent\/planner\//); assert.match(combinedOutput, /\[codex-agent\] Auto-finish skipped.*no mergeable remote context/); + assert.doesNotMatch(combinedOutput, /Unsafe starter output/); const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim(); assert.match( @@ -3414,18 +3433,8 @@ test('codex-agent supports --codex-bin override before positional arguments', () const cwdMarker = path.join(repoDir, '.codex-agent-cwd-override'); const argsMarker = path.join(repoDir, '.codex-agent-args-override'); - const launch = runCmd( - 'bash', - [ - 'scripts/codex-agent.sh', - '--codex-bin', - fakeCodexPath, - 'launch-task', - 'planner', - 'dev', - '--model', - 'gpt-5.4-mini', - ], + const launch = runCodexAgent( + ['--codex-bin', fakeCodexPath, 'launch-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], repoDir, { GUARDEX_TEST_CODEX_CWD: cwdMarker, @@ -3467,16 +3476,11 @@ test('codex-agent keeps dirty sandbox worktrees after session exit', () => { const cwdMarker = path.join(repoDir, '.codex-agent-cwd-dirty'); const argsMarker = path.join(repoDir, '.codex-agent-args-dirty'); - const launch = runCmd( - 'bash', - ['scripts/codex-agent.sh', 'dirty-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], - repoDir, - { - PATH: `${fakeBin}:${process.env.PATH}`, - GUARDEX_TEST_CODEX_CWD: cwdMarker, - GUARDEX_TEST_CODEX_ARGS: argsMarker, - }, - ); + const launch = runCodexAgent(['dirty-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], repoDir, { + PATH: `${fakeBin}:${process.env.PATH}`, + GUARDEX_TEST_CODEX_CWD: cwdMarker, + GUARDEX_TEST_CODEX_ARGS: argsMarker, + }); assert.equal(launch.status, 0, launch.stderr || launch.stdout); assert.match(launch.stdout, /\[agent-worktree-prune\] Summary: .*removed_worktrees=0/); assert.match(launch.stdout, /\[codex-agent\] Sandbox worktree kept:/); @@ -3547,20 +3551,15 @@ exit 1 const cwdMarker = path.join(repoDir, '.codex-agent-cwd-autofinish'); const argsMarker = path.join(repoDir, '.codex-agent-args-autofinish'); - const launch = runCmd( - 'bash', - ['scripts/codex-agent.sh', 'autofinish-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], - repoDir, - { - PATH: `${fakeCodexBin}:${process.env.PATH}`, - GUARDEX_TEST_CODEX_CWD: cwdMarker, - GUARDEX_TEST_CODEX_ARGS: argsMarker, - GUARDEX_TEST_GH_MERGE_STATE: ghMergeState, - GUARDEX_GH_BIN: fakeGhPath, - GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS: '60', - GUARDEX_FINISH_WAIT_POLL_SECONDS: '0', - }, - ); + const launch = runCodexAgent(['autofinish-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], repoDir, { + PATH: `${fakeCodexBin}:${process.env.PATH}`, + GUARDEX_TEST_CODEX_CWD: cwdMarker, + GUARDEX_TEST_CODEX_ARGS: argsMarker, + GUARDEX_TEST_GH_MERGE_STATE: ghMergeState, + GUARDEX_GH_BIN: fakeGhPath, + GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS: '60', + GUARDEX_FINISH_WAIT_POLL_SECONDS: '0', + }); assert.equal(launch.status, 0, launch.stderr || launch.stdout); const combinedOutput = `${launch.stdout}\n${launch.stderr}`; assert.match(combinedOutput, /\[codex-agent\] Auto-finish enabled: commit -> push\/PR -> wait for merge -> cleanup\./); @@ -3605,15 +3604,10 @@ test('codex-agent prints a takeover prompt when the sandbox is kept after an inc fs.chmodSync(fakeCodexPath, 0o755); const cwdMarker = path.join(repoDir, '.codex-agent-cwd-takeover'); - const launch = runCmd( - 'bash', - ['scripts/codex-agent.sh', 'usage-limit-task', 'planner', 'dev'], - repoDir, - { - PATH: `${fakeCodexBin}:${process.env.PATH}`, - GUARDEX_TEST_CODEX_CWD: cwdMarker, - }, - ); + const launch = runCodexAgent(['usage-limit-task', 'planner', 'dev'], repoDir, { + PATH: `${fakeCodexBin}:${process.env.PATH}`, + GUARDEX_TEST_CODEX_CWD: cwdMarker, + }); assert.equal(launch.status, 42, launch.stderr || launch.stdout); const combinedOutput = `${launch.stdout}\n${launch.stderr}`; @@ -3632,7 +3626,7 @@ test('codex-agent prints a takeover prompt when the sandbox is kept after an inc ); assert.match( combinedOutput, - new RegExp(`agent-branch-finish\\.sh --branch "${escapeRegexLiteral(launchedBranch)}" --base dev --via-pr --wait-for-merge --cleanup`), + new RegExp(`gx branch finish --branch "${escapeRegexLiteral(launchedBranch)}" --base dev --via-pr --wait-for-merge --cleanup`), ); }); @@ -3706,21 +3700,16 @@ exit 1 const cwdMarker = path.join(repoDir, '.codex-agent-cwd-autocommit-retry'); const argsMarker = path.join(repoDir, '.codex-agent-args-autocommit-retry'); const originAdvanceClone = path.join(repoDir, '.origin-advance-clone'); - const launch = runCmd( - 'bash', - ['scripts/codex-agent.sh', 'autocommit-retry-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], - repoDir, - { - PATH: `${fakeCodexBin}:${process.env.PATH}`, - GUARDEX_TEST_CODEX_CWD: cwdMarker, - GUARDEX_TEST_CODEX_ARGS: argsMarker, - GUARDEX_TEST_ORIGIN_PATH: originPath, - GUARDEX_TEST_ORIGIN_ADVANCE_CLONE: originAdvanceClone, - GUARDEX_GH_BIN: fakeGhPath, - GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS: '60', - GUARDEX_FINISH_WAIT_POLL_SECONDS: '0', - }, - ); + const launch = runCodexAgent(['autocommit-retry-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], repoDir, { + PATH: `${fakeCodexBin}:${process.env.PATH}`, + GUARDEX_TEST_CODEX_CWD: cwdMarker, + GUARDEX_TEST_CODEX_ARGS: argsMarker, + GUARDEX_TEST_ORIGIN_PATH: originPath, + GUARDEX_TEST_ORIGIN_ADVANCE_CLONE: originAdvanceClone, + GUARDEX_GH_BIN: fakeGhPath, + GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS: '60', + GUARDEX_FINISH_WAIT_POLL_SECONDS: '0', + }); assert.equal(launch.status, 0, launch.stderr || launch.stdout); const combinedOutput = `${launch.stdout}\n${launch.stderr}`; assert.match(combinedOutput, /\[codex-agent\] Auto-committed sandbox changes on 'agent\/planner\/autocommit-retry-task-/); @@ -3770,18 +3759,13 @@ echo "unexpected gh args: $*" >&2 exit 1 `); - const launch = runCmd( - 'bash', - ['scripts/codex-agent.sh', 'hook-fail-task', 'planner', 'dev'], - repoDir, - { - PATH: `${fakeCodexBin}:${process.env.PATH}`, - GUARDEX_CODEX_WAIT_FOR_MERGE: 'false', - GUARDEX_GH_BIN: fakeGhPath, - GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS: '30', - GUARDEX_FINISH_WAIT_POLL_SECONDS: '0', - }, - ); + const launch = runCodexAgent(['hook-fail-task', 'planner', 'dev'], repoDir, { + PATH: `${fakeCodexBin}:${process.env.PATH}`, + GUARDEX_CODEX_WAIT_FOR_MERGE: 'false', + GUARDEX_GH_BIN: fakeGhPath, + GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS: '30', + GUARDEX_FINISH_WAIT_POLL_SECONDS: '0', + }); assert.notEqual(launch.status, 0, launch.stderr || launch.stdout); assert.match(launch.stderr, /Auto-commit failed in sandbox/); assert.match(launch.stderr, /forced pre-commit failure for test/); @@ -3866,11 +3850,7 @@ test('pre-commit sync gate blocks agent commits when branch is too far behind ba assert.equal(result.status, 0, result.stderr); fs.writeFileSync(path.join(repoDir, 'agent-blocked.txt'), 'blocked\n'); - result = runCmd( - 'python3', - ['scripts/agent-file-locks.py', 'claim', '--branch', 'agent/test-behind-gate', 'agent-blocked.txt'], - repoDir, - ); + result = runLockTool(['claim', '--branch', 'agent/test-behind-gate', 'agent-blocked.txt'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); result = runCmd('git', ['add', 'agent-blocked.txt'], repoDir); assert.equal(result.status, 0, result.stderr); @@ -3914,11 +3894,7 @@ test('pre-commit sync gate honors maxBehindCommits threshold', () => { assert.equal(result.status, 0, result.stderr); fs.writeFileSync(path.join(repoDir, 'agent-allowed.txt'), 'allowed\n'); - result = runCmd( - 'python3', - ['scripts/agent-file-locks.py', 'claim', '--branch', 'agent/test-behind-threshold', 'agent-allowed.txt'], - repoDir, - ); + result = runLockTool(['claim', '--branch', 'agent/test-behind-threshold', 'agent-allowed.txt'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); result = runCmd('git', ['add', 'agent-allowed.txt'], repoDir); assert.equal(result.status, 0, result.stderr); @@ -3956,11 +3932,7 @@ test('agent-branch-finish auto-syncs source branch when behind origin/dev', () = result = runCmd('git', ['checkout', 'agent/test-finish-sync-guard'], repoDir); assert.equal(result.status, 0, result.stderr); - const finish = runCmd( - 'bash', - ['scripts/agent-branch-finish.sh', '--branch', 'agent/test-finish-sync-guard'], - repoDir, - ); + const finish = runBranchFinish(['--branch', 'agent/test-finish-sync-guard'], repoDir); assert.equal(finish.status, 0, finish.stderr || finish.stdout); assert.match(finish.stderr, /agent-sync-guard/); assert.match(finish.stderr, /Auto-syncing 'agent\/test-finish-sync-guard' onto origin\/dev before finish/); @@ -4015,9 +3987,8 @@ echo "unexpected gh args: $*" >&2 exit 1 `); - const finish = runCmd( - 'bash', - ['scripts/agent-branch-finish.sh', '--branch', 'agent/test-pr-delete-error', '--mode', 'pr', '--cleanup'], + const finish = runBranchFinish( + ['--branch', 'agent/test-pr-delete-error', '--mode', 'pr', '--cleanup'], repoDir, { GUARDEX_GH_BIN: fakeGhPath }, ); @@ -4093,18 +4064,8 @@ echo "unexpected gh args: $*" >&2 exit 1 `); - const finish = runCmd( - 'bash', - [ - path.join(repoDir, 'scripts', 'agent-branch-finish.sh'), - '--branch', - 'agent/test-active-worktree-cleanup', - '--base', - 'dev', - '--mode', - 'pr', - '--cleanup', - ], + const finish = runBranchFinish( + ['--branch', 'agent/test-active-worktree-cleanup', '--base', 'dev', '--mode', 'pr', '--cleanup'], agentWorktreePath, { GUARDEX_GH_BIN: fakeGhPath }, ); @@ -4187,10 +4148,8 @@ echo "unexpected gh args: $*" >&2 exit 1 `); - const finish = runCmd( - 'bash', + const finish = runBranchFinish( [ - 'scripts/agent-branch-finish.sh', '--branch', 'agent/test-pr-wait-merge', '--mode', @@ -4228,11 +4187,7 @@ test('OpenSpec plan workspace scaffold creates expected role/task structure', () assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); const planSlug = 'plan-workspace-smoke'; - const scaffold = runCmd( - 'bash', - ['scripts/openspec/init-plan-workspace.sh', planSlug], - repoDir, - ); + const scaffold = runPlanInit([planSlug], repoDir); assert.equal(scaffold.status, 0, scaffold.stderr || scaffold.stdout); const planDir = path.join(repoDir, 'openspec', 'plan', planSlug); @@ -4298,11 +4253,7 @@ test('OpenSpec change workspace scaffold creates proposal/tasks/spec defaults', const changeSlug = 'change-workspace-smoke'; const capabilitySlug = 'runtime-migration'; - const scaffold = runCmd( - 'bash', - ['scripts/openspec/init-change-workspace.sh', changeSlug, capabilitySlug], - repoDir, - ); + const scaffold = runChangeInit([changeSlug, capabilitySlug], repoDir); assert.equal(scaffold.status, 0, scaffold.stderr || scaffold.stdout); const changeDir = path.join(repoDir, 'openspec', 'changes', changeSlug); @@ -4334,12 +4285,9 @@ test('OpenSpec change workspace scaffold supports minimal T1 notes mode', () => const changeSlug = 'change-workspace-minimal'; const capabilitySlug = 'runtime-migration'; const agentBranch = 'agent/codex/minimal-change'; - const scaffold = runCmd( - 'bash', - ['scripts/openspec/init-change-workspace.sh', changeSlug, capabilitySlug, agentBranch], - repoDir, - { GUARDEX_OPENSPEC_MINIMAL: '1' }, - ); + const scaffold = runChangeInit([changeSlug, capabilitySlug, agentBranch], repoDir, { + GUARDEX_OPENSPEC_MINIMAL: '1', + }); assert.equal(scaffold.status, 0, scaffold.stderr || scaffold.stdout); const changeDir = path.join(repoDir, 'openspec', 'changes', changeSlug); @@ -4374,37 +4322,21 @@ test('validate blocks unapproved deletions until allow-delete is set', () => { result = runCmd('git', ['commit', '-m', 'seed'], repoDir); assert.equal(result.status, 0, result.stderr); - result = runCmd( - 'python3', - ['scripts/agent-file-locks.py', 'claim', '--branch', 'agent/test', 'src/logic.txt'], - repoDir, - ); + result = runLockTool(['claim', '--branch', 'agent/test', 'src/logic.txt'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); fs.unlinkSync(featureFile); result = runCmd('git', ['add', '-A'], repoDir); assert.equal(result.status, 0, result.stderr); - result = runCmd( - 'python3', - ['scripts/agent-file-locks.py', 'validate', '--branch', 'agent/test', '--staged'], - repoDir, - ); + result = runLockTool(['validate', '--branch', 'agent/test', '--staged'], repoDir); assert.equal(result.status, 1, 'deletion should be blocked without allow-delete'); assert.match(result.stderr, /Delete not approved/); - result = runCmd( - 'python3', - ['scripts/agent-file-locks.py', 'allow-delete', '--branch', 'agent/test', 'src/logic.txt'], - repoDir, - ); + result = runLockTool(['allow-delete', '--branch', 'agent/test', 'src/logic.txt'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); - result = runCmd( - 'python3', - ['scripts/agent-file-locks.py', 'validate', '--branch', 'agent/test', '--staged'], - repoDir, - ); + result = runLockTool(['validate', '--branch', 'agent/test', '--staged'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); }); @@ -4415,7 +4347,7 @@ test('fix repairs stale lock issues so scan becomes clean', () => { assert.equal(result.status, 0, result.stderr || result.stdout); // Simulate broken state - fs.rmSync(path.join(repoDir, 'scripts', 'agent-branch-start.sh')); + fs.rmSync(path.join(repoDir, 'scripts', 'guardex-env.sh')); result = runCmd('git', ['config', 'core.hooksPath', '.git/hooks'], repoDir); assert.equal(result.status, 0, result.stderr); @@ -4454,7 +4386,7 @@ test('doctor repairs setup drift and confirms repo is safe', () => { assert.equal(result.status, 0, result.stderr || result.stdout); // Simulate broken setup + stale lock. - fs.rmSync(path.join(repoDir, 'scripts', 'agent-branch-start.sh')); + fs.rmSync(path.join(repoDir, 'scripts', 'guardex-env.sh')); fs.rmSync(path.join(repoDir, '.omx', 'notepad.md')); fs.rmSync(path.join(repoDir, '.omx', 'project-memory.json')); fs.rmSync(path.join(repoDir, '.omx', 'logs'), { recursive: true, force: true }); @@ -4512,16 +4444,15 @@ test('doctor recurses into nested frontend repos and repairs protected-main drif assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(fs.existsSync(path.join(frontendDir, 'AGENTS.md')), true, 'nested frontend should be bootstrapped by setup'); const initialFrontendGitignore = fs.readFileSync(frontendGitignorePath, 'utf8'); - assert.match(initialFrontendGitignore, /^scripts\/\*$/m); - assert.match(initialFrontendGitignore, /^\.githooks$/m); + assertZeroCopyManagedGitignore(initialFrontendGitignore); fs.rmSync(path.join(frontendDir, 'AGENTS.md')); - fs.rmSync(path.join(frontendDir, 'scripts', 'agent-branch-start.sh')); + fs.rmSync(path.join(frontendDir, 'scripts', 'guardex-env.sh')); fs.rmSync(path.join(frontendDir, '.githooks', 'pre-commit')); fs.writeFileSync( frontendGitignorePath, initialFrontendGitignore - .replace(/^scripts\/\*\n/m, '') + .replace(/^scripts\/guardex-env\.sh\n/m, '') .replace(/^\.githooks\n/m, ''), 'utf8', ); @@ -4536,13 +4467,12 @@ test('doctor recurses into nested frontend repos and repairs protected-main drif assert.equal(fs.existsSync(path.join(frontendDir, 'AGENTS.md')), true, 'nested frontend AGENTS.md should be restored'); assert.equal( - fs.existsSync(path.join(frontendDir, 'scripts', 'agent-branch-start.sh')), + fs.existsSync(path.join(frontendDir, 'scripts', 'guardex-env.sh')), true, - 'nested frontend sandbox starter should be restored', + 'nested frontend zero-copy managed script should be restored', ); const repairedFrontendGitignore = fs.readFileSync(frontendGitignorePath, 'utf8'); - assert.match(repairedFrontendGitignore, /^scripts\/\*$/m); - assert.match(repairedFrontendGitignore, /^\.githooks$/m); + assertZeroCopyManagedGitignore(repairedFrontendGitignore); const repairedFrontendHook = fs.readFileSync(path.join(frontendDir, '.githooks', 'pre-commit'), 'utf8'); assert.match(repairedFrontendHook, /'hook' 'run' 'pre-commit'/); @@ -4579,12 +4509,12 @@ test('recursive doctor forwards no-wait-for-merge to protected nested sandbox re assert.equal(result.status, 0, result.stderr || result.stdout); fs.rmSync(path.join(frontendDir, 'AGENTS.md')); - fs.rmSync(path.join(frontendDir, 'scripts', 'agent-branch-start.sh')); + fs.rmSync(path.join(frontendDir, 'scripts', 'guardex-env.sh')); fs.rmSync(path.join(frontendDir, '.githooks', 'pre-commit')); fs.writeFileSync( frontendGitignorePath, initialFrontendGitignore - .replace(/^scripts\/\*\n/m, '') + .replace(/^scripts\/guardex-env\.sh\n/m, '') .replace(/^\.githooks\n/m, ''), 'utf8', ); @@ -4930,13 +4860,13 @@ test('worktree prune keeps merged agent worktrees/branches unless delete flags a assert.equal(result.status, 0, result.stderr); assert.equal(fs.existsSync(worktreePath), true); - result = runCmd('bash', ['scripts/agent-worktree-prune.sh'], repoDir); + result = runWorktreePrune([], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); const branchResult = runCmd('git', ['show-ref', '--verify', '--quiet', 'refs/heads/agent/test-prune'], repoDir); assert.equal(branchResult.status, 0, 'merged agent branch should remain by default'); - result = runCmd('bash', ['scripts/agent-worktree-prune.sh', '--delete-branches'], repoDir); + result = runWorktreePrune(['--delete-branches'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(fs.existsSync(worktreePath), false); const branchAfterDelete = runCmd('git', ['show-ref', '--verify', '--quiet', 'refs/heads/agent/test-prune'], repoDir); @@ -4955,11 +4885,11 @@ test('worktree prune preserves dirty agent worktrees unless --force-dirty is use fs.writeFileSync(path.join(worktreePath, 'dirty.txt'), 'dirty\n', 'utf8'); - result = runCmd('bash', ['scripts/agent-worktree-prune.sh', '--delete-branches'], repoDir); + result = runWorktreePrune(['--delete-branches'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(fs.existsSync(worktreePath), true, 'dirty worktree should remain without --force-dirty'); - result = runCmd('bash', ['scripts/agent-worktree-prune.sh', '--force-dirty', '--delete-branches'], repoDir); + result = runWorktreePrune(['--force-dirty', '--delete-branches'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(fs.existsSync(worktreePath), false, 'dirty worktree should be removable with --force-dirty'); }); @@ -4980,7 +4910,7 @@ test('worktree prune --only-dirty-worktrees removes clean agent worktrees but ke result = runCmd('git', ['-C', worktreePath, 'commit', '-m', 'unmerged clean worktree commit'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); - result = runCmd('bash', ['scripts/agent-worktree-prune.sh', '--only-dirty-worktrees'], repoDir); + result = runWorktreePrune(['--only-dirty-worktrees'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(fs.existsSync(worktreePath), false, 'clean agent worktree should be removed'); @@ -5006,7 +4936,7 @@ test('worktree prune reroutes foreign worktrees to the owning repo .omx root', ( assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(fs.existsSync(misplacedPath), true, 'foreign worktree should start misplaced under current repo'); - result = runCmd('bash', ['scripts/agent-worktree-prune.sh'], repoDir); + result = runWorktreePrune([], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); assert.match(result.stdout, /Relocating foreign worktree to owning repo/); assert.equal(fs.existsSync(misplacedPath), false, 'misplaced foreign worktree should be moved out'); @@ -5039,19 +4969,14 @@ test('worktree prune --idle-minutes preserves recent branch activity and prunes result = runCmd('git', ['-C', worktreePath, 'commit', '-m', 'idle threshold branch commit'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); - result = runCmd('bash', ['scripts/agent-worktree-prune.sh', '--only-dirty-worktrees', '--idle-minutes', '10'], repoDir); + result = runWorktreePrune(['--only-dirty-worktrees', '--idle-minutes', '10'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(fs.existsSync(worktreePath), true, 'recent branch should remain inside idle threshold'); const fakeNowEpoch = Math.floor(Date.now() / 1000) + 3600; - result = runCmd( - 'bash', - ['scripts/agent-worktree-prune.sh', '--only-dirty-worktrees', '--idle-minutes', '10'], - repoDir, - { - GUARDEX_PRUNE_NOW_EPOCH: String(fakeNowEpoch), - }, - ); + result = runWorktreePrune(['--only-dirty-worktrees', '--idle-minutes', '10'], repoDir, { + GUARDEX_PRUNE_NOW_EPOCH: String(fakeNowEpoch), + }); assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(fs.existsSync(worktreePath), false, 'idle branch should be pruned after threshold is exceeded'); });