@imdeadpool/guardex 5.0.1 → 5.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -238,6 +238,10 @@ Use this exact checklist to setup multi-agent safety in this repository for Code
238
238
  - For every new user message/task, repeat the same cycle:
239
239
  start isolated agent branch/worktree -> claim file locks -> implement/verify ->
240
240
  finish via PR/merge cleanup with scripts/agent-branch-finish.sh.
241
+ - `scripts/codex-agent.sh` now auto-runs this finish flow after Codex exits:
242
+ auto-commit changed files -> push/create PR -> merge attempt -> keep branch/worktree for follow-up.
243
+ - Remove merged branches when you are done reviewing:
244
+ gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
241
245
 
242
246
  5) Optional: create OpenSpec planning workspace:
243
247
  bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
@@ -269,9 +273,11 @@ gx protect set <branch...> [--target <path>]
269
273
  gx protect reset [--target <path>]
270
274
  gx sync --check [--target <path>] [--base <branch>] [--json]
271
275
  gx sync [--target <path>] [--base <branch>] [--strategy rebase|merge] [--ff-only]
276
+ gx cleanup [--target <path>] [--base <branch>] [--branch <agent/...>] [--dry-run] [--force-dirty] [--keep-remote]
272
277
  gx report scorecard [--target <path>] [--repo github.com/<owner>/<repo>] [--scorecard-json <file>] [--output-dir <path>] [--date YYYY-MM-DD]
273
- bash scripts/agent-worktree-prune.sh # manual stale worktree cleanup (auto base detection)
274
- bash scripts/agent-worktree-prune.sh --force-dirty # remove stale dirty worktrees too
278
+ bash scripts/agent-worktree-prune.sh # prune temporary worktrees only (keeps merged agent branches by default)
279
+ bash scripts/agent-worktree-prune.sh --delete-branches --delete-remote-branches # full merged-branch cleanup
280
+ bash scripts/agent-worktree-prune.sh --force-dirty --delete-branches # force-remove dirty merged worktrees too
275
281
  bash scripts/openspec/init-plan-workspace.sh <plan-slug> # optional OpenSpec plan scaffold
