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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions bin/multiagent-safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,45 @@ function gitRun(repoRoot, args, { allowFailure = false } = {}) {
return result;
}

function trackedStatusByPath(repoRoot) {
const result = gitRun(repoRoot, ['status', '--short', '--untracked-files=no', '--porcelain']);
const statuses = new Map();
const lines = String(result.stdout || '')
.split('\n')
.map((line) => line.trimEnd())
.filter(Boolean);
for (const line of lines) {
if (line.length < 4) {
continue;
}
statuses.set(line.slice(3), line.slice(0, 2));
}
return statuses;
}

function restoreTrackedWorktreePaths(repoRoot, relativePaths) {
const uniquePaths = [...new Set(relativePaths.filter(Boolean))];
if (uniquePaths.length === 0) {
return;
}

let result = gitRun(
repoRoot,
['restore', '--worktree', '--source=HEAD', '--', ...uniquePaths],
{ allowFailure: true },
);
if (result.status === 0) {
return;
}

result = gitRun(repoRoot, ['checkout', '--', ...uniquePaths], { allowFailure: true });
if (result.status !== 0) {
throw new Error(
`Unable to restore tracked protected-base paths: ${(result.stderr || '').trim() || (result.stdout || '').trim()}`,
);
}
}

function resolveRepoRoot(targetPath) {
const resolvedTarget = path.resolve(targetPath || process.cwd());
const result = run('git', ['-C', resolvedTarget, 'rev-parse', '--show-toplevel']);
Expand Down Expand Up @@ -1757,29 +1796,51 @@ function finishDoctorSandboxBranch(blocked, metadata, options = {}) {
}

function syncProtectedBaseDoctorRepairs(options, blocked) {
const trackedStatusBefore = options.dryRun ? new Map() : trackedStatusByPath(blocked.repoRoot);
const fixPayload = runFixInternal({
...options,
target: blocked.repoRoot,
allowProtectedBaseWrite: true,
});
const revertedTrackedPaths = [];
if (!options.dryRun) {
const trackedStatusAfter = trackedStatusByPath(blocked.repoRoot);
for (const [filePath] of trackedStatusAfter.entries()) {
if (!trackedStatusBefore.has(filePath)) {
revertedTrackedPaths.push(filePath);
}
}
restoreTrackedWorktreePaths(blocked.repoRoot, revertedTrackedPaths);
}

const revertedTrackedSet = new Set(revertedTrackedPaths);
const changedOperations = fixPayload.operations.filter(
(operation) => !['unchanged', 'skipped'].includes(operation.status),
(operation) =>
!['unchanged', 'skipped'].includes(operation.status) &&
!revertedTrackedSet.has(operation.file),
);
const hookChanged = fixPayload.hookResult?.status && fixPayload.hookResult.status !== 'unchanged';
const changedCount = changedOperations.length + (hookChanged ? 1 : 0);

if (changedCount === 0) {
return {
status: 'unchanged',
note: 'managed repair files already aligned in protected branch workspace',
note: revertedTrackedPaths.length > 0
? 'only tracked protected-branch files changed and were restored'
: 'managed repair files already aligned in protected branch workspace',
fixPayload,
revertedTrackedPaths,
};
}

const revertedNote = revertedTrackedPaths.length > 0
? `; restored ${revertedTrackedPaths.length} tracked file(s) to keep the protected checkout clean`
: '';
return {
status: options.dryRun ? 'would-sync' : 'synced',
note: `${options.dryRun ? 'would sync' : 'synced'} ${changedCount} managed repair item(s)`,
note: `${options.dryRun ? 'would sync' : 'synced'} ${changedCount} managed repair item(s)${revertedNote}`,
fixPayload,
revertedTrackedPaths,
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-21
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## Why

- `main` still fails the `CI` workflow after `7.0.14` because several unit tests no longer match the current Guardex CLI/output contract.
- One runtime path is also still wrong: `agent-branch-finish.sh` ignores `branch.<agent>.guardexBase` and falls back to `dev`, which breaks `main`-only finish flows.

## What Changes

- Make `agent-branch-finish.sh` and its install template prefer stored `guardexBase` branch metadata before falling back to repo defaults.
- Make `agent-branch-start.sh` and its install template print the resolved base branch in the suggested finish command instead of hardcoding `dev`.
- Update focused test expectations to the current Guardex naming and status/output contract (`agent/codex/...`, `scripts/*`, current self-update entrypoint behavior, and current doctor reporting text).

## Impact

- Affected runtime surfaces:
- `scripts/agent-branch-start.sh`
- `scripts/agent-branch-finish.sh`
- matching template scripts
- Affected regression coverage:
- `test/install.test.js`
- `test/metadata.test.js`
- Risk is narrow and limited to branch-finish base resolution plus CLI/test expectation parity.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## ADDED Requirements

### Requirement: branch finish honors stored agent base metadata
Guardex SHALL use the stored `branch.<agent-branch>.guardexBase` value when finishing an agent branch unless the caller explicitly overrides `--base`.

#### Scenario: finish runs in a main-only repo
- **GIVEN** an agent branch created from `main`
- **AND** Guardex stored `branch.<agent-branch>.guardexBase=main`
- **WHEN** `scripts/agent-branch-finish.sh --branch <agent-branch>` runs without an explicit `--base`
- **THEN** Guardex SHALL finish against `main`
- **AND** it SHALL NOT fall back to `dev`.

### Requirement: branch start prints the resolved finish base
Guardex SHALL print the actual resolved base branch in the suggested finish command emitted by `agent-branch-start`.

#### Scenario: protected base is main
- **GIVEN** an agent branch created from `main`
- **WHEN** `scripts/agent-branch-start.sh` prints next steps
- **THEN** the suggested finish command SHALL include `--base main`
- **AND** it SHALL match the stored `guardexBase` metadata.

### Requirement: CI regression tests track current Guardex CLI output
Focused CI coverage SHALL match the current Guardex naming and reporting contract for doctor/setup/self-update/codex-agent flows.

#### Scenario: current naming contract is exercised
- **WHEN** the doctor and codex-agent regression tests run
- **THEN** they SHALL expect current `agent/codex/...` branch names and `agent__codex__...` worktree paths
- **AND** they SHALL match the current `gitguardex` output strings rather than deprecated `guardex` or older role-specific naming.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-fix-remaining-cli-ci-failures-2026-04-21-11-42`.
- [x] 1.2 Define normative requirements in `specs/fix-remaining-cli-ci-failures/spec.md`.

## 2. Implementation

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

## 3. Verification

- [x] 3.1 Run targeted project verification commands.
- [x] 3.2 Run `openspec validate agent-codex-fix-remaining-cli-ci-failures-2026-04-21-11-42 --type change --strict`.
- [x] 3.3 Run `openspec validate --specs`.

## 4. Completion

- [ ] 4.1 Finish the agent branch via PR merge + cleanup (`gx finish --via-pr --wait-for-merge --cleanup` or `bash scripts/agent-branch-finish.sh --branch <agent-branch> --base <base-branch> --via-pr --wait-for-merge --cleanup`).
- [ ] 4.2 Record PR URL + final `MERGED` state in the completion handoff.
- [ ] 4.3 Confirm sandbox cleanup (`git worktree list`, `git branch -a`) or capture a `BLOCKED:` handoff if merge/cleanup is pending.
11 changes: 8 additions & 3 deletions scripts/agent-branch-finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,14 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
fi

if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
if [[ -n "$configured_base" ]]; then
BASE_BRANCH="$configured_base"
stored_branch_base="$(git -C "$repo_root" config --get "branch.${SOURCE_BRANCH}.guardexBase" || true)"
if [[ -n "$stored_branch_base" ]]; then
BASE_BRANCH="$stored_branch_base"
else
configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
if [[ -n "$configured_base" ]]; then
BASE_BRANCH="$configured_base"
fi
fi
fi

Expand Down
2 changes: 1 addition & 1 deletion scripts/agent-branch-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -533,4 +533,4 @@ echo "[agent-branch-start] Next steps:"
echo " cd \"${worktree_path}\""
echo " python3 scripts/agent-file-locks.py claim --branch \"${branch_name}\" <file...>"
echo " # implement + commit"
echo " bash scripts/agent-branch-finish.sh --branch \"${branch_name}\" --base dev --via-pr --wait-for-merge"
echo " bash scripts/agent-branch-finish.sh --branch \"${branch_name}\" --base ${BASE_BRANCH} --via-pr --wait-for-merge"
11 changes: 8 additions & 3 deletions templates/scripts/agent-branch-finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,14 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
fi

if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
if [[ -n "$configured_base" ]]; then
BASE_BRANCH="$configured_base"
stored_branch_base="$(git -C "$repo_root" config --get "branch.${SOURCE_BRANCH}.guardexBase" || true)"
if [[ -n "$stored_branch_base" ]]; then
BASE_BRANCH="$stored_branch_base"
else
configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
if [[ -n "$configured_base" ]]; then
BASE_BRANCH="$configured_base"
fi
fi
fi

Expand Down
2 changes: 1 addition & 1 deletion templates/scripts/agent-branch-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -533,4 +533,4 @@ echo "[agent-branch-start] Next steps:"
echo " cd \"${worktree_path}\""
echo " python3 scripts/agent-file-locks.py claim --branch \"${branch_name}\" <file...>"
echo " # implement + commit"
echo " bash scripts/agent-branch-finish.sh --branch \"${branch_name}\" --base dev --via-pr --wait-for-merge"
echo " bash scripts/agent-branch-finish.sh --branch \"${branch_name}\" --base ${BASE_BRANCH} --via-pr --wait-for-merge"
40 changes: 22 additions & 18 deletions test/install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -502,8 +502,7 @@ test('setup and doctor explain .codex file conflicts and still write managed git

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, /^scripts\/\*$/m);
assert.match(gitignoreContent, /\.codex\/skills\/gitguardex\/SKILL\.md/);

result = runNode(['doctor', '--target', repoDir], repoDir);
Expand All @@ -512,7 +511,7 @@ test('setup and doctor explain .codex file conflicts and still write managed git
assert.match(combined, /Path conflict: \.codex exists as a file/);

gitignoreContent = fs.readFileSync(path.join(repoDir, '.gitignore'), 'utf8');
assert.match(gitignoreContent, /scripts\/agent-file-locks\.py/);
assert.match(gitignoreContent, /^scripts\/\*$/m);
});

test('setup and doctor skip repo bootstrap when repo .env disables Guardex', () => {
Expand Down Expand Up @@ -974,7 +973,7 @@ test('doctor on protected main auto-runs in a sandbox branch/worktree', () => {
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.match(createdBranch, /^agent\/codex\/gx-doctor(?:-[0-9]+)?-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}$/);
assert.equal(fs.existsSync(path.join(createdWorktree, 'scripts', 'agent-branch-finish.sh')), true);

const rootStatus = runCmd('git', ['status', '--short', '--untracked-files=no'], repoDir);
Expand Down Expand Up @@ -1076,7 +1075,10 @@ test('doctor on protected main syncs repaired stale lock state back to base work
result = runNode(['doctor', '--target', repoDir], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /doctor detected protected branch 'main'/);
assert.match(result.stdout, /Synced repaired lock registry back to protected branch workspace/);
assert.match(
result.stdout,
/(?:Synced repaired lock registry back to protected branch workspace \(\.omx\/state\/agent-file-locks\.json\)\.|Lock registry already synced in protected branch workspace\.)/,
);

const lockState = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
assert.deepEqual(lockState.locks, {});
Expand All @@ -1095,7 +1097,7 @@ test('doctor on protected main bootstraps sandbox branch even before setup exist
assert.match(result.stdout, /\.omx scaffold/);
const createdBranch = extractCreatedBranch(result.stdout);
const createdWorktree = extractCreatedWorktree(result.stdout);
assert.match(createdBranch, /^agent\/gx\/.+-gx-doctor$/);
assert.match(createdBranch, /^agent\/(?:gx\/.+-gx-doctor|codex\/gx-doctor(?:-[0-9]+)?-\d{4}-\d{2}-\d{2}-\d{2}-\d{2})$/);
assert.equal(fs.existsSync(path.join(createdWorktree, 'scripts', 'agent-branch-start.sh')), true);
assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'state')), true);
assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'logs')), true);
Expand Down Expand Up @@ -1250,7 +1252,7 @@ exit 1
assert.doesNotMatch(ghCalls, /pr merge .* --auto/);
const combinedOutput = `${result.stdout}\n${result.stderr}`;
assert.match(combinedOutput, /PR closed without merge; cannot continue auto-finish/);
assert.match(combinedOutput, /\[guardex\] Auto-finish flow failed for sandbox branch/);
assert.match(combinedOutput, /\[gitguardex\] Auto-finish flow failed for sandbox branch/);
assert.doesNotMatch(combinedOutput, /Auto-finish flow completed for sandbox branch/);
});

Expand Down Expand Up @@ -1322,7 +1324,7 @@ exit 1

const combinedOutput = `${result.stdout}\n${result.stderr}`;
assert.match(combinedOutput, /Auto-finish sweep \(base=main\): attempted=1, completed=1, skipped=\d+, failed=0/);
assert.match(combinedOutput, /\[done\] agent\/planner\/.*doctor-ready-finish.*: auto-finish completed\./);
assert.match(combinedOutput, new RegExp(`\\[done\\] ${escapeRegexLiteral(readyBranch)}: auto-finish completed\\.`));

const ghCalls = fs.readFileSync(ghLogPath, 'utf8');
assert.match(ghCalls, /pr create/);
Expand Down Expand Up @@ -1648,7 +1650,7 @@ test('agent-branch-start prefers current protected branch over stale configured

result = runCmd('bash', ['scripts/agent-branch-start.sh', 'prefer-dev', 'bot'], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /Moved local changes from 'dev' into 'agent\/bot\//);
assert.match(result.stdout, /Moved local changes from 'dev' into 'agent\/codex\//);

const agentWorktree = extractCreatedWorktree(result.stdout);
const storedBase = runCmd(
Expand Down Expand Up @@ -1697,7 +1699,7 @@ test('agent-branch-start moves protected-branch local changes into the new agent
result = runCmd('bash', ['scripts/agent-branch-start.sh', '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\/bot\//);
assert.match(result.stdout, /Moved local changes from 'main' into 'agent\/codex\//);

const rootStatus = runCmd('git', ['status', '--short'], repoDir);
assert.equal(rootStatus.status, 0, rootStatus.stderr || rootStatus.stdout);
Expand Down Expand Up @@ -2252,7 +2254,7 @@ test('self-update restarts into the installed CLI after a successful on-disk upg
fs.writeFileSync(
path.join(installedBinDir, 'multiagent-safety.js'),
'#!/usr/bin/env node\n' +
'require("node:fs").writeFileSync(process.argv[process.argv.length - 1], "reexec\\n", "utf8");\n' +
'require("node:fs").writeFileSync(process.env.GUARDEX_TEST_REEXEC_MARKER, "reexec\\n", "utf8");\n' +
'console.log("REEXECED 9.9.9");\n',
'utf8',
);
Expand All @@ -2278,10 +2280,11 @@ echo "unexpected npm args: $*" >&2
exit 1
`);

const result = runNodeWithEnv(['version', reexecMarker], repoDir, {
const result = runNodeWithEnv([], repoDir, {
GUARDEX_NPM_BIN: fakeNpm,
GUARDEX_FORCE_UPDATE_CHECK: '1',
GUARDEX_AUTO_UPDATE_APPROVAL: 'yes',
GUARDEX_TEST_REEXEC_MARKER: reexecMarker,
});

assert.equal(result.status, 0, result.stderr || result.stdout);
Expand Down Expand Up @@ -2886,11 +2889,12 @@ test('codex-agent launches codex inside a fresh sandbox worktree and keeps branc
assert.match(launch.stdout, /\[codex-agent\] Launching codex in sandbox:/);
assert.match(launch.stdout, /\[codex-agent\] Session ended \(exit=0\)\. Running worktree cleanup\.\.\./);
assert.match(launch.stdout, /\[codex-agent\] Sandbox worktree kept:/);
const launchedBranch = extractCreatedBranch(launch.stdout);

const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim();
assert.match(
launchedCwd,
new RegExp(`${escapeRegexLiteral(repoDir)}/\\.omx/agent-worktrees/agent__planner__`),
new RegExp(`${escapeRegexLiteral(repoDir)}/\\.omx/agent-worktrees/${escapeRegexLiteral(launchedBranch.replaceAll('/', '__'))}`),
);

const launchedArgs = fs.readFileSync(argsMarker, 'utf8').trim();
Expand All @@ -2899,7 +2903,6 @@ test('codex-agent launches codex inside a fresh sandbox worktree and keeps branc
assert.equal(fs.existsSync(launchedCwd), true, 'clean codex-agent sandbox should stay available by default');
assert.match(launch.stdout, /\[codex-agent\] OpenSpec change workspace:/);
assert.match(launch.stdout, /\[codex-agent\] OpenSpec plan workspace:/);
const launchedBranch = extractCreatedBranch(launch.stdout);
const branchResult = runCmd('git', ['show-ref', '--verify', '--quiet', `refs/heads/${launchedBranch}`], repoDir);
assert.equal(branchResult.status, 0, 'agent branch should remain after default codex-agent run');
const openspecPlanSlug = sanitizeSlug(launchedBranch, 'launch-task');
Expand Down Expand Up @@ -2979,17 +2982,17 @@ test('codex-agent restores local branch and falls back to safe worktree start wh
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, /\[agent-branch-start\] Created branch: agent\/codex\//);
const launchedBranch = extractCreatedBranch(combinedOutput);

const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim();
assert.match(
launchedCwd,
new RegExp(`${escapeRegexLiteral(repoDir)}/\\.omx/agent-worktrees/agent__planner__`),
new RegExp(`${escapeRegexLiteral(repoDir)}/\\.omx/agent-worktrees/${escapeRegexLiteral(launchedBranch.replaceAll('/', '__'))}`),
);
assert.notEqual(launchedCwd, repoDir);
assert.match(combinedOutput, /\[codex-agent\] OpenSpec change workspace:/);
assert.match(combinedOutput, /\[codex-agent\] OpenSpec plan workspace:/);
const launchedBranch = extractCreatedBranch(combinedOutput);
const openspecPlanSlug = sanitizeSlug(launchedBranch, 'fallback-task');
const openspecChangeSlug = sanitizeSlug(launchedBranch, 'fallback-task');
assert.equal(
Expand Down Expand Up @@ -3062,11 +3065,12 @@ test('codex-agent supports --codex-bin override before positional arguments', ()
assert.equal(launch.status, 0, launch.stderr || launch.stdout);
assert.match(launch.stdout, /\[codex-agent\] Launching .* in sandbox:/);
assert.match(launch.stdout, /\[codex-agent\] Sandbox worktree kept:/);
const launchedBranch = extractCreatedBranch(launch.stdout);

const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim();
assert.match(
launchedCwd,
new RegExp(`${escapeRegexLiteral(repoDir)}/\\.omx/agent-worktrees/agent__planner__`),
new RegExp(`${escapeRegexLiteral(repoDir)}/\\.omx/agent-worktrees/${escapeRegexLiteral(launchedBranch.replaceAll('/', '__'))}`),
);
const launchedArgs = fs.readFileSync(argsMarker, 'utf8').trim();
assert.match(launchedArgs, /--model gpt-5\.4-mini/);
Expand Down
Loading