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
696 changes: 367 additions & 329 deletions bin/multiagent-safety.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions openspec/changes/fix-regression-finish-flows/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-21
24 changes: 24 additions & 0 deletions openspec/changes/fix-regression-finish-flows/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Why

- The Guardex CLI still had a few workflow regressions after the recent protected-main and finish-flow fixes landed on `main`.
- `agent-branch-finish.sh` could still fall back to `dev` in repos that only expose `main`, which makes cleanup and merge automation choose the wrong base.
- `agent-branch-start.sh` still collapsed explicit agent roles to `codex`, which hid real lane ownership in branch names and made the runtime/test surface drift from the intended role-based contract.
- `codex-agent.sh` still attempted PR auto-finish in local/file-remote repos that do not expose a mergeable GitHub PR surface, leaving the session on a finish path that could never succeed.

## What Changes

- Teach `agent-branch-finish.sh` and its template to fall back through `dev`, `main`, and `master` before defaulting so main-only repos finish against a real base branch.
- Preserve explicit role tokens in `agent-branch-start.sh` and its template, while keeping the legacy `claude`, `codex`, and `bot -> codex` compatibility paths intact.
- Gate `codex-agent.sh` auto-finish on a real mergeable remote context and refresh focused regression coverage in `test/install.test.js` and `scripts/test-agent-naming.sh` so the suite matches the current Guardex workflow contract.

## Impact

- Affected runtime surfaces:
- `scripts/agent-branch-finish.sh`
- `scripts/agent-branch-start.sh`
- `scripts/codex-agent.sh`
- matching templates under `templates/scripts/`
- Affected regression coverage:
- `test/install.test.js`
- `scripts/test-agent-naming.sh`
- Risk is moderate because the patch touches branch creation, finish, and auto-finish orchestration, but the blast radius stays inside Guardex workflow scripts and their regression suite.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
## ADDED Requirements

### Requirement: finish flow chooses a real base branch
Guardex SHALL finish agent branches against an available base branch even when no explicit base metadata is stored on the source branch.

#### Scenario: main-only repo without stored base metadata
- **GIVEN** an agent branch is being finished
- **AND** the branch does not have `branch.<name>.guardexBase` metadata
- **AND** the repo exposes `main` but not `dev`
- **WHEN** `scripts/agent-branch-finish.sh` resolves the base branch
- **THEN** it SHALL select `main`
- **AND** it SHALL not fall through to a non-existent `dev` base.

### Requirement: explicit agent roles stay visible in sandbox names
Guardex SHALL preserve explicit agent role tokens in branch/worktree naming while keeping legacy compatibility aliases for the common `codex`, `claude`, and `bot` flows.

#### Scenario: explicit planner role requested
- **GIVEN** `scripts/agent-branch-start.sh` is invoked with an explicit role such as `planner`
- **WHEN** the branch name is normalized
- **THEN** the emitted branch/worktree name SHALL keep the explicit sanitized role token
- **AND** legacy `bot` inputs SHALL still collapse to `codex`.

### Requirement: codex-agent auto-finish requires mergeable remote context
Guardex SHALL skip the PR auto-finish path when the current repo does not expose a mergeable GitHub-backed remote context.