276
282
  ```
277
283
 
@@ -284,8 +290,14 @@ and asks `[y/N]` whether to update immediately (default is `N`).
284
290
  - Interactive setup: prompts for Y/N approval before global OMX/OpenSpec/codex-auth install.
285
291
  - Interactive prompt is strict (`[y/n]`) and waits for explicit answer.
286
292
  - Non-interactive setup: skips global installs by default; use `--yes-global-install` to force.
287
- - In already-initialized repos, `setup` / `install` / `fix` / `doctor` block writes on protected `main` by default; start an agent branch first. Use `--allow-protected-base-write` only for emergency in-place maintenance.
288
- - `scripts/codex-agent.sh` now auto-runs worktree prune after a Codex session; clean sandbox branches are removed automatically, dirty ones are kept.
293
+ - In already-initialized repos, `setup` / `install` / `fix` block writes on protected `main` by default; start an agent branch first. Use `--allow-protected-base-write` only for emergency in-place maintenance.
294
+ - `gx doctor` on protected `main` auto-starts an isolated `agent/gx/...-gx-doctor` worktree branch and applies repairs there.
295
+ - `gx setup` and `gx doctor` always refresh `.githooks/pre-commit` from templates, so Codex sub-branch enforcement stays repaired.
296
+ - `scripts/codex-agent.sh` now auto-runs finish automation after a Codex session when `origin` exists:
297
+ auto-commit changed files, run PR/merge automation, and keep merged agent branches/worktrees by default.
298
+ It also auto-syncs each sandbox branch against the latest base branch before task execution.
299
+ If conflicts remain, it keeps the sandbox and prompts for a conflict-resolution review pass.
300
+ - use `gx cleanup` (or `gx cleanup --branch <agent/...>`) to remove merged branches/worktrees when done.
289
301
 
290
302
  ## Advanced commands
291
303
 
@@ -362,7 +374,7 @@ multiagent.protectedBranches
362
374
  ## What is protected
363
375
 
364
376
  - direct commits to protected branches (defaults: `dev`, `main`, `master`; configurable via `gx protect ...`)
365
- - protected-branch commits are blocked regardless of commit client (including VS Code Source Control)
377
+ - protected-branch commits are blocked by default for all clients; Codex sessions only may commit protected branches when staged files are strictly `AGENTS.md` and/or `.gitignore`
366
378
  - Codex-session commits on non-`agent/*` branches are blocked by default (`multiagent.codexRequireAgentBranch=true`)
367
379
  - Codex commits attempted on protected branches trigger `guardex-preedit-guard` and require starting work via `scripts/codex-agent.sh`
368
380
  - overlapping file ownership between agents
@@ -58,6 +58,8 @@ const CRITICAL_GUARDRAIL_PATHS = new Set([
58
58
  '.githooks/pre-commit',
59
59
  'scripts/agent-branch-start.sh',
60
60
  'scripts/agent-branch-finish.sh',
61
+ 'scripts/agent-worktree-prune.sh',
62
+ 'scripts/codex-agent.sh',
61
63
  'scripts/agent-file-locks.py',
62
64
  ]);
63
65
 
@@ -88,6 +90,7 @@ const COMMAND_TYPO_ALIASES = new Map([
88
90
  ['intsall', 'install'],
89
91
  ['docter', 'doctor'],
90
92
  ['doctro', 'doctor'],
93
+ ['cleunup', 'cleanup'],
91
94
  ['scna', 'scan'],
92
95
  ]);
93
96
  const SUGGESTIBLE_COMMANDS = [
@@ -100,6 +103,7 @@ const SUGGESTIBLE_COMMANDS = [
100
103
  'copy-commands',
101
104
  'protect',
102
105
  'sync',
106
+ 'cleanup',
103
107
  'release',
104
108
  'install',
105
109
  'fix',
@@ -118,6 +122,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
118
122
  ['copy-commands', 'Print setup checklist as executable commands only'],
119
123
  ['protect', 'Manage protected branches (list/add/remove/set/reset)'],
120
124
  ['sync', 'Check or sync agent branches with origin/<base>'],
125
+ ['cleanup', 'Cleanup merged agent branches/worktrees (local + remote)'],
121
126
  ['install', 'Install templates/locks/hooks without running full setup (supports --no-gitignore)'],
122
127
  ['fix', 'Repair broken or missing guardrail files/config (supports --no-gitignore)'],
123
128
  ['scan', 'Report safety issues and exit non-zero on findings'],
@@ -152,6 +157,9 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
152
157
  - For every new user message/task, repeat the same cycle:
153
158
  start isolated agent branch/worktree -> claim file locks -> implement/verify ->
154
159
  finish via PR/merge cleanup with scripts/agent-branch-finish.sh.
160
+ - Finished branches stay available by default for audit/follow-up.
161
+ Remove them explicitly when done:
162
+ gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
155
163
 
156
164
  5) Optional: create OpenSpec planning workspace:
157
165
  bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
@@ -174,6 +182,7 @@ bash scripts/codex-agent.sh "task" "agent-name"
174
182
  bash scripts/agent-branch-start.sh "task" "agent-name"
175
183
  python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
176
184
  bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)"
185
+ gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
177
186
  bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
178
187
  gx protect add release staging
179
188
  gx sync --check
@@ -288,7 +297,10 @@ NOTES
288
297
  - Short alias: ${SHORT_TOOL_NAME}
289
298
  - ${SHORT_TOOL_NAME} init is an alias of ${SHORT_TOOL_NAME} setup
290
299
  - ${TOOL_NAME} setup asks for Y/N approval before global installs
291
- - In initialized repos, setup/install/fix/doctor block in-place writes on protected main by default
300
+ - In initialized repos, setup/install/fix block in-place writes on protected main by default
301
+ - doctor auto-starts a sandbox agent branch/worktree when run on protected main
302
+ - agent-branch-finish merges by default and keeps agent branches/worktrees until explicit cleanup
303
+ - use '${SHORT_TOOL_NAME} cleanup' to remove merged agent branches/worktrees (optionally remote refs too)
292
304
  - Legacy command aliases are still supported: ${LEGACY_NAMES.join(', ')}`);
293
305
 
294
306
  if (outsideGitRepo) {
@@ -362,6 +374,10 @@ function ensureExecutable(destinationPath, relativePath, dryRun) {
362
374
  }
363
375
  }
364
376
 
377
+ function isCriticalGuardrailPath(relativePath) {
378
+ return CRITICAL_GUARDRAIL_PATHS.has(relativePath);
379
+ }
380
+
365
381
  function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
366
382
  const sourcePath = path.join(TEMPLATE_ROOT, relativeTemplatePath);
367
383
  const destinationRelativePath = toDestinationPath(relativeTemplatePath);
@@ -376,7 +392,7 @@ function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
376
392
  ensureExecutable(destinationPath, destinationRelativePath, dryRun);
377
393
  return { status: 'unchanged', file: destinationRelativePath };
378
394
  }
379
- if (!force) {
395
+ if (!force && !isCriticalGuardrailPath(destinationRelativePath)) {
380
396
  throw new Error(
381
397
  `Refusing to overwrite existing file without --force: ${destinationRelativePath}`,
382
398
  );
@@ -389,6 +405,10 @@ function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
389
405
  ensureExecutable(destinationPath, destinationRelativePath, dryRun);
390
406
  }
391
407
 
408
+ if (destinationExists && !force && isCriticalGuardrailPath(destinationRelativePath)) {
409
+ return { status: dryRun ? 'would-repair-critical' : 'repaired-critical', file: destinationRelativePath };
410
+ }
411
+
392
412
  return { status: destinationExists ? 'overwritten' : 'created', file: destinationRelativePath };
393
413
  }
394
414
 
@@ -405,6 +425,14 @@ function ensureTemplateFilePresent(repoRoot, relativeTemplatePath, dryRun) {
405
425
  return { status: 'unchanged', file: destinationRelativePath };
406
426
  }
407
427
 
428
+ if (isCriticalGuardrailPath(destinationRelativePath)) {
429
+ if (!dryRun) {
430
+ fs.writeFileSync(destinationPath, sourceContent, 'utf8');
431
+ ensureExecutable(destinationPath, destinationRelativePath, dryRun);
432
+ }
433
+ return { status: dryRun ? 'would-repair-critical' : 'repaired-critical', file: destinationRelativePath };
434
+ }
435
+
408
436
  // In fix mode, avoid silently replacing local customizations.
409
437
  return { status: 'skipped-conflict', file: destinationRelativePath };
410
438
  }
@@ -489,7 +517,7 @@ function ensurePackageScripts(repoRoot, dryRun) {
489
517
  'agent:codex': 'bash ./scripts/codex-agent.sh',
490
518
  'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
491
519
  'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
492
- 'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh',
520
+ 'agent:cleanup': `${SHORT_TOOL_NAME} cleanup`,
493
521
  'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
494
522
  'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
495
523
  'agent:locks:allow-delete': 'python3 ./scripts/agent-file-locks.py allow-delete',
@@ -671,34 +699,139 @@ function hasGuardexBootstrapFiles(repoRoot) {
671
699
  return required.every((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
672
700
  }
673
701
 
674
- function assertProtectedMainWriteAllowed(options, commandName) {
702
+ function protectedBaseWriteBlock(options) {
675
703
  if (options.dryRun || options.allowProtectedBaseWrite) {
676
- return;
704
+ return null;
677
705
  }
678
706
 
679
707
  const repoRoot = resolveRepoRoot(options.target);
680
708
  if (!hasGuardexBootstrapFiles(repoRoot)) {
681
- return;
709
+ return null;
682
710
  }
683
711
 
684
712
  const branch = currentBranchName(repoRoot);
685
713
  if (branch !== 'main') {
686
- return;
714
+ return null;
687
715
  }
688
716
 
689
717
  const protectedBranches = readProtectedBranches(repoRoot);
690
718
  if (!protectedBranches.includes(branch)) {
719
+ return null;
720
+ }
721
+
722
+ return {
723
+ repoRoot,
724
+ branch,
725
+ };
726
+ }
727
+
728
+ function assertProtectedMainWriteAllowed(options, commandName) {
729
+ const blocked = protectedBaseWriteBlock(options);
730
+ if (!blocked) {
691
731
  return;
692
732
  }
693
733
 
694
734
  throw new Error(
695
- `${commandName} blocked on protected branch '${branch}' in an initialized repo.\n` +
696
- `Keep local '${branch}' pull-only: start an agent branch/worktree first:\n` +
735
+ `${commandName} blocked on protected branch '${blocked.branch}' in an initialized repo.\n` +
736
+ `Keep local '${blocked.branch}' pull-only: start an agent branch/worktree first:\n` +
697
737
  ` bash scripts/agent-branch-start.sh "<task>" "codex"\n` +
698
738
  `Override once only when intentional: --allow-protected-base-write`,
699
739
  );
700
740
  }
701
741
 
742
+ function extractAgentBranchStartMetadata(output) {
743
+ const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m);
744
+ const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m);
745
+ return {
746
+ branch: branchMatch ? branchMatch[1].trim() : '',
747
+ worktreePath: worktreeMatch ? worktreeMatch[1].trim() : '',
748
+ };
749
+ }
750
+
751
+ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
752
+ const resolvedTarget = path.resolve(targetPath);
753
+ const relativeTarget = path.relative(repoRoot, resolvedTarget);
754
+ if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
755
+ throw new Error(`doctor target must stay inside repo root when sandboxing: ${resolvedTarget}`);
756
+ }
757
+ if (!relativeTarget || relativeTarget === '.') {
758
+ return worktreePath;
759
+ }
760
+ return path.join(worktreePath, relativeTarget);
761
+ }
762
+
763
+ function buildSandboxDoctorArgs(options, sandboxTarget) {
764
+ const args = ['doctor', '--target', sandboxTarget];
765
+ if (options.dryRun) args.push('--dry-run');
766
+ if (options.skipAgents) args.push('--skip-agents');
767
+ if (options.skipPackageJson) args.push('--skip-package-json');
768
+ if (options.skipGitignore) args.push('--no-gitignore');
769
+ if (!options.dropStaleLocks) args.push('--keep-stale-locks');
770
+ if (options.json) args.push('--json');
771
+ return args;
772
+ }
773
+
774
+ function runDoctorInSandbox(options, blocked) {
775
+ const startScript = path.join(blocked.repoRoot, 'scripts', 'agent-branch-start.sh');
776
+ if (!fs.existsSync(startScript)) {
777
+ throw new Error(
778
+ `doctor sandbox fallback is unavailable because '${startScript}' is missing.\n` +
779
+ `Run '${SHORT_TOOL_NAME} setup --allow-protected-base-write' once to restore branch-start tooling.`,
780
+ );
781
+ }
782
+
783
+ const startResult = run('bash', [
784
+ startScript,
785
+ '--task',
786
+ `${SHORT_TOOL_NAME}-doctor`,
787
+ '--agent',
788
+ SHORT_TOOL_NAME,
789
+ '--base',
790
+ blocked.branch,
791
+ ], { cwd: blocked.repoRoot });
792
+ if (startResult.error) {
793
+ throw startResult.error;
794
+ }
795
+ if (startResult.status !== 0) {
796
+ throw new Error((startResult.stderr || startResult.stdout || 'failed to start doctor sandbox').trim());
797
+ }
798
+
799
+ const metadata = extractAgentBranchStartMetadata(startResult.stdout);
800
+ if (!metadata.worktreePath) {
801
+ throw new Error(`Failed to parse sandbox worktree from agent-branch-start output:\n${startResult.stdout}`);
802
+ }
803
+
804
+ const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
805
+ const nestedResult = run(
806
+ process.execPath,
807
+ [__filename, ...buildSandboxDoctorArgs(options, sandboxTarget)],
808
+ { cwd: metadata.worktreePath },
809
+ );
810
+ if (nestedResult.error) {
811
+ throw nestedResult.error;
812
+ }
813
+
814
+ if (options.json) {
815
+ if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
816
+ if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
817
+ } else {
818
+ console.log(
819
+ `[${TOOL_NAME}] doctor detected protected branch '${blocked.branch}'. ` +
820
+ `Running repairs in sandbox branch '${metadata.branch || 'agent/<auto>'}'.`,
821
+ );
822
+ if (startResult.stdout) process.stdout.write(startResult.stdout);
823
+ if (startResult.stderr) process.stderr.write(startResult.stderr);
824
+ if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
825
+ if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
826
+ }
827
+
828
+ if (typeof nestedResult.status === 'number') {
829
+ process.exitCode = nestedResult.status;
830
+ return;
831
+ }
832
+ process.exitCode = 1;
833
+ }
834
+
702
835
  function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) {
703
836
  const remaining = [];
704
837
  let target = defaultTarget;
@@ -1134,6 +1267,63 @@ function parseSyncArgs(rawArgs) {
1134
1267
  return options;
1135
1268
  }
1136
1269
 
1270
+ function parseCleanupArgs(rawArgs) {
1271
+ const options = {
1272
+ target: process.cwd(),
1273
+ base: '',
1274
+ branch: '',
1275
+ dryRun: false,
1276
+ forceDirty: false,
1277
+ keepRemote: false,
1278
+ };
1279
+
1280
+ for (let index = 0; index < rawArgs.length; index += 1) {
1281
+ const arg = rawArgs[index];
1282
+ if (arg === '--target') {
1283
+ const next = rawArgs[index + 1];
1284
+ if (!next) {
1285
+ throw new Error('--target requires a path value');
1286
+ }
1287
+ options.target = next;
1288
+ index += 1;
1289
+ continue;
1290
+ }
1291
+ if (arg === '--base') {
1292
+ const next = rawArgs[index + 1];
1293
+ if (!next) {
1294
+ throw new Error('--base requires a branch value');
1295
+ }
1296
+ options.base = next;
1297
+ index += 1;
1298
+ continue;
1299
+ }
1300
+ if (arg === '--branch') {
1301
+ const next = rawArgs[index + 1];
1302
+ if (!next) {
1303
+ throw new Error('--branch requires an agent branch value');
1304
+ }
1305
+ options.branch = next;
1306
+ index += 1;
1307
+ continue;
1308
+ }
1309
+ if (arg === '--dry-run') {
1310
+ options.dryRun = true;
1311
+ continue;
1312
+ }
1313
+ if (arg === '--force-dirty') {
1314
+ options.forceDirty = true;
1315
+ continue;
1316
+ }
1317
+ if (arg === '--keep-remote') {
1318
+ options.keepRemote = true;
1319
+ continue;
1320
+ }
1321
+ throw new Error(`Unknown option: ${arg}`);
1322
+ }
1323
+
1324
+ return options;
1325
+ }
1326
+
1137
1327
  function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
1138
1328
  if (strategy === 'rebase') {
1139
1329
  if (ffOnly) {
@@ -1894,6 +2084,12 @@ function doctor(rawArgs) {
1894
2084
  allowProtectedBaseWrite: false,
1895
2085
  });
1896
2086
 
2087
+ const blocked = protectedBaseWriteBlock(options);
2088
+ if (blocked) {
2089
+ runDoctorInSandbox(options, blocked);
2090
+ return;
2091
+ }
2092
+
1897
2093
  assertProtectedMainWriteAllowed(options, 'doctor');
1898
2094
  const fixPayload = runFixInternal(options);
1899
2095
  const scanResult = runScanInternal({ target: options.target, json: false });
@@ -2156,6 +2352,39 @@ function copyCommands() {
2156
2352
  process.exitCode = 0;
2157
2353
  }
2158
2354
 
2355
+ function cleanup(rawArgs) {
2356
+ const options = parseCleanupArgs(rawArgs);
2357
+ const repoRoot = resolveRepoRoot(options.target);
2358
+ const pruneScript = path.join(repoRoot, 'scripts', 'agent-worktree-prune.sh');
2359
+ if (!fs.existsSync(pruneScript)) {
2360
+ throw new Error(`Missing cleanup script: ${pruneScript}. Run '${SHORT_TOOL_NAME} setup' first.`);
2361
+ }
2362
+
2363
+ const args = [pruneScript];
2364
+ if (options.base) {
2365
+ args.push('--base', options.base);
2366
+ }
2367
+ if (options.branch) {
2368
+ args.push('--branch', options.branch);
2369
+ }
2370
+ if (options.forceDirty) {
2371
+ args.push('--force-dirty');
2372
+ }
2373
+ if (options.dryRun) {
2374
+ args.push('--dry-run');
2375
+ }
2376
+ args.push('--delete-branches');
2377
+ if (!options.keepRemote) {
2378
+ args.push('--delete-remote-branches');
2379
+ }
2380
+
2381
+ const runResult = run('bash', args, { cwd: repoRoot, stdio: 'inherit' });
2382
+ if (runResult.status !== 0) {
2383
+ throw new Error('Cleanup command failed');
2384
+ }
2385
+ process.exitCode = 0;
2386
+ }
2387
+
2159
2388
  function sync(rawArgs) {
2160
2389
  const options = parseSyncArgs(rawArgs);
2161
2390
  const repoRoot = resolveRepoRoot(options.target);
@@ -2504,6 +2733,11 @@ function main() {
2504
2733
  return;
2505
2734
  }
2506
2735
 
2736
+ if (command === 'cleanup') {
2737
+ cleanup(rest);
2738
+ return;
2739
+ }
2740
+
2507
2741
  if (command === 'release') {
2508
2742
  release(rest);
2509
2743
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imdeadpool/guardex",
3
- "version": "5.0.1",
3
+ "version": "5.0.2",
4
4
  "description": "GuardeX: the Guardian T-Rex for your repo, with hardened multi-agent git guardrails.",
5
5
  "license": "MIT",
6
6
  "preferGlobal": true,
@@ -11,7 +11,11 @@
11
11
  - If ownership is unclear or overlaps, stop that edit, post a blocker comment, and let the leader/integrator reassign scope.
12
12
  - For git isolation, each agent must start on a dedicated branch via `scripts/agent-branch-start.sh "<task-or-plan>" "<agent-name>"`.
13
13
  - Treat the base branch (`main` or the user's current local base branch) as read-only while the agent branch is active.
14
- - Agent completion must use `scripts/agent-branch-finish.sh` (direct merge to base when allowed; auto PR fallback for protected bases, then cleanup after merge).
14
+ - Agent completion defaults to `scripts/codex-agent.sh`, which auto-finishes the branch (auto-commit changed files, push/create PR, attempt merge, and pull the local base branch after merge).
15
+ - Auto-finish keeps the sandbox branch/worktree by default so conflict follow-ups and audits stay reproducible.
16
+ - Use explicit cleanup when done: `gx cleanup --branch "<agent-branch>"` (or `gx cleanup` for all merged agent branches).
17
+ - If codex-agent auto-finish cannot complete, immediately run `scripts/agent-branch-finish.sh --branch "<agent-branch>" --via-pr` and keep the branch open until checks/review pass.
18
+ - If merge/rebase conflicts block auto-finish, run a conflict-resolution review pass in that sandbox branch, then rerun `agent-branch-finish.sh --via-pr` until merged.
15
19
  - Per-message loop is mandatory: for every new user message/task, start a fresh agent branch/worktree, claim ownership locks, implement and verify, finish via PR/merge cleanup, then repeat for the next message/task.
16
20
 
17
21
  1. Explicit ownership before edits
@@ -36,4 +36,6 @@ gx scan
36
36
  - Keep agent work isolated (`agent/*` branches + lock claims).
37
37
  - For every new user message/task, restart the full loop on a fresh agent branch/worktree.
38
38
  - For one-command Codex sandbox startup, use `bash scripts/codex-agent.sh "<task>" "<agent-name>"`.
39
+ - `scripts/codex-agent.sh` auto-syncs the sandbox branch against base before each task and auto-finishes merge/PR flow after Codex exits.
40
+ - Auto-finish keeps the branch/worktree by default; remove merged branches explicitly with `gx cleanup` (or `gx cleanup --branch "<agent-branch>"`).
39
41
  - Do not bypass protected branch safeguards unless explicitly required.
@@ -50,9 +50,31 @@ case "$codex_require_agent_branch" in
50
50
  *) should_require_codex_agent_branch=1 ;;
51
51
  esac
52
52
 
53
+ is_codex_managed_only_commit_on_protected=0
54
+ if [[ "$is_codex_session" == "1" && "$is_protected_branch" == "1" ]]; then
55
+ deleted_paths="$(git diff --cached --name-only --diff-filter=D)"
56
+ staged_paths="$(git diff --cached --name-only --diff-filter=ACMRTUXB)"
57
+ if [[ -z "$deleted_paths" && -n "$staged_paths" ]]; then
58
+ managed_only=1
59
+ while IFS= read -r staged_path; do
60
+ case "$staged_path" in
61
+ AGENTS.md|.gitignore) ;;
62
+ *) managed_only=0; break ;;
63
+ esac
64
+ done <<< "$staged_paths"
65
+ if [[ "$managed_only" == "1" ]]; then
66
+ is_codex_managed_only_commit_on_protected=1
67
+ fi
68
+ fi
69
+ fi
70
+
53
71
  if [[ "$should_require_codex_agent_branch" == "1" && "${MUSAFETY_ALLOW_CODEX_ON_NON_AGENT:-0}" != "1" ]]; then
54
72
  if [[ "$is_codex_session" == "1" && "$branch" != agent/* ]]; then
55
73
  if [[ "$is_protected_branch" == "1" ]]; then
74
+ if [[ "$is_codex_managed_only_commit_on_protected" == "1" ]]; then
75
+ exit 0
76
+ fi
77
+
56
78
  cat >&2 <<'MSG'
57
79
  [guardex-preedit-guard] Codex edit/commit detected on a protected branch.
58
80
  GuardeX requires Codex work to run from an isolated agent/* branch.
@@ -5,9 +5,26 @@ BASE_BRANCH=""
5
5
  BASE_BRANCH_EXPLICIT=0
6
6
  SOURCE_BRANCH=""
7
7
  PUSH_ENABLED=1
8
- DELETE_REMOTE_BRANCH=1
8
+ DELETE_REMOTE_BRANCH=0
9
+ DELETE_REMOTE_BRANCH_EXPLICIT=0
9
10
  MERGE_MODE="auto"
10
11
  GH_BIN="${MUSAFETY_GH_BIN:-gh}"
12
+ CLEANUP_AFTER_MERGE_RAW="${MUSAFETY_FINISH_CLEANUP:-false}"
13
+
14
+ normalize_bool() {
15
+ local raw="${1:-}"
16
+ local fallback="${2:-0}"
17
+ local lowered
18
+ lowered="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
19
+ case "$lowered" in
20
+ 1|true|yes|on) printf '1' ;;
21
+ 0|false|no|off) printf '0' ;;
22
+ '') printf '%s' "$fallback" ;;
23
+ *) printf '%s' "$fallback" ;;
24
+ esac
25
+ }
26
+
27
+ CLEANUP_AFTER_MERGE="$(normalize_bool "$CLEANUP_AFTER_MERGE_RAW" "0")"
11
28
 
12
29
  while [[ $# -gt 0 ]]; do
13
30
  case "$1" in
@@ -26,6 +43,20 @@ while [[ $# -gt 0 ]]; do
26
43
  ;;
27
44
  --keep-remote-branch)
28
45
  DELETE_REMOTE_BRANCH=0
46
+ DELETE_REMOTE_BRANCH_EXPLICIT=1
47
+ shift
48
+ ;;
49
+ --delete-remote-branch)
50
+ DELETE_REMOTE_BRANCH=1
51
+ DELETE_REMOTE_BRANCH_EXPLICIT=1
52
+ shift
53
+ ;;
54
+ --cleanup)
55
+ CLEANUP_AFTER_MERGE=1
56
+ shift
57
+ ;;
58
+ --no-cleanup)
59
+ CLEANUP_AFTER_MERGE=0
29
60
  shift
30
61
  ;;
31
62
  --mode)
@@ -42,12 +73,16 @@ while [[ $# -gt 0 ]]; do
42
73
  ;;
43
74
  *)
44
75
  echo "[agent-branch-finish] Unknown argument: $1" >&2
45
- echo "Usage: $0 [--base <branch>] [--branch <branch>] [--no-push] [--keep-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2
76
+ 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
46
77
  exit 1
47
78
  ;;
48
79
  esac
49
80
  done
50
81
 
82
+ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 && "$DELETE_REMOTE_BRANCH_EXPLICIT" -eq 0 ]]; then
83
+ DELETE_REMOTE_BRANCH=1
84
+ fi
85
+
51
86
  case "$MERGE_MODE" in
52
87
  auto|direct|pr) ;;
53
88
  *)
@@ -347,43 +382,58 @@ if [[ -x "${repo_root}/scripts/agent-file-locks.py" ]]; then
347
382
  python3 "${repo_root}/scripts/agent-file-locks.py" release --branch "$SOURCE_BRANCH" >/dev/null 2>&1 || true
348
383
  fi
349
384
 
350
- if [[ "$source_worktree" == "$repo_root" ]]; then
351
- if is_clean_worktree "$source_worktree"; then
352
- git -C "$source_worktree" checkout "$BASE_BRANCH" >/dev/null 2>&1 || true
353
- if [[ "$PUSH_ENABLED" -eq 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
354
- git -C "$source_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true
385
+ base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")"
386
+ if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then
387
+ git -C "$base_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true
388
+ fi
389
+
390
+ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
391
+ if [[ "$source_worktree" == "$repo_root" ]]; then
392
+ if is_clean_worktree "$source_worktree"; then
393
+ git -C "$source_worktree" checkout "$BASE_BRANCH" >/dev/null 2>&1 || true
394
+ if [[ "$PUSH_ENABLED" -eq 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
395
+ git -C "$source_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true
396
+ fi
355
397
  fi
398
+ elif [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then
399
+ git -C "$source_worktree" checkout --detach >/dev/null 2>&1 || true
356
400
  fi
357
- elif [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then
358
- git -C "$source_worktree" checkout --detach >/dev/null 2>&1 || true
359
- fi
360
401
 
361
- if [[ "$source_worktree" != "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then
362
- git -C "$repo_root" worktree remove "$source_worktree" --force >/dev/null 2>&1 || true
363
- fi
402
+ if [[ "$source_worktree" != "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then
403
+ git -C "$repo_root" worktree remove "$source_worktree" --force >/dev/null 2>&1 || true
404
+ fi
364
405
 
365
- git -C "$repo_root" branch -d "$SOURCE_BRANCH"
406
+ git -C "$repo_root" branch -d "$SOURCE_BRANCH"
366
407
 
367
- if [[ "$PUSH_ENABLED" -eq 1 && "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then
368
- if git -C "$repo_root" ls-remote --exit-code --heads origin "$SOURCE_BRANCH" >/dev/null 2>&1; then
369
- git -C "$repo_root" push origin --delete "$SOURCE_BRANCH"
408
+ if [[ "$PUSH_ENABLED" -eq 1 && "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then
409
+ if git -C "$repo_root" ls-remote --exit-code --heads origin "$SOURCE_BRANCH" >/dev/null 2>&1; then
410
+ git -C "$repo_root" push origin --delete "$SOURCE_BRANCH"
411
+ fi
370
412
  fi
371
- fi
372
413
 
373
- base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")"
374
- if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then
375
- git -C "$base_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true
376
- fi
414
+ if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then
415
+ prune_args=(--base "$BASE_BRANCH" --delete-branches)
416
+ if [[ "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then
417
+ prune_args+=(--delete-remote-branches)
418
+ fi
419
+ if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then
420
+ echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2
421
+ echo "[agent-branch-finish] You can run manual cleanup: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches" >&2
422
+ fi
423
+ fi
377
424
 
378
- if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then
379
- if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" --base "$BASE_BRANCH"; then
380
- echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2
381
- echo "[agent-branch-finish] You can run manual cleanup: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH}" >&2
425
+ echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and cleaned source branch/worktree."
426
+ if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then
427
+ echo "[agent-branch-finish] Current worktree '${source_worktree}' still exists because it is the active shell cwd." >&2
428
+ echo "[agent-branch-finish] Leave this directory, then run: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches" >&2
429
+ fi
430
+ else
431
+ if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then
432
+ if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" --base "$BASE_BRANCH"; then
433
+ echo "[agent-branch-finish] Warning: temporary worktree prune failed." >&2
434
+ fi
382
435
  fi
383
- fi
384
436
 
385
- echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and removed branch."
386
- if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then
387
- echo "[agent-branch-finish] Current worktree '${source_worktree}' still exists because it is the active shell cwd." >&2
388
- echo "[agent-branch-finish] Leave this directory, then run: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH}" >&2
437
+ echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and kept source branch/worktree."
438
+ echo "[agent-branch-finish] Cleanup later with: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches --delete-remote-branches"
389
439
  fi
@@ -5,6 +5,9 @@ BASE_BRANCH="${MUSAFETY_BASE_BRANCH:-}"
5
5
  BASE_BRANCH_EXPLICIT=0
6
6
  DRY_RUN=0
7
7
  FORCE_DIRTY=0
8
+ DELETE_BRANCHES=0
9
+ DELETE_REMOTE_BRANCHES=0
10
+ TARGET_BRANCH=""
8
11
 
9
12
  if [[ -n "$BASE_BRANCH" ]]; then
10
13
  BASE_BRANCH_EXPLICIT=1
@@ -25,9 +28,21 @@ while [[ $# -gt 0 ]]; do
25
28
  FORCE_DIRTY=1
26
29
  shift
27
30
  ;;
31
+ --delete-branches)
32
+ DELETE_BRANCHES=1
33
+ shift
34
+ ;;
35
+ --delete-remote-branches)
36
+ DELETE_REMOTE_BRANCHES=1
37
+ shift
38
+ ;;
39
+ --branch)
40
+ TARGET_BRANCH="${2:-}"
41
+ shift 2
42
+ ;;
28
43
  *)
29
44
  echo "[agent-worktree-prune] Unknown argument: $1" >&2
30
- echo "Usage: $0 [--base <branch>] [--dry-run] [--force-dirty]" >&2
45
+ echo "Usage: $0 [--base <branch>] [--dry-run] [--force-dirty] [--delete-branches] [--delete-remote-branches] [--branch <agent/...>]" >&2
31
46
  exit 1
32
47
  ;;
33
48
  esac
@@ -73,6 +88,11 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
73
88
  exit 1
74
89
  fi
75
90
 
91
+ if [[ -n "$TARGET_BRANCH" && "$TARGET_BRANCH" != agent/* ]]; then
92
+ echo "[agent-worktree-prune] --branch must reference an agent/* branch: ${TARGET_BRANCH}" >&2
93
+ exit 1
94
+ fi
95
+
76
96
  if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
77
97
  BASE_BRANCH="$(resolve_base_branch)"
78
98
  fi
@@ -124,6 +144,10 @@ process_entry() {
124
144
  branch="${branch_ref#refs/heads/}"
125
145
  fi
126
146
 
147
+ if [[ -n "$TARGET_BRANCH" && "$branch" != "$TARGET_BRANCH" ]]; then
148
+ return
149
+ fi
150
+
127
151
  if [[ "$wt" == "$current_pwd" ]]; then
128
152
  skipped_active=$((skipped_active + 1))
129
153
  echo "[agent-worktree-prune] Skipping active cwd worktree: ${wt}"
@@ -138,7 +162,9 @@ process_entry() {
138
162
  remove_reason="missing-branch"
139
163
  elif [[ "$branch" == agent/* ]]; then
140
164
  if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then
141
- remove_reason="merged-agent-branch"
165
+ if [[ "$DELETE_BRANCHES" -eq 1 ]]; then
166
+ remove_reason="merged-agent-branch"
167
+ fi
142
168
  fi
143
169
  elif [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then
144
170
  remove_reason="temporary-worktree"
@@ -163,10 +189,16 @@ process_entry() {
163
189
  fi
164
190
 
165
191
  if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}" && ! branch_has_worktree "$branch"; then
166
- if [[ "$branch" == agent/* ]]; then
192
+ if [[ "$branch" == agent/* && "$DELETE_BRANCHES" -eq 1 ]]; then
167
193
  if run_cmd git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then
168
194
  removed_branches=$((removed_branches + 1))
169
195
  echo "[agent-worktree-prune] Deleted merged branch: ${branch}"
196
+ if [[ "$DELETE_REMOTE_BRANCHES" -eq 1 ]]; then
197
+ if git -C "$repo_root" ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
198
+ run_cmd git -C "$repo_root" push origin --delete "$branch" >/dev/null 2>&1 || true
199
+ echo "[agent-worktree-prune] Deleted merged remote branch: ${branch}"
200
+ fi
201
+ fi
170
202
  fi
171
203
  elif [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then
172
204
  run_cmd git -C "$repo_root" branch -D "$branch" >/dev/null 2>&1 || true
@@ -199,18 +231,29 @@ done < <(git -C "$repo_root" worktree list --porcelain)
199
231
 
200
232
  process_entry "$current_wt" "$current_branch_ref"
201
233
 
202
- while IFS= read -r branch; do
203
- [[ -z "$branch" ]] && continue
204
- if branch_has_worktree "$branch"; then
205
- continue
206
- fi
207
- if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then
208
- if run_cmd git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then
209
- removed_branches=$((removed_branches + 1))
210
- echo "[agent-worktree-prune] Deleted stale merged branch: ${branch}"
234
+ if [[ "$DELETE_BRANCHES" -eq 1 ]]; then
235
+ while IFS= read -r branch; do
236
+ [[ -z "$branch" ]] && continue
237
+ if [[ -n "$TARGET_BRANCH" && "$branch" != "$TARGET_BRANCH" ]]; then
238
+ continue
211
239
  fi
212
- fi
213
- done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads/agent)
240
+ if branch_has_worktree "$branch"; then
241
+ continue
242
+ fi
243
+ if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then
244
+ if run_cmd git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then
245
+ removed_branches=$((removed_branches + 1))
246
+ echo "[agent-worktree-prune] Deleted stale merged branch: ${branch}"
247
+ if [[ "$DELETE_REMOTE_BRANCHES" -eq 1 ]]; then
248
+ if git -C "$repo_root" ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
249
+ run_cmd git -C "$repo_root" push origin --delete "$branch" >/dev/null 2>&1 || true
250
+ echo "[agent-worktree-prune] Deleted stale merged remote branch: ${branch}"
251
+ fi
252
+ fi
253
+ fi
254
+ fi
255
+ done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads/agent)
256
+ fi
214
257
 
215
258
  run_cmd git -C "$repo_root" worktree prune
216
259
 
@@ -6,6 +6,26 @@ AGENT_NAME="${MUSAFETY_AGENT_NAME:-agent}"
6
6
  BASE_BRANCH="${MUSAFETY_BASE_BRANCH:-}"
7
7
  BASE_BRANCH_EXPLICIT=0
8
8
  CODEX_BIN="${MUSAFETY_CODEX_BIN:-codex}"
9
+ AUTO_FINISH_RAW="${MUSAFETY_CODEX_AUTO_FINISH:-true}"
10
+ AUTO_REVIEW_ON_CONFLICT_RAW="${MUSAFETY_CODEX_AUTO_REVIEW_ON_CONFLICT:-true}"
11
+ AUTO_CLEANUP_RAW="${MUSAFETY_CODEX_AUTO_CLEANUP:-false}"
12
+
13
+ normalize_bool() {
14
+ local raw="${1:-}"
15
+ local fallback="${2:-0}"
16
+ local lowered
17
+ lowered="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
18
+ case "$lowered" in
19
+ 1|true|yes|on) printf '1' ;;
20
+ 0|false|no|off) printf '0' ;;
21
+ '') printf '%s' "$fallback" ;;
22
+ *) printf '%s' "$fallback" ;;
23
+ esac
24
+ }
25
+
26
+ AUTO_FINISH="$(normalize_bool "$AUTO_FINISH_RAW" "1")"
27
+ AUTO_REVIEW_ON_CONFLICT="$(normalize_bool "$AUTO_REVIEW_ON_CONFLICT_RAW" "1")"
28
+ AUTO_CLEANUP="$(normalize_bool "$AUTO_CLEANUP_RAW" "0")"
9
29
 
10
30
  if [[ -n "$BASE_BRANCH" ]]; then
11
31
  BASE_BRANCH_EXPLICIT=1
@@ -30,6 +50,30 @@ while [[ $# -gt 0 ]]; do
30
50
  CODEX_BIN="${2:-$CODEX_BIN}"
31
51
  shift 2
32
52
  ;;
53
+ --auto-finish)
54
+ AUTO_FINISH=1
55
+ shift
56
+ ;;
57
+ --no-auto-finish)
58
+ AUTO_FINISH=0
59
+ shift
60
+ ;;
61
+ --auto-review-on-conflict)
62
+ AUTO_REVIEW_ON_CONFLICT=1
63
+ shift
64
+ ;;
65
+ --no-auto-review-on-conflict)
66
+ AUTO_REVIEW_ON_CONFLICT=0
67
+ shift
68
+ ;;
69
+ --cleanup)
70
+ AUTO_CLEANUP=1
71
+ shift
72
+ ;;
73
+ --no-cleanup)
74
+ AUTO_CLEANUP=0
75
+ shift
76
+ ;;
33
77
  --)
34
78
  shift
35
79
  break
@@ -95,6 +139,221 @@ if [[ ! -d "$worktree_path" ]]; then
95
139
  exit 1
96
140
  fi
97
141
 
142
+ has_origin_remote() {
143
+ git -C "$repo_root" remote get-url origin >/dev/null 2>&1
144
+ }
145
+
146
+ resolve_worktree_base_branch() {
147
+ local wt="$1"
148
+ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -n "$BASE_BRANCH" ]]; then
149
+ printf '%s' "$BASE_BRANCH"
150
+ return 0
151
+ fi
152
+
153
+ local branch
154
+ branch="$(git -C "$wt" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
155
+ if [[ -z "$branch" || "$branch" == "HEAD" ]]; then
156
+ return 0
157
+ fi
158
+
159
+ local stored_base
160
+ stored_base="$(git -C "$repo_root" config --get "branch.${branch}.musafetyBase" || true)"
161
+ if [[ -n "$stored_base" ]]; then
162
+ printf '%s' "$stored_base"
163
+ return 0
164
+ fi
165
+
166
+ local configured_base
167
+ configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
168
+ if [[ -n "$configured_base" ]]; then
169
+ printf '%s' "$configured_base"
170
+ fi
171
+ }
172
+
173
+ sync_worktree_with_base() {
174
+ local wt="$1"
175
+ if ! has_origin_remote; then
176
+ return 0
177
+ fi
178
+
179
+ local base_branch
180
+ base_branch="$(resolve_worktree_base_branch "$wt")"
181
+ if [[ -z "$base_branch" ]]; then
182
+ return 0
183
+ fi
184
+
185
+ if ! git -C "$wt" fetch origin "$base_branch" --quiet; then
186
+ echo "[codex-agent] Warning: could not fetch origin/${base_branch} before task start." >&2
187
+ return 0
188
+ fi
189
+
190
+ if ! git -C "$wt" show-ref --verify --quiet "refs/remotes/origin/${base_branch}"; then
191
+ return 0
192
+ fi
193
+
194
+ local behind_count
195
+ behind_count="$(git -C "$wt" rev-list --left-right --count "HEAD...origin/${base_branch}" 2>/dev/null | awk '{print $2}')"
196
+ behind_count="${behind_count:-0}"
197
+ if [[ "$behind_count" -le 0 ]]; then
198
+ return 0
199
+ fi
200
+
201
+ local branch
202
+ branch="$(git -C "$wt" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
203
+ echo "[codex-agent] Task sync: '${branch}' is behind origin/${base_branch} by ${behind_count} commit(s). Rebasing before launch..."
204
+ if ! git -C "$wt" rebase "origin/${base_branch}"; then
205
+ echo "[codex-agent] Task sync failed. Resolve and continue in sandbox:" >&2
206
+ echo " git -C \"$wt\" rebase --continue" >&2
207
+ echo " # or abort" >&2
208
+ echo " git -C \"$wt\" rebase --abort" >&2
209
+ return 1
210
+ fi
211
+ echo "[codex-agent] Task sync complete."
212
+ return 0
213
+ }
214
+
215
+ worktree_has_changes() {
216
+ local wt="$1"
217
+ if ! git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json"; then
218
+ return 0
219
+ fi
220
+ if ! git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json"; then
221
+ return 0
222
+ fi
223
+ if [[ -n "$(git -C "$wt" ls-files --others --exclude-standard)" ]]; then
224
+ return 0
225
+ fi
226
+ return 1
227
+ }
228
+
229
+ claim_changed_files() {
230
+ local wt="$1"
231
+ local branch="$2"
232
+ local lock_script="${repo_root}/scripts/agent-file-locks.py"
233
+
234
+ if [[ ! -x "$lock_script" ]]; then
235
+ return 0
236
+ fi
237
+
238
+ local changed_raw deleted_raw
239
+ changed_raw="$({
240
+ git -C "$wt" diff --name-only -- . ":(exclude).omx/state/agent-file-locks.json";
241
+ git -C "$wt" diff --cached --name-only -- . ":(exclude).omx/state/agent-file-locks.json";
242
+ git -C "$wt" ls-files --others --exclude-standard;
243
+ } | sed '/^$/d' | sort -u)"
244
+
245
+ if [[ -n "$changed_raw" ]]; then
246
+ mapfile -t changed_files < <(printf '%s\n' "$changed_raw")
247
+ python3 "$lock_script" claim --branch "$branch" "${changed_files[@]}" >/dev/null 2>&1 || true
248
+ fi
249
+
250
+ deleted_raw="$({
251
+ git -C "$wt" diff --name-only --diff-filter=D -- . ":(exclude).omx/state/agent-file-locks.json";
252
+ git -C "$wt" diff --cached --name-only --diff-filter=D -- . ":(exclude).omx/state/agent-file-locks.json";
253
+ } | sed '/^$/d' | sort -u)"
254
+
255
+ if [[ -n "$deleted_raw" ]]; then
256
+ mapfile -t deleted_files < <(printf '%s\n' "$deleted_raw")
257
+ python3 "$lock_script" allow-delete --branch "$branch" "${deleted_files[@]}" >/dev/null 2>&1 || true
258
+ fi
259
+ }
260
+
261
+ auto_commit_worktree_changes() {
262
+ local wt="$1"
263
+ local branch="$2"
264
+
265
+ if ! worktree_has_changes "$wt"; then
266
+ return 0
267
+ fi
268
+
269
+ claim_changed_files "$wt" "$branch"
270
+ git -C "$wt" add -A
271
+
272
+ if git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json"; then
273
+ return 0
274
+ fi
275
+
276
+ local default_message="Auto-finish: ${TASK_NAME}"
277
+ local commit_message="${MUSAFETY_CODEX_AUTO_COMMIT_MESSAGE:-$default_message}"
278
+
279
+ if ! git -C "$wt" commit -m "$commit_message" >/dev/null 2>&1; then
280
+ echo "[codex-agent] Auto-commit failed in sandbox. Keeping branch for manual review: $branch" >&2
281
+ return 1
282
+ fi
283
+
284
+ echo "[codex-agent] Auto-committed sandbox changes on '${branch}'."
285
+ return 0
286
+ }
287
+
288
+ looks_like_conflict_failure() {
289
+ local output="$1"
290
+ if grep -qiE 'preflight conflict detected|merge conflict detected|auto-sync failed while rebasing|rebase --continue|rebase --abort' <<< "$output"; then
291
+ return 0
292
+ fi
293
+ return 1
294
+ }
295
+
296
+ run_finish_flow() {
297
+ local wt="$1"
298
+ local branch="$2"
299
+ local finish_output=""
300
+ local -a finish_args
301
+
302
+ finish_args=(--branch "$branch")
303
+ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then
304
+ finish_args+=(--base "$BASE_BRANCH")
305
+ fi
306
+ if [[ "$AUTO_CLEANUP" -eq 1 ]]; then
307
+ finish_args+=(--cleanup)
308
+ fi
309
+
310
+ if has_origin_remote; then
311
+ if command -v gh >/dev/null 2>&1 || command -v "${MUSAFETY_GH_BIN:-gh}" >/dev/null 2>&1; then
312
+ finish_args+=(--via-pr)
313
+ fi
314
+ else
315
+ echo "[codex-agent] No origin remote detected; skipping auto-finish merge/PR pipeline." >&2
316
+ return 2
317
+ fi
318
+
319
+ if finish_output="$(bash "${repo_root}/scripts/agent-branch-finish.sh" "${finish_args[@]}" 2>&1)"; then
320
+ printf '%s\n' "$finish_output"
321
+ return 0
322
+ fi
323
+
324
+ printf '%s\n' "$finish_output" >&2
325
+
326
+ if [[ "$AUTO_REVIEW_ON_CONFLICT" -eq 1 ]] && looks_like_conflict_failure "$finish_output"; then
327
+ echo "[codex-agent] Auto-finish hit conflicts. Launching Codex conflict-review pass in sandbox..." >&2
328
+ local review_prompt
329
+ review_prompt="Resolve git conflicts for branch ${branch} against ${BASE_BRANCH:-base branch}, then commit the resolution in this sandbox worktree and exit."
330
+
331
+ (
332
+ cd "$wt"
333
+ set +e
334
+ "$CODEX_BIN" "$review_prompt"
335
+ review_exit="$?"
336
+ set -e
337
+ if [[ "$review_exit" -ne 0 ]]; then
338
+ echo "[codex-agent] Conflict-review Codex pass exited with status ${review_exit}." >&2
339
+ fi
340
+ )
341
+
342
+ if finish_output="$(bash "${repo_root}/scripts/agent-branch-finish.sh" "${finish_args[@]}" 2>&1)"; then
343
+ printf '%s\n' "$finish_output"
344
+ return 0
345
+ fi
346
+
347
+ printf '%s\n' "$finish_output" >&2
348
+ fi
349
+
350
+ return 1
351
+ }
352
+
353
+ if ! sync_worktree_with_base "$worktree_path"; then
354
+ exit 1
355
+ fi
356
+
98
357
  echo "[codex-agent] Launching ${CODEX_BIN} in sandbox: $worktree_path"
99
358
  cd "$worktree_path"
100
359
  set +e
@@ -103,6 +362,38 @@ codex_exit="$?"
103
362
  set -e
104
363
 
105
364
  cd "$repo_root"
365
+ final_exit="$codex_exit"
366
+ auto_finish_completed=0
367
+
368
+ worktree_branch="$(git -C "$worktree_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
369
+
370
+ if [[ "$AUTO_FINISH" -eq 1 && -n "$worktree_branch" && "$worktree_branch" != "HEAD" ]]; then
371
+ if [[ "$AUTO_CLEANUP" -eq 1 ]]; then
372
+ echo "[codex-agent] Auto-finish enabled: commit -> push/PR -> merge -> cleanup."
373
+ else
374
+ echo "[codex-agent] Auto-finish enabled: commit -> push/PR -> merge (keep branch/worktree)."
375
+ fi
376
+ if auto_commit_worktree_changes "$worktree_path" "$worktree_branch"; then
377
+ if run_finish_flow "$worktree_path" "$worktree_branch"; then
378
+ auto_finish_completed=1
379
+ echo "[codex-agent] Auto-finish completed for '${worktree_branch}'."
380
+ else
381
+ finish_status="$?"
382
+ if [[ "$finish_status" -eq 2 ]]; then
383
+ echo "[codex-agent] Auto-finish skipped for '${worktree_branch}' (no mergeable remote context)." >&2
384
+ else
385
+ echo "[codex-agent] Auto-finish did not complete; keeping sandbox for manual review: $worktree_path" >&2
386
+ if [[ "$final_exit" -eq 0 ]]; then
387
+ final_exit=1
388
+ fi
389
+ fi
390
+ fi
391
+ else
392
+ if [[ "$final_exit" -eq 0 ]]; then
393
+ final_exit=1
394
+ fi
395
+ fi
396
+ fi
106
397
 
107
398
  if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then
108
399
  echo "[codex-agent] Session ended (exit=${codex_exit}). Running worktree cleanup..."
@@ -110,6 +401,9 @@ if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then
110
401
  if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then
111
402
  prune_args+=(--base "$BASE_BRANCH")
112
403
  fi
404
+ if [[ "$AUTO_CLEANUP" -eq 1 ]]; then
405
+ prune_args+=(--delete-branches --delete-remote-branches)
406
+ fi
113
407
  if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then
114
408
  echo "[codex-agent] Warning: automatic worktree cleanup failed." >&2
115
409
  fi
@@ -121,8 +415,13 @@ else
121
415
  worktree_branch="$(git -C "$worktree_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
122
416
  echo "[codex-agent] Sandbox worktree kept: $worktree_path"
123
417
  if [[ -n "$worktree_branch" && "$worktree_branch" != "HEAD" ]]; then
124
- echo "[codex-agent] If finished, merge + clean with: bash scripts/agent-branch-finish.sh --branch \"${worktree_branch}\""
418
+ if [[ "$auto_finish_completed" -eq 1 ]]; then
419
+ echo "[codex-agent] Branch kept intentionally. Cleanup on demand: gx cleanup --branch \"${worktree_branch}\""
420
+ else
421
+ echo "[codex-agent] If finished, merge with: bash scripts/agent-branch-finish.sh --branch \"${worktree_branch}\" --via-pr"
422
+ echo "[codex-agent] Cleanup on demand: gx cleanup --branch \"${worktree_branch}\""
423
+ fi
125
424
  fi
126
425
  fi
127
426
 
128
- exit "$codex_exit"
427
+ exit "$final_exit"