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
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,9 @@ Use this exact checklist to setup multi-agent safety in this repository for Code
start isolated agent branch/worktree -> claim file locks -> implement/verify ->
finish via PR/merge cleanup with scripts/agent-branch-finish.sh.
- `scripts/codex-agent.sh` now auto-runs this finish flow after Codex exits:
auto-commit changed files -> push/create PR -> merge attempt -> branch/worktree cleanup ->
pull local base branch.
auto-commit changed files -> push/create PR -> merge attempt -> keep branch/worktree for follow-up.
- Remove merged branches when you are done reviewing:
gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"

5) Optional: create OpenSpec planning workspace:
bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
Expand Down Expand Up @@ -272,9 +273,11 @@ gx protect set <branch...> [--target <path>]
gx protect reset [--target <path>]
gx sync --check [--target <path>] [--base <branch>] [--json]
gx sync [--target <path>] [--base <branch>] [--strategy rebase|merge] [--ff-only]
gx cleanup [--target <path>] [--base <branch>] [--branch <agent/...>] [--dry-run] [--force-dirty] [--keep-remote]
gx report scorecard [--target <path>] [--repo github.com/<owner>/<repo>] [--scorecard-json <file>] [--output-dir <path>] [--date YYYY-MM-DD]
bash scripts/agent-worktree-prune.sh # manual stale worktree cleanup (auto base detection)
bash scripts/agent-worktree-prune.sh --force-dirty # remove stale dirty worktrees too
bash scripts/agent-worktree-prune.sh # prune temporary worktrees only (keeps merged agent branches by default)
bash scripts/agent-worktree-prune.sh --delete-branches --delete-remote-branches # full merged-branch cleanup
bash scripts/agent-worktree-prune.sh --force-dirty --delete-branches # force-remove dirty merged worktrees too
bash scripts/openspec/init-plan-workspace.sh <plan-slug> # optional OpenSpec plan scaffold
```

Expand All @@ -291,8 +294,10 @@ and asks `[y/N]` whether to update immediately (default is `N`).
- `gx doctor` on protected `main` auto-starts an isolated `agent/gx/...-gx-doctor` worktree branch and applies repairs there.
- `gx setup` and `gx doctor` always refresh `.githooks/pre-commit` from templates, so Codex sub-branch enforcement stays repaired.
- `scripts/codex-agent.sh` now auto-runs finish automation after a Codex session when `origin` exists:
auto-commit changed files, run PR/merge cleanup, and prune merged worktrees.
auto-commit changed files, run PR/merge automation, and keep merged agent branches/worktrees by default.
It also auto-syncs each sandbox branch against the latest base branch before task execution.
If conflicts remain, it keeps the sandbox and prompts for a conflict-resolution review pass.
- use `gx cleanup` (or `gx cleanup --branch <agent/...>`) to remove merged branches/worktrees when done.

## Advanced commands

Expand Down
108 changes: 107 additions & 1 deletion bin/multiagent-safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ const CRITICAL_GUARDRAIL_PATHS = new Set([
'.githooks/pre-commit',
'scripts/agent-branch-start.sh',
'scripts/agent-branch-finish.sh',
'scripts/agent-worktree-prune.sh',
'scripts/codex-agent.sh',
'scripts/agent-file-locks.py',
]);

Expand Down Expand Up @@ -88,6 +90,7 @@ const COMMAND_TYPO_ALIASES = new Map([
['intsall', 'install'],
['docter', 'doctor'],
['doctro', 'doctor'],
['cleunup', 'cleanup'],
['scna', 'scan'],
]);
const SUGGESTIBLE_COMMANDS = [
Expand All @@ -100,6 +103,7 @@ const SUGGESTIBLE_COMMANDS = [
'copy-commands',
'protect',
'sync',
'cleanup',
'release',
'install',
'fix',
Expand All @@ -118,6 +122,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
['copy-commands', 'Print setup checklist as executable commands only'],
['protect', 'Manage protected branches (list/add/remove/set/reset)'],
['sync', 'Check or sync agent branches with origin/<base>'],
['cleanup', 'Cleanup merged agent branches/worktrees (local + remote)'],
['install', 'Install templates/locks/hooks without running full setup (supports --no-gitignore)'],
['fix', 'Repair broken or missing guardrail files/config (supports --no-gitignore)'],
['scan', 'Report safety issues and exit non-zero on findings'],
Expand Down Expand Up @@ -152,6 +157,9 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
- For every new user message/task, repeat the same cycle:
start isolated agent branch/worktree -> claim file locks -> implement/verify ->
finish via PR/merge cleanup with scripts/agent-branch-finish.sh.
- Finished branches stay available by default for audit/follow-up.
Remove them explicitly when done:
gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"

5) Optional: create OpenSpec planning workspace:
bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
Expand All @@ -174,6 +182,7 @@ bash scripts/codex-agent.sh "task" "agent-name"
bash scripts/agent-branch-start.sh "task" "agent-name"
python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)"
gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
gx protect add release staging
gx sync --check
Expand Down Expand Up @@ -290,6 +299,8 @@ NOTES
- ${TOOL_NAME} setup asks for Y/N approval before global installs
- In initialized repos, setup/install/fix block in-place writes on protected main by default
- doctor auto-starts a sandbox agent branch/worktree when run on protected main
- agent-branch-finish merges by default and keeps agent branches/worktrees until explicit cleanup
- use '${SHORT_TOOL_NAME} cleanup' to remove merged agent branches/worktrees (optionally remote refs too)
- Legacy command aliases are still supported: ${LEGACY_NAMES.join(', ')}`);