#### Scenario: local or file-backed origin remote
- **GIVEN** `scripts/codex-agent.sh` finishes a successful task run
- **AND** the repo `origin` resolves to a local path or `file://` URL, or `gh` auth is not usable
- **WHEN** auto-finish evaluation runs
- **THEN** Guardex SHALL skip the PR merge/wait flow
- **AND** it SHALL keep the sandbox branch/worktree available for manual follow-up instead of waiting for merge.
21 changes: 21 additions & 0 deletions openspec/changes/fix-regression-finish-flows/tasks.md
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 `fix-regression-finish-flows`.
- [x] 1.2 Define normative requirements in `specs/workflow-guardrails/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 fix-regression-finish-flows --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.
29 changes: 24 additions & 5 deletions scripts/agent-branch-finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,9 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
fi

if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
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"
source_branch_base="$(git -C "$repo_root" config --get "branch.${SOURCE_BRANCH}.guardexBase" || true)"
if [[ -n "$source_branch_base" ]]; then
BASE_BRANCH="$source_branch_base"
else
configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
if [[ -n "$configured_base" ]]; then
Expand All @@ -167,6 +167,16 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
fi
fi

if [[ -z "$BASE_BRANCH" ]]; then
for fallback_branch in dev main master; do
if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${fallback_branch}" \
|| git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${fallback_branch}"; then
BASE_BRANCH="$fallback_branch"
break
fi
done
fi

if [[ -z "$BASE_BRANCH" ]]; then
BASE_BRANCH="dev"
fi
Expand Down Expand Up @@ -273,8 +283,17 @@ if [[ "$should_require_sync" -eq 1 ]] && git -C "$repo_root" show-ref --verify -
fi
fi

integration_worktree="${agent_worktree_root}/__integrate-${BASE_BRANCH//\//__}-$(date +%Y%m%d-%H%M%S)"
integration_branch="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)"
integration_stamp="$(date +%Y%m%d-%H%M%S)"
integration_worktree_base="${agent_worktree_root}/__integrate-${BASE_BRANCH//\//__}-${integration_stamp}"
integration_branch_base="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)"
integration_worktree="$integration_worktree_base"
integration_branch="$integration_branch_base"
integration_suffix=1
while [[ -e "$integration_worktree" ]] || git -C "$repo_root" show-ref --verify --quiet "refs/heads/${integration_branch}"; do
integration_worktree="${integration_worktree_base}-${integration_suffix}"
integration_branch="${integration_branch_base}_${integration_suffix}"
integration_suffix=$((integration_suffix + 1))
done
mkdir -p "$(dirname "$integration_worktree")"

git -C "$repo_root" worktree add "$integration_worktree" "$start_ref" >/dev/null
Expand Down
22 changes: 12 additions & 10 deletions scripts/agent-branch-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,11 @@ shorten_slug() {
printf '%s' "$shortened"
}

# Collapse arbitrary agent identifiers to a clean role token: claude | codex |
# <other-kebab>. Priority: GUARDEX_AGENT_TYPE env override, then the raw
# AGENT_NAME (if it contains 'claude' or 'codex'), then CLAUDECODE=1 sentinel
# (set by Claude Code CLI), else fall back to 'codex'. Any other role name
# (integrator, executor, rust-port, etc.) is preserved as-is after slug
# sanitization.
# Collapse arbitrary agent identifiers to a clean role token. Priority:
# GUARDEX_AGENT_TYPE env override, then recognizable claude/codex aliases, then
# a small legacy compatibility set, then the literal requested role after slug
# sanitization. This preserves explicit roles such as planner/executor while
# keeping the older bot -> codex fallback stable for existing callers.
normalize_role() {
local raw_agent="$1"
local override="${GUARDEX_AGENT_TYPE:-}"
Expand All @@ -150,10 +149,13 @@ normalize_role() {
printf 'claude'
return 0
fi
# Unrecognized raw name (rust-port-lead, some-worker, empty, ...): default to
# codex. To get a different role (integrator, executor, ...) pass the role
# explicitly via GUARDEX_AGENT_TYPE, handled above.
printf 'codex'
local sanitized
sanitized="$(sanitize_slug "$raw_agent" "codex")"
if [[ "$sanitized" == "bot" ]]; then
printf 'codex'
return 0
fi
printf '%s' "$sanitized"
}

# Timestamp the branch/worktree/openspec slug so parallel agents never collide
Expand Down
33 changes: 32 additions & 1 deletion scripts/codex-agent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,35 @@ resolve_start_ref() {
return 1
}

origin_remote_looks_like_github() {
local wt="$1"
local origin_url=""
origin_url="$(git -C "$wt" remote get-url origin 2>/dev/null || true)"
[[ -n "$origin_url" && "$origin_url" =~ github\.com[:/] ]]
}

auto_finish_context_is_ready() {
local wt="$1"
local gh_bin="${GUARDEX_GH_BIN:-gh}"

if ! git -C "$wt" remote get-url origin >/dev/null 2>&1; then
return 1
fi
if ! command -v "$gh_bin" >/dev/null 2>&1; then
return 1
fi

if [[ -n "${GUARDEX_GH_BIN:-}" ]]; then
return 0
fi

if ! origin_remote_looks_like_github "$wt"; then
return 1
fi

"$gh_bin" auth status >/dev/null 2>&1
}

restore_repo_branch_if_changed() {
local expected_branch="$1"
if [[ -z "$expected_branch" || "$expected_branch" == "HEAD" ]]; then
Expand Down Expand Up @@ -780,7 +809,9 @@ if [[ "$AUTO_FINISH" -eq 1 && -n "$worktree_branch" && "$worktree_branch" != "HE
else
echo "[codex-agent] Auto-finish enabled: commit -> push/PR -> merge (keep branch/worktree)."
fi
if auto_commit_worktree_changes "$worktree_path" "$worktree_branch"; then
if ! auto_finish_context_is_ready "$worktree_path"; then
echo "[codex-agent] Auto-finish skipped for '${worktree_branch}' (no mergeable remote context)." >&2
elif auto_commit_worktree_changes "$worktree_path" "$worktree_branch"; then
if run_finish_flow "$worktree_path" "$worktree_branch"; then
auto_finish_completed=1
echo "[codex-agent] Auto-finish completed for '${worktree_branch}'."
Expand Down
9 changes: 5 additions & 4 deletions scripts/test-agent-naming.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
# agent/<role>/<task-slug>-<YYYY-MM-DD>-<HH-MM>
#
# Where:
# - role ∈ {claude, codex} for the common case, or any sanitized role token
# (integrator, executor, rust-port, ...) when passed via GUARDEX_AGENT_TYPE.
# - role ∈ {claude, codex} for the common case, or any sanitized explicit role
# token (integrator, executor, rust-port, ...) when passed directly or via
# GUARDEX_AGENT_TYPE. The legacy name "bot" still falls back to codex.
# - task-slug is the user-provided task name, lowercased + kebab-cased.
# - timestamp is local YYYY-MM-DD-HH-MM; colons are forbidden in git refs, so
# the HH:MM the user sees is stored as HH-MM in the slug.
Expand Down Expand Up @@ -101,8 +102,8 @@ assert_eq "neutral name + CLAUDECODE=1 → claude" \
"$actual" "agent/claude/task4-${STAMP}"

actual="$(run_name_only task5 rust-port-lead)"
assert_eq "neutral name + no env → codex default" \
"$actual" "agent/codex/task5-${STAMP}"
assert_eq "neutral explicit name stays preserved" \
"$actual" "agent/rust-port-lead/task5-${STAMP}"

actual="$(run_name_only task6 claude GUARDEX_AGENT_TYPE=codex)"
assert_eq "GUARDEX_AGENT_TYPE=codex overrides claude arg" \
Expand Down
29 changes: 24 additions & 5 deletions templates/scripts/agent-branch-finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,9 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
fi

if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
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"
source_branch_base="$(git -C "$repo_root" config --get "branch.${SOURCE_BRANCH}.guardexBase" || true)"
if [[ -n "$source_branch_base" ]]; then
BASE_BRANCH="$source_branch_base"
else
configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
if [[ -n "$configured_base" ]]; then
Expand All @@ -167,6 +167,16 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
fi
fi

if [[ -z "$BASE_BRANCH" ]]; then
for fallback_branch in dev main master; do
if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${fallback_branch}" \
|| git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${fallback_branch}"; then
BASE_BRANCH="$fallback_branch"
break
fi
done
fi

if [[ -z "$BASE_BRANCH" ]]; then
BASE_BRANCH="dev"
fi
Expand Down Expand Up @@ -273,8 +283,17 @@ if [[ "$should_require_sync" -eq 1 ]] && git -C "$repo_root" show-ref --verify -
fi
fi

integration_worktree="${agent_worktree_root}/__integrate-${BASE_BRANCH//\//__}-$(date +%Y%m%d-%H%M%S)"
integration_branch="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)"
integration_stamp="$(date +%Y%m%d-%H%M%S)"
integration_worktree_base="${agent_worktree_root}/__integrate-${BASE_BRANCH//\//__}-${integration_stamp}"
integration_branch_base="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)"
integration_worktree="$integration_worktree_base"
integration_branch="$integration_branch_base"
integration_suffix=1
while [[ -e "$integration_worktree" ]] || git -C "$repo_root" show-ref --verify --quiet "refs/heads/${integration_branch}"; do
integration_worktree="${integration_worktree_base}-${integration_suffix}"
integration_branch="${integration_branch_base}_${integration_suffix}"
integration_suffix=$((integration_suffix + 1))
done
mkdir -p "$(dirname "$integration_worktree")"

git -C "$repo_root" worktree add "$integration_worktree" "$start_ref" >/dev/null
Expand Down
22 changes: 12 additions & 10 deletions templates/scripts/agent-branch-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,11 @@ shorten_slug() {
printf '%s' "$shortened"
}

# Collapse arbitrary agent identifiers to a clean role token: claude | codex |
# <other-kebab>. Priority: GUARDEX_AGENT_TYPE env override, then the raw
# AGENT_NAME (if it contains 'claude' or 'codex'), then CLAUDECODE=1 sentinel
# (set by Claude Code CLI), else fall back to 'codex'. Any other role name
# (integrator, executor, rust-port, etc.) is preserved as-is after slug
# sanitization.
# Collapse arbitrary agent identifiers to a clean role token. Priority:
# GUARDEX_AGENT_TYPE env override, then recognizable claude/codex aliases, then
# a small legacy compatibility set, then the literal requested role after slug
# sanitization. This preserves explicit roles such as planner/executor while
# keeping the older bot -> codex fallback stable for existing callers.
normalize_role() {
local raw_agent="$1"
local override="${GUARDEX_AGENT_TYPE:-}"
Expand All @@ -150,10 +149,13 @@ normalize_role() {
printf 'claude'
return 0
fi
# Unrecognized raw name (rust-port-lead, some-worker, empty, ...): default to
# codex. To get a different role (integrator, executor, ...) pass the role
# explicitly via GUARDEX_AGENT_TYPE, handled above.
printf 'codex'
local sanitized
sanitized="$(sanitize_slug "$raw_agent" "codex")"
if [[ "$sanitized" == "bot" ]]; then
printf 'codex'
return 0
fi
printf '%s' "$sanitized"
}

# Timestamp the branch/worktree/openspec slug so parallel agents never collide
Expand Down
33 changes: 32 additions & 1 deletion templates/scripts/codex-agent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,35 @@ resolve_start_ref() {
return 1
}

origin_remote_looks_like_github() {
local wt="$1"
local origin_url=""
origin_url="$(git -C "$wt" remote get-url origin 2>/dev/null || true)"
[[ -n "$origin_url" && "$origin_url" =~ github\.com[:/] ]]
}

auto_finish_context_is_ready() {
local wt="$1"
local gh_bin="${GUARDEX_GH_BIN:-gh}"

if ! git -C "$wt" remote get-url origin >/dev/null 2>&1; then
return 1
fi
if ! command -v "$gh_bin" >/dev/null 2>&1; then
return 1
fi

if [[ -n "${GUARDEX_GH_BIN:-}" ]]; then
return 0
fi

if ! origin_remote_looks_like_github "$wt"; then
return 1
fi

"$gh_bin" auth status >/dev/null 2>&1
}

restore_repo_branch_if_changed() {
local expected_branch="$1"
if [[ -z "$expected_branch" || "$expected_branch" == "HEAD" ]]; then
Expand Down Expand Up @@ -780,7 +809,9 @@ if [[ "$AUTO_FINISH" -eq 1 && -n "$worktree_branch" && "$worktree_branch" != "HE
else
echo "[codex-agent] Auto-finish enabled: commit -> push/PR -> merge (keep branch/worktree)."
fi
if auto_commit_worktree_changes "$worktree_path" "$worktree_branch"; then
if ! auto_finish_context_is_ready "$worktree_path"; then
echo "[codex-agent] Auto-finish skipped for '${worktree_branch}' (no mergeable remote context)." >&2
elif auto_commit_worktree_changes "$worktree_path" "$worktree_branch"; then
if run_finish_flow "$worktree_path" "$worktree_branch"; then
auto_finish_completed=1
echo "[codex-agent] Auto-finish completed for '${worktree_branch}'."
Expand Down
Loading