if (outsideGitRepo) {
Expand Down Expand Up @@ -506,7 +517,7 @@ function ensurePackageScripts(repoRoot, dryRun) {
'agent:codex': 'bash ./scripts/codex-agent.sh',
'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh',
'agent:cleanup': `${SHORT_TOOL_NAME} cleanup`,
'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
'agent:locks:allow-delete': 'python3 ./scripts/agent-file-locks.py allow-delete',
Expand Down Expand Up @@ -1256,6 +1267,63 @@ function parseSyncArgs(rawArgs) {
return options;
}

function parseCleanupArgs(rawArgs) {
const options = {
target: process.cwd(),
base: '',
branch: '',
dryRun: false,
forceDirty: false,
keepRemote: false,
};

for (let index = 0; index < rawArgs.length; index += 1) {
const arg = rawArgs[index];
if (arg === '--target') {
const next = rawArgs[index + 1];
if (!next) {
throw new Error('--target requires a path value');
}
options.target = next;
index += 1;
continue;
}
if (arg === '--base') {
const next = rawArgs[index + 1];
if (!next) {
throw new Error('--base requires a branch value');
}
options.base = next;
index += 1;
continue;
}
if (arg === '--branch') {
const next = rawArgs[index + 1];
if (!next) {
throw new Error('--branch requires an agent branch value');
}
options.branch = next;
index += 1;
continue;
}
if (arg === '--dry-run') {
options.dryRun = true;
continue;
}
if (arg === '--force-dirty') {
options.forceDirty = true;
continue;
}
if (arg === '--keep-remote') {
options.keepRemote = true;
continue;
}
throw new Error(`Unknown option: ${arg}`);
}

return options;
}

function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
if (strategy === 'rebase') {
if (ffOnly) {
Expand Down Expand Up @@ -2284,6 +2352,39 @@ function copyCommands() {
process.exitCode = 0;
}

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 = [pruneScript];
if (options.base) {
args.push('--base', options.base);
}
if (options.branch) {
args.push('--branch', options.branch);
}
if (options.forceDirty) {
args.push('--force-dirty');
}
if (options.dryRun) {
args.push('--dry-run');
}
args.push('--delete-branches');
if (!options.keepRemote) {
args.push('--delete-remote-branches');
}

const runResult = run('bash', args, { cwd: repoRoot, stdio: 'inherit' });
if (runResult.status !== 0) {
throw new Error('Cleanup command failed');
}
process.exitCode = 0;
}

function sync(rawArgs) {
const options = parseSyncArgs(rawArgs);
const repoRoot = resolveRepoRoot(options.target);
Expand Down Expand Up @@ -2632,6 +2733,11 @@ function main() {
return;
}

if (command === 'cleanup') {
cleanup(rest);
return;
}

if (command === 'release') {
release(rest);
return;
Expand Down
112 changes: 81 additions & 31 deletions scripts/agent-branch-finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,26 @@ BASE_BRANCH=""
BASE_BRANCH_EXPLICIT=0
SOURCE_BRANCH=""
PUSH_ENABLED=1
DELETE_REMOTE_BRANCH=1
DELETE_REMOTE_BRANCH=0
DELETE_REMOTE_BRANCH_EXPLICIT=0
MERGE_MODE="auto"
GH_BIN="${MUSAFETY_GH_BIN:-gh}"
CLEANUP_AFTER_MERGE_RAW="${MUSAFETY_FINISH_CLEANUP:-false}"

normalize_bool() {
local raw="${1:-}"
local fallback="${2:-0}"
local lowered
lowered="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
case "$lowered" in
1|true|yes|on) printf '1' ;;
0|false|no|off) printf '0' ;;
'') printf '%s' "$fallback" ;;
*) printf '%s' "$fallback" ;;
esac
}

CLEANUP_AFTER_MERGE="$(normalize_bool "$CLEANUP_AFTER_MERGE_RAW" "0")"

while [[ $# -gt 0 ]]; do
case "$1" in
Expand All @@ -26,6 +43,20 @@ while [[ $# -gt 0 ]]; do
;;
--keep-remote-branch)
DELETE_REMOTE_BRANCH=0
DELETE_REMOTE_BRANCH_EXPLICIT=1
shift
;;
--delete-remote-branch)
DELETE_REMOTE_BRANCH=1
DELETE_REMOTE_BRANCH_EXPLICIT=1
shift
;;
--cleanup)
CLEANUP_AFTER_MERGE=1
shift
;;
--no-cleanup)
CLEANUP_AFTER_MERGE=0
shift
;;
--mode)
Expand All @@ -42,12 +73,16 @@ while [[ $# -gt 0 ]]; do
;;
*)
echo "[agent-branch-finish] Unknown argument: $1" >&2
echo "Usage: $0 [--base <branch>] [--branch <branch>] [--no-push] [--keep-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2
echo "Usage: $0 [--base <branch>] [--branch <branch>] [--no-push] [--cleanup|--no-cleanup] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2
exit 1
;;
esac
done

if [[ "$CLEANUP_AFTER_MERGE" -eq 1 && "$DELETE_REMOTE_BRANCH_EXPLICIT" -eq 0 ]]; then
DELETE_REMOTE_BRANCH=1
fi

case "$MERGE_MODE" in
auto|direct|pr) ;;
*)
Expand Down Expand Up @@ -347,43 +382,58 @@ 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

if [[ "$source_worktree" == "$repo_root" ]]; then
if is_clean_worktree "$source_worktree"; then
git -C "$source_worktree" checkout "$BASE_BRANCH" >/dev/null 2>&1 || true
if [[ "$PUSH_ENABLED" -eq 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
git -C "$source_worktree" pull --ff-only origin "$BASE_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
git -C "$base_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true
fi

if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
if [[ "$source_worktree" == "$repo_root" ]]; then
if is_clean_worktree "$source_worktree"; then
git -C "$source_worktree" checkout "$BASE_BRANCH" >/dev/null 2>&1 || true
if [[ "$PUSH_ENABLED" -eq 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
git -C "$source_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true
fi
fi
elif [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then
git -C "$source_worktree" checkout --detach >/dev/null 2>&1 || true
fi
elif [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then
git -C "$source_worktree" checkout --detach >/dev/null 2>&1 || true
fi

if [[ "$source_worktree" != "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then
git -C "$repo_root" worktree remove "$source_worktree" --force >/dev/null 2>&1 || true
fi
if [[ "$source_worktree" != "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then
git -C "$repo_root" worktree remove "$source_worktree" --force >/dev/null 2>&1 || true
fi

git -C "$repo_root" branch -d "$SOURCE_BRANCH"
git -C "$repo_root" branch -d "$SOURCE_BRANCH"

if [[ "$PUSH_ENABLED" -eq 1 && "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then
if git -C "$repo_root" ls-remote --exit-code --heads origin "$SOURCE_BRANCH" >/dev/null 2>&1; then
git -C "$repo_root" push origin --delete "$SOURCE_BRANCH"
if [[ "$PUSH_ENABLED" -eq 1 && "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then
if git -C "$repo_root" ls-remote --exit-code --heads origin "$SOURCE_BRANCH" >/dev/null 2>&1; then
git -C "$repo_root" push origin --delete "$SOURCE_BRANCH"
fi
fi
fi

base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")"
if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then
git -C "$base_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true
fi
if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then
prune_args=(--base "$BASE_BRANCH" --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
fi

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: automatic worktree prune failed." >&2
echo "[agent-branch-finish] You can run manual cleanup: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH}" >&2
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" == "${repo_root}/.omx/agent-worktrees"/* ]]; 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
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
fi
fi

echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and removed branch."
if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; 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}" >&2
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"
fi
Loading
Loading