@imdeadpool/guardex 5.0.4 → 5.0.7

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
@@ -110,7 +110,7 @@ gx sync
110
110
  # continuously monitor open PRs targeting current branch and dispatch codex-agent review/merge tasks
111
111
  bash scripts/review-bot-watch.sh --interval 30
112
112
 
113
- # cleanup merged agent branches/worktrees
113
+ # cleanup merged agent branches and hide clean stale agent worktrees
114
114
  gx cleanup
115
115
 
116
116
  # scan/report
@@ -143,7 +143,8 @@ Note: the monitor dispatches Codex through explicit `--task/--agent/--base` flag
143
143
  - `gx setup` checks GitHub CLI (`gh`) and prints install guidance if missing.
144
144
  - Interactive self-update prompt defaults to **No** (`[y/N]`).
145
145
  - In initialized repos, `setup`/`install`/`fix` block protected-base writes unless explicitly overridden.
146
- - In VS Code Source Control, manual (non-Codex) commits/pushes to protected branches are allowed by default.
146
+ - Direct commits/pushes to protected branches are blocked by default (including VS Code Source Control).
147
+ - Optional repo override for manual VS Code protected-branch writes: `git config multiagent.allowVscodeProtectedBranchWrites true`.
147
148
  - Codex/agent sessions stay blocked on protected branches and must use `agent/*` branch + PR workflow.
148
149
  - On protected `main`, `gx doctor` auto-runs in a sandbox agent branch/worktree.
149
150
  - `scripts/agent-branch-start.sh` hydrates `scripts/codex-agent.sh` into new sandbox worktrees when missing, so auto-finish launcher flow stays available.
@@ -238,6 +239,20 @@ npm pack --dry-run
238
239
 
239
240
  ## Release notes
240
241
 
242
+ ### v5.0.7
243
+
244
+ - Bumped package version from `5.0.6` to `5.0.7` to stay one patch ahead for the next npm publish.
245
+
246
+ ### v5.0.6
247
+
248
+ - `gx cleanup` and auto-finish cleanup now prune clean agent worktrees by default, so VS Code Source Control focuses on your local branch plus worktrees with active changes.
249
+ - Added `gx cleanup --keep-clean-worktrees` to opt out and keep clean worktrees visible.
250
+ - Bumped package version from `5.0.5` to `5.0.6` for the next npm publish.
251
+
252
+ ### v5.0.5
253
+
254
+ - Bumped package version from `5.0.4` to `5.0.5` so npm publish can proceed with the next patch release.
255
+
241
256
  ### v5.0.4
242
257
 
243
258
  - Bumped package version from `5.0.3` to `5.0.4` to stay one patch ahead of the current npm published version.
@@ -79,6 +79,7 @@ const CRITICAL_GUARDRAIL_PATHS = new Set([
79
79
 
80
80
  const LOCK_FILE_RELATIVE = '.omx/state/agent-file-locks.json';
81
81
  const AGENTS_MARKER_START = '<!-- multiagent-safety:START -->';
82
+ const AGENTS_MARKER_END = '<!-- multiagent-safety:END -->';
82
83
  const GITIGNORE_MARKER_START = '# multiagent-safety:START';
83
84
  const GITIGNORE_MARKER_END = '# multiagent-safety:END';
84
85
  const MANAGED_GITIGNORE_PATHS = [
@@ -147,6 +148,10 @@ const CLI_COMMAND_DESCRIPTIONS = [
147
148
  ['help', 'Show this help output'],
148
149
  ['version', 'Print GuardeX version'],
149
150
  ];
151
+ const AGENT_BOT_DESCRIPTIONS = [
152
+ ['review', 'Monitor open PRs targeting current branch and dispatch codex-agent review flow'],
153
+ ['start', 'bash scripts/review-bot-watch.sh --interval 30'],
154
+ ];
150
155
 
151
156
  const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-Rex for your repo) in this repository for Codex or Claude.
152
157
 
@@ -261,9 +266,20 @@ function commandCatalogLines(indent = ' ') {
261
266
  );
262
267
  }
263
268
 
269
+ function agentBotCatalogLines(indent = ' ') {
270
+ const maxCommandLength = AGENT_BOT_DESCRIPTIONS.reduce(
271
+ (max, [command]) => Math.max(max, command.length),
272
+ 0,
273
+ );
274
+ return AGENT_BOT_DESCRIPTIONS.map(
275
+ ([command, description]) => `${indent}${command.padEnd(maxCommandLength + 2)}${description}`,
276
+ );
277
+ }
278
+
264
279
  function printToolLogsSummary() {
265
280
  const usageLine = ` $ ${SHORT_TOOL_NAME} <command> [options]`;
266
281
  const commandDetails = commandCatalogLines(' ');
282
+ const agentBotDetails = agentBotCatalogLines(' ');
267
283
 
268
284
  if (!supportsAnsiColors()) {
269
285
  console.log(`${TOOL_NAME}-tools logs:`);
@@ -273,12 +289,17 @@ function printToolLogsSummary() {
273
289
  for (const line of commandDetails) {
274
290
  console.log(line);
275
291
  }
292
+ console.log(' AGENT BOT');
293
+ for (const line of agentBotDetails) {
294
+ console.log(line);
295
+ }
276
296
  return;
277
297
  }
278
298
 
279
299
  const title = colorize(`${TOOL_NAME}-tools logs`, '1;36');
280
300
  const usageHeader = colorize('USAGE', '1');
281
301
  const commandsHeader = colorize('COMMANDS', '1');
302
+ const agentBotHeader = colorize('AGENT BOT', '1');
282
303
  const pipe = colorize('│', '90');
283
304
  const tee = colorize('├', '90');
284
305
  const corner = colorize('└', '90');
@@ -294,6 +315,14 @@ function printToolLogsSummary() {
294
315
  }
295
316
  console.log(` ${pipe}${line.slice(2)}`);
296
317
  }
318
+ console.log(` ${tee}─ ${agentBotHeader}`);
319
+ for (const line of agentBotDetails) {
320
+ if (!line) {
321
+ console.log(` ${pipe}`);
322
+ continue;
323
+ }
324
+ console.log(` ${pipe}${line.slice(2)}`);
325
+ }
297
326
  console.log(` ${corner}─ ${colorize(`Try '${TOOL_NAME} doctor' for one-step repair + verification.`, '2')}`);
298
327
  }
299
328
 
@@ -311,6 +340,9 @@ USAGE
311
340
  COMMANDS
312
341
  ${commandCatalogLines().join('\n')}
313
342
 
343
+ AGENT BOT
344
+ ${agentBotCatalogLines().join('\n')}
345
+
314
346
  NOTES
315
347
  - Running ${TOOL_NAME} with no command defaults to: ${SHORT_TOOL_NAME} status
316
348
  - Short alias: ${SHORT_TOOL_NAME}
@@ -577,6 +609,10 @@ function ensurePackageScripts(repoRoot, dryRun) {
577
609
  function ensureAgentsSnippet(repoRoot, dryRun) {
578
610
  const agentsPath = path.join(repoRoot, 'AGENTS.md');
579
611
  const snippet = fs.readFileSync(path.join(TEMPLATE_ROOT, 'AGENTS.multiagent-safety.md'), 'utf8').trimEnd();
612
+ const managedRegex = new RegExp(
613
+ `${AGENTS_MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${AGENTS_MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
614
+ 'm',
615
+ );
580
616
 
581
617
  if (!fs.existsSync(agentsPath)) {
582
618
  if (!dryRun) {
@@ -586,8 +622,19 @@ function ensureAgentsSnippet(repoRoot, dryRun) {
586
622
  }
587
623
 
588
624
  const existing = fs.readFileSync(agentsPath, 'utf8');
625
+ if (managedRegex.test(existing)) {
626
+ const next = existing.replace(managedRegex, snippet);
627
+ if (next === existing) {
628
+ return { status: 'unchanged', file: 'AGENTS.md' };
629
+ }
630
+ if (!dryRun) {
631
+ fs.writeFileSync(agentsPath, next, 'utf8');
632
+ }
633
+ return { status: 'updated', file: 'AGENTS.md', note: 'refreshed guardex-managed block' };
634
+ }
635
+
589
636
  if (existing.includes(AGENTS_MARKER_START)) {
590
- return { status: 'unchanged', file: 'AGENTS.md' };
637
+ return { status: 'unchanged', file: 'AGENTS.md', note: 'existing marker found without managed end marker' };
591
638
  }
592
639
 
593
640
  const separator = existing.endsWith('\n') ? '\n' : '\n\n';
@@ -1038,6 +1085,19 @@ function isCommandAvailable(commandName) {
1038
1085
  return run('which', [commandName]).status === 0;
1039
1086
  }
1040
1087
 
1088
+ function extractAgentBranchFinishPrUrl(output) {
1089
+ const match = String(output || '').match(/\[agent-branch-finish\] PR:\s*(\S+)/);
1090
+ return match ? match[1] : '';
1091
+ }
1092
+
1093
+ function doctorFinishFlowIsPending(output) {
1094
+ return (
1095
+ /\[agent-branch-finish\] PR merge not completed yet; leaving PR open\./.test(output) ||
1096
+ /\[agent-branch-finish\] Merge pending review\/check policy\. Branch cleanup skipped for now\./.test(output) ||
1097
+ /\[agent-branch-finish\] PR auto-merge enabled; waiting for required checks\/reviews\./.test(output)
1098
+ );
1099
+ }
1100
+
1041
1101
  function finishDoctorSandboxBranch(blocked, metadata) {
1042
1102
  const finishScript = path.join(metadata.worktreePath, 'scripts', 'agent-branch-finish.sh');
1043
1103
  if (!fs.existsSync(finishScript)) {
@@ -1091,6 +1151,17 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1091
1151
  };
1092
1152
  }
1093
1153
 
1154
+ const combinedOutput = `${finishResult.stdout || ''}\n${finishResult.stderr || ''}`;
1155
+ if (doctorFinishFlowIsPending(combinedOutput)) {
1156
+ return {
1157
+ status: 'pending',
1158
+ note: 'PR created and waiting for merge policy/checks',
1159
+ prUrl: extractAgentBranchFinishPrUrl(combinedOutput),
1160
+ stdout: finishResult.stdout || '',
1161
+ stderr: finishResult.stderr || '',
1162
+ };
1163
+ }
1164
+
1094
1165
  return {
1095
1166
  status: 'completed',
1096
1167
  note: 'doctor sandbox finish flow completed',
@@ -1235,6 +1306,15 @@ function runDoctorInSandbox(options, blocked) {
1235
1306
  console.log(`[${TOOL_NAME}] Auto-finish flow completed for sandbox branch '${metadata.branch}'.`);
1236
1307
  if (finishResult.stdout) process.stdout.write(finishResult.stdout);
1237
1308
  if (finishResult.stderr) process.stderr.write(finishResult.stderr);
1309
+ } else if (finishResult.status === 'pending') {
1310
+ console.log(
1311
+ `[${TOOL_NAME}] Auto-finish pending for sandbox branch '${metadata.branch}': ${finishResult.note}.`,
1312
+ );
1313
+ if (finishResult.prUrl) {
1314
+ console.log(`[${TOOL_NAME}] PR: ${finishResult.prUrl}`);
1315
+ }
1316
+ if (finishResult.stdout) process.stdout.write(finishResult.stdout);
1317
+ if (finishResult.stderr) process.stderr.write(finishResult.stderr);
1238
1318
  } else if (finishResult.status === 'failed') {
1239
1319
  console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
1240
1320
  if (finishResult.stdout) process.stdout.write(finishResult.stdout);
@@ -1499,6 +1579,88 @@ function uniquePreserveOrder(items) {
1499
1579
  return result;
1500
1580
  }
1501
1581
 
1582
+ function readConfiguredProtectedBranches(repoRoot) {
1583
+ const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
1584
+ if (result.status !== 0) {
1585
+ return null;
1586
+ }
1587
+ const parsed = uniquePreserveOrder(parseBranchList(result.stdout.trim()));
1588
+ if (parsed.length === 0) {
1589
+ return null;
1590
+ }
1591
+ return parsed;
1592
+ }
1593
+
1594
+ function listLocalUserBranches(repoRoot) {
1595
+ const result = gitRun(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { allowFailure: true });
1596
+ const branchNames = result.status === 0
1597
+ ? uniquePreserveOrder(
1598
+ String(result.stdout || '')
1599
+ .split('\n')
1600
+ .map((item) => item.trim())
1601
+ .filter(Boolean),
1602
+ )
1603
+ : [];
1604
+
1605
+ const additionalUserBranches = branchNames.filter(
1606
+ (branchName) =>
1607
+ !branchName.startsWith('agent/') &&
1608
+ !DEFAULT_PROTECTED_BRANCHES.includes(branchName),
1609
+ );
1610
+ if (additionalUserBranches.length > 0) {
1611
+ return additionalUserBranches;
1612
+ }
1613
+
1614
+ const current = gitRun(repoRoot, ['branch', '--show-current'], { allowFailure: true });
1615
+ if (current.status !== 0) {
1616
+ return [];
1617
+ }
1618
+
1619
+ const branchName = String(current.stdout || '').trim();
1620
+ if (
1621
+ !branchName ||
1622
+ branchName.startsWith('agent/') ||
1623
+ DEFAULT_PROTECTED_BRANCHES.includes(branchName)
1624
+ ) {
1625
+ return [];
1626
+ }
1627
+
1628
+ return [branchName];
1629
+ }
1630
+
1631
+ function ensureSetupProtectedBranches(repoRoot, dryRun) {
1632
+ const localUserBranches = listLocalUserBranches(repoRoot);
1633
+ if (localUserBranches.length === 0) {
1634
+ return {
1635
+ status: 'unchanged',
1636
+ file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
1637
+ note: 'no additional local user branches detected',
1638
+ };
1639
+ }
1640
+
1641
+ const configured = readConfiguredProtectedBranches(repoRoot);
1642
+ const currentBranches = configured || [...DEFAULT_PROTECTED_BRANCHES];
1643
+ const missingBranches = localUserBranches.filter((branchName) => !currentBranches.includes(branchName));
1644
+ if (missingBranches.length === 0) {
1645
+ return {
1646
+ status: 'unchanged',
1647
+ file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
1648
+ note: 'local user branches already protected',
1649
+ };
1650
+ }
1651
+
1652
+ const nextBranches = uniquePreserveOrder([...currentBranches, ...missingBranches]);
1653
+ if (!dryRun) {
1654
+ writeProtectedBranches(repoRoot, nextBranches);
1655
+ }
1656
+
1657
+ return {
1658
+ status: dryRun ? 'would-update' : 'updated',
1659
+ file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
1660
+ note: `added local user branch(es): ${missingBranches.join(', ')}`,
1661
+ };
1662
+ }
1663
+
1502
1664
  function readProtectedBranches(repoRoot) {
1503
1665
  const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
1504
1666
  if (result.status !== 0) {
@@ -1705,6 +1867,7 @@ function parseCleanupArgs(rawArgs) {
1705
1867
  dryRun: false,
1706
1868
  forceDirty: false,
1707
1869
  keepRemote: false,
1870
+ keepCleanWorktrees: false,
1708
1871
  };
1709
1872
 
1710
1873
  for (let index = 0; index < rawArgs.length; index += 1) {
@@ -1748,6 +1911,10 @@ function parseCleanupArgs(rawArgs) {
1748
1911
  options.keepRemote = true;
1749
1912
  continue;
1750
1913
  }
1914
+ if (arg === '--keep-clean-worktrees') {
1915
+ options.keepCleanWorktrees = true;
1916
+ continue;
1917
+ }
1751
1918
  throw new Error(`Unknown option: ${arg}`);
1752
1919
  }
1753
1920
 
@@ -2768,6 +2935,7 @@ function setup(rawArgs) {
2768
2935
 
2769
2936
  assertProtectedMainWriteAllowed(options, 'setup');
2770
2937
  const installPayload = runInstallInternal(options);
2938
+ installPayload.operations.push(ensureSetupProtectedBranches(installPayload.repoRoot, Boolean(options.dryRun)));
2771
2939
  printOperations('Setup/install', installPayload, options.dryRun);
2772
2940
 
2773
2941
  const fixPayload = runFixInternal({
@@ -2882,6 +3050,9 @@ function cleanup(rawArgs) {
2882
3050
  if (options.dryRun) {
2883
3051
  args.push('--dry-run');
2884
3052
  }
3053
+ if (!options.keepCleanWorktrees) {
3054
+ args.push('--only-dirty-worktrees');
3055
+ }
2885
3056
  args.push('--delete-branches');
2886
3057
  if (!options.keepRemote) {
2887
3058
  args.push('--delete-remote-branches');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imdeadpool/guardex",
3
- "version": "5.0.4",
3
+ "version": "5.0.7",
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,
@@ -28,7 +28,8 @@
28
28
  "agent:safety:setup": "gx setup",
29
29
  "agent:safety:scan": "gx scan",
30
30
  "agent:safety:fix": "gx fix",
31
- "agent:safety:doctor": "gx doctor"
31
+ "agent:safety:doctor": "gx doctor",
32
+ "agent:review:watch": "bash ./scripts/review-bot-watch.sh"
32
33
  },
33
34
  "engines": {
34
35
  "node": ">=18"
@@ -17,7 +17,8 @@
17
17
  - If codex-agent auto-finish cannot complete, immediately run `scripts/agent-branch-finish.sh --branch "<agent-branch>" --via-pr --wait-for-merge` and keep the branch open until checks/review pass.
18
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.
19
19
  - Completion is not valid until these are true: commit exists on the agent branch, branch is pushed to `origin`, and PR/merge status is produced by `agent-branch-finish.sh` or `codex-agent`.
20
- - 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.
20
+ - For every new task, if an assigned agent sub-branch/worktree is already open, continue in that sub-branch; otherwise create a fresh one from the current local base snapshot with `scripts/agent-branch-start.sh`.
21
+ - Never implement directly on the local/base branch checkout; keep it unchanged and perform all edits in the agent sub-branch/worktree.
21
22
  - If the change publishes or bumps a version, the same change must also update release notes/changelog entries.
22
23
 
23
24
  1. Explicit ownership before edits
@@ -28,6 +28,19 @@ if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n
28
28
  is_vscode_git_context=1
29
29
  fi
30
30
 
31
+ allow_vscode_protected_raw="${MUSAFETY_ALLOW_VSCODE_PROTECTED_BRANCH_WRITES:-$(git config --get multiagent.allowVscodeProtectedBranchWrites || true)}"
32
+ if [[ -z "$allow_vscode_protected_raw" ]]; then
33
+ allow_vscode_protected_raw="false"
34
+ fi
35
+ allow_vscode_protected="$(printf '%s' "$allow_vscode_protected_raw" | tr '[:upper:]' '[:lower:]')"
36
+
37
+ allow_vscode_protected_branch_writes=0
38
+ case "$allow_vscode_protected" in
39
+ 1|true|yes|on) allow_vscode_protected_branch_writes=1 ;;
40
+ 0|false|no|off) allow_vscode_protected_branch_writes=0 ;;
41
+ *) allow_vscode_protected_branch_writes=0 ;;
42
+ esac
43
+
31
44
  protected_branches_raw="${MUSAFETY_PROTECTED_BRANCHES:-$(git config --get multiagent.protectedBranches || true)}"
32
45
  if [[ -z "$protected_branches_raw" ]]; then
33
46
  protected_branches_raw="dev main master"
@@ -111,7 +124,7 @@ MSG
111
124
  fi
112
125
 
113
126
  if [[ "$is_protected_branch" == "1" ]]; then
114
- if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" ]]; then
127
+ if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" && "$allow_vscode_protected_branch_writes" == "1" ]]; then
115
128
  exit 0
116
129
  fi
117
130
 
@@ -131,6 +144,9 @@ Use an agent branch first:
131
144
  After finishing work:
132
145
  bash scripts/agent-branch-finish.sh
133
146
 
147
+ Optional repo override for manual VS Code protected-branch commits:
148
+ git config multiagent.allowVscodeProtectedBranchWrites true
149
+
134
150
  Temporary bypass (not recommended):
135
151
  ALLOW_COMMIT_ON_PROTECTED_BRANCH=1 git commit ...
136
152
  MSG
@@ -10,6 +10,19 @@ if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n
10
10
  is_vscode_git_context=1
11
11
  fi
12
12
 
13
+ allow_vscode_protected_raw="${MUSAFETY_ALLOW_VSCODE_PROTECTED_BRANCH_WRITES:-$(git config --get multiagent.allowVscodeProtectedBranchWrites || true)}"
14
+ if [[ -z "$allow_vscode_protected_raw" ]]; then
15
+ allow_vscode_protected_raw="false"
16
+ fi
17
+ allow_vscode_protected="$(printf '%s' "$allow_vscode_protected_raw" | tr '[:upper:]' '[:lower:]')"
18
+
19
+ allow_vscode_protected_branch_writes=0
20
+ case "$allow_vscode_protected" in
21
+ 1|true|yes|on) allow_vscode_protected_branch_writes=1 ;;
22
+ 0|false|no|off) allow_vscode_protected_branch_writes=0 ;;
23
+ *) allow_vscode_protected_branch_writes=0 ;;
24
+ esac
25
+
13
26
  is_codex_session=0
14
27
  if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}" == "1" ]]; then
15
28
  is_codex_session=1
@@ -56,14 +69,16 @@ if [[ "${#blocked_refs[@]}" -gt 0 ]]; then
56
69
  exit 1
57
70
  fi
58
71
 
59
- if [[ "$is_vscode_git_context" == "1" ]]; then
72
+ if [[ "$is_vscode_git_context" == "1" && "$allow_vscode_protected_branch_writes" == "1" ]]; then
60
73
  exit 0
61
74
  fi
62
75
 
63
76
  {
64
- echo "[agent-branch-guard] Push to protected branch blocked outside VS Code Git context."
77
+ echo "[agent-branch-guard] Push to protected branch blocked."
65
78
  echo "[agent-branch-guard] Protected target(s): ${blocked_refs[*]}"
66
- echo "[agent-branch-guard] Use VS Code Source Control for protected-branch push, or push from an agent branch and merge via PR."
79
+ echo "[agent-branch-guard] Use an agent branch and merge via PR."
80
+ echo "[agent-branch-guard] Optional VS Code override:"
81
+ echo " git config multiagent.allowVscodeProtectedBranchWrites true"
67
82
  echo
68
83
  echo "Temporary bypass (not recommended):"
69
84
  echo " ALLOW_PUSH_ON_PROTECTED_BRANCH=1 git push ..."
@@ -545,7 +545,7 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
545
545
  fi
546
546
 
547
547
  if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then
548
- prune_args=(--base "$BASE_BRANCH" --delete-branches)
548
+ prune_args=(--base "$BASE_BRANCH" --only-dirty-worktrees --delete-branches)
549
549
  if [[ "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then
550
550
  prune_args+=(--delete-remote-branches)
551
551
  fi
@@ -7,6 +7,7 @@ DRY_RUN=0
7
7
  FORCE_DIRTY=0
8
8
  DELETE_BRANCHES=0
9
9
  DELETE_REMOTE_BRANCHES=0
10
+ ONLY_DIRTY_WORKTREES=0
10
11
  TARGET_BRANCH=""
11
12
 
12
13
  if [[ -n "$BASE_BRANCH" ]]; then
@@ -36,13 +37,17 @@ while [[ $# -gt 0 ]]; do
36
37
  DELETE_REMOTE_BRANCHES=1
37
38
  shift
38
39
  ;;
40
+ --only-dirty-worktrees)
41
+ ONLY_DIRTY_WORKTREES=1
42
+ shift
43
+ ;;
39
44
  --branch)
40
45
  TARGET_BRANCH="${2:-}"
41
46
  shift 2
42
47
  ;;
43
48
  *)
44
49
  echo "[agent-worktree-prune] Unknown argument: $1" >&2
45
- echo "Usage: $0 [--base <branch>] [--dry-run] [--force-dirty] [--delete-branches] [--delete-remote-branches] [--branch <agent/...>]" >&2
50
+ echo "Usage: $0 [--base <branch>] [--dry-run] [--force-dirty] [--delete-branches] [--delete-remote-branches] [--only-dirty-worktrees] [--branch <agent/...>]" >&2
46
51
  exit 1
47
52
  ;;
48
53
  esac
@@ -56,6 +61,11 @@ fi
56
61
  repo_root="$(git rev-parse --show-toplevel)"
57
62
  current_pwd="$(pwd -P)"
58
63
  worktree_root="${repo_root}/.omx/agent-worktrees"
64
+ repo_common_dir="$(
65
+ git -C "$repo_root" rev-parse --git-common-dir \
66
+ | awk -v root="$repo_root" '{ if ($0 ~ /^\//) { print $0 } else { print root "/" $0 } }'
67
+ )"
68
+ repo_common_dir="$(cd "$repo_common_dir" && pwd -P)"
59
69
 
60
70
  resolve_base_branch() {
61
71
  local configured=""
@@ -127,11 +137,98 @@ is_clean_worktree() {
127
137
  && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]]
128
138
  }
129
139
 
140
+ resolve_worktree_common_dir() {
141
+ local wt="$1"
142
+ local common_dir=""
143
+ common_dir="$(git -C "$wt" rev-parse --git-common-dir 2>/dev/null || true)"
144
+ if [[ -z "$common_dir" ]]; then
145
+ return 1
146
+ fi
147
+ if [[ "$common_dir" == /* ]]; then
148
+ common_dir="$(cd "$common_dir" 2>/dev/null && pwd -P || true)"
149
+ else
150
+ common_dir="$(cd "$wt/$common_dir" 2>/dev/null && pwd -P || true)"
151
+ fi
152
+ if [[ -z "$common_dir" ]]; then
153
+ return 1
154
+ fi
155
+ printf '%s' "$common_dir"
156
+ }
157
+
158
+ select_unique_worktree_path() {
159
+ local root="$1"
160
+ local name="$2"
161
+ local candidate="${root}/${name}"
162
+ local suffix=2
163
+ while [[ -e "$candidate" ]]; do
164
+ candidate="${root}/${name}-${suffix}"
165
+ suffix=$((suffix + 1))
166
+ done
167
+ printf '%s' "$candidate"
168
+ }
169
+
170
+ relocated_foreign=0
171
+ skipped_foreign=0
172
+
173
+ relocate_foreign_worktree_entries() {
174
+ [[ -d "$worktree_root" ]] || return 0
175
+
176
+ local entry=""
177
+ for entry in "${worktree_root}"/*; do
178
+ [[ -d "$entry" ]] || continue
179
+ if ! git -C "$entry" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
180
+ continue
181
+ fi
182
+
183
+ local entry_common_dir=""
184
+ entry_common_dir="$(resolve_worktree_common_dir "$entry" || true)"
185
+ [[ -n "$entry_common_dir" ]] || continue
186
+
187
+ if [[ "$entry_common_dir" == "$repo_common_dir" ]]; then
188
+ continue
189
+ fi
190
+
191
+ if [[ "$(basename "$entry_common_dir")" != ".git" ]]; then
192
+ skipped_foreign=$((skipped_foreign + 1))
193
+ echo "[agent-worktree-prune] Skipping foreign worktree with unsupported git common dir: ${entry}"
194
+ continue
195
+ fi
196
+
197
+ local owner_repo_root
198
+ owner_repo_root="$(dirname "$entry_common_dir")"
199
+ local owner_worktree_root="${owner_repo_root}/.omx/agent-worktrees"
200
+ local target_path
201
+ target_path="$(select_unique_worktree_path "$owner_worktree_root" "$(basename "$entry")")"
202
+
203
+ if [[ "$entry" == "$current_pwd" || "$current_pwd" == "${entry}"/* ]]; then
204
+ skipped_foreign=$((skipped_foreign + 1))
205
+ echo "[agent-worktree-prune] Skipping active foreign worktree: ${entry}"
206
+ continue
207
+ fi
208
+
209
+ echo "[agent-worktree-prune] Relocating foreign worktree to owning repo: ${entry} -> ${target_path}"
210
+ if [[ "$DRY_RUN" -eq 1 ]]; then
211
+ relocated_foreign=$((relocated_foreign + 1))
212
+ continue
213
+ fi
214
+
215
+ mkdir -p "$owner_worktree_root"
216
+ if git -C "$owner_repo_root" worktree move "$entry" "$target_path" >/dev/null 2>&1; then
217
+ relocated_foreign=$((relocated_foreign + 1))
218
+ else
219
+ skipped_foreign=$((skipped_foreign + 1))
220
+ echo "[agent-worktree-prune] Failed to relocate foreign worktree: ${entry}" >&2
221
+ fi
222
+ done
223
+ }
224
+
130
225
  removed_worktrees=0
131
226
  removed_branches=0
132
227
  skipped_active=0
133
228
  skipped_dirty=0
134
229
 
230
+ relocate_foreign_worktree_entries
231
+
135
232
  process_entry() {
136
233
  local wt="$1"
137
234
  local branch_ref="$2"
@@ -165,6 +262,8 @@ process_entry() {
165
262
  if [[ "$DELETE_BRANCHES" -eq 1 ]]; then
166
263
  remove_reason="merged-agent-branch"
167
264
  fi
265
+ elif [[ "$ONLY_DIRTY_WORKTREES" -eq 1 ]] && is_clean_worktree "$wt"; then
266
+ remove_reason="clean-agent-worktree"
168
267
  fi
169
268
  elif [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then
170
269
  remove_reason="temporary-worktree"
@@ -258,6 +357,9 @@ fi
258
357
  run_cmd git -C "$repo_root" worktree prune
259
358
 
260
359
  echo "[agent-worktree-prune] Summary: base=${BASE_BRANCH}, removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}, skipped_dirty=${skipped_dirty}"
360
+ if [[ "$relocated_foreign" -gt 0 || "$skipped_foreign" -gt 0 ]]; then
361
+ echo "[agent-worktree-prune] Foreign routing: relocated=${relocated_foreign}, skipped=${skipped_foreign}"
362
+ fi
261
363
  if [[ "$skipped_active" -gt 0 ]]; then
262
364
  echo "[agent-worktree-prune] Tip: leave active agent worktree directories, then run this command again for full cleanup." >&2
263
365
  fi
@@ -285,13 +285,84 @@ auto_commit_worktree_changes() {
285
285
 
286
286
  local default_message="Auto-finish: ${TASK_NAME}"
287
287
  local commit_message="${MUSAFETY_CODEX_AUTO_COMMIT_MESSAGE:-$default_message}"
288
+ local commit_output=""
288
289
 
289
- if ! git -C "$wt" commit -m "$commit_message" >/dev/null 2>&1; then
290
- echo "[codex-agent] Auto-commit failed in sandbox. Keeping branch for manual review: $branch" >&2
290
+ if commit_output="$(git -C "$wt" commit -m "$commit_message" 2>&1)"; then
291
+ echo "[codex-agent] Auto-committed sandbox changes on '${branch}'."
292
+ return 0
293
+ fi
294
+
295
+ if auto_sync_for_commit_retry "$wt" "$branch"; then
296
+ claim_changed_files "$wt" "$branch"
297
+ git -C "$wt" add -A
298
+ if commit_output="$(git -C "$wt" commit -m "$commit_message" 2>&1)"; then
299
+ echo "[codex-agent] Auto-committed sandbox changes on '${branch}' after sync retry."
300
+ return 0
301
+ fi
302
+ fi
303
+
304
+ echo "[codex-agent] Auto-commit failed in sandbox. Keeping branch for manual review: $branch" >&2
305
+ if [[ -n "$commit_output" ]]; then
306
+ printf '%s\n' "$commit_output" >&2
307
+ fi
308
+ return 1
309
+ }
310
+
311
+ auto_sync_for_commit_retry() {
312
+ local wt="$1"
313
+ local branch="$2"
314
+
315
+ if ! has_origin_remote; then
316
+ return 1
317
+ fi
318
+
319
+ local base_branch
320
+ base_branch="$(resolve_worktree_base_branch "$wt")"
321
+ if [[ -z "$base_branch" ]]; then
291
322
  return 1
292
323
  fi
293
324
 
294
- echo "[codex-agent] Auto-committed sandbox changes on '${branch}'."
325
+ if ! git -C "$wt" fetch origin "$base_branch" --quiet; then
326
+ return 1
327
+ fi
328
+
329
+ if ! git -C "$wt" show-ref --verify --quiet "refs/remotes/origin/${base_branch}"; then
330
+ return 1
331
+ fi
332
+
333
+ local behind_count
334
+ behind_count="$(git -C "$wt" rev-list --left-right --count "HEAD...origin/${base_branch}" 2>/dev/null | awk '{print $2}')"
335
+ behind_count="${behind_count:-0}"
336
+ if [[ "$behind_count" -le 0 ]]; then
337
+ return 1
338
+ fi
339
+
340
+ echo "[codex-agent] Auto-commit retry: '${branch}' is behind origin/${base_branch} by ${behind_count} commit(s). Syncing and retrying..."
341
+
342
+ local stash_ref=""
343
+ local stash_output=""
344
+ if worktree_has_changes "$wt"; then
345
+ if ! stash_output="$(git -C "$wt" stash push --include-untracked -m "codex-agent-autocommit-sync-${branch}-$(date +%s)" 2>&1)"; then
346
+ return 1
347
+ fi
348
+ stash_ref="$(printf '%s\n' "$stash_output" | grep -o 'stash@{[0-9]\+}' | head -n 1 || true)"
349
+ fi
350
+
351
+ if ! git -C "$wt" rebase "origin/${base_branch}" >/dev/null 2>&1; then
352
+ git -C "$wt" rebase --abort >/dev/null 2>&1 || true
353
+ if [[ -n "$stash_ref" ]]; then
354
+ git -C "$wt" stash pop "$stash_ref" >/dev/null 2>&1 || true
355
+ fi
356
+ return 1
357
+ fi
358
+
359
+ if [[ -n "$stash_ref" ]]; then
360
+ if ! git -C "$wt" stash pop "$stash_ref" >/dev/null 2>&1; then
361
+ echo "[codex-agent] Auto-commit retry could not re-apply local changes after sync. Manual resolution required in: $wt" >&2
362
+ return 1
363
+ fi
364
+ fi
365
+
295
366
  return 0
296
367
  }
297
368
 
@@ -419,7 +490,7 @@ if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then
419
490
  prune_args+=(--base "$BASE_BRANCH")
420
491
  fi
421
492
  if [[ "$AUTO_CLEANUP" -eq 1 && "$auto_finish_completed" -eq 1 ]]; then
422
- prune_args+=(--delete-branches --delete-remote-branches)
493
+ prune_args+=(--only-dirty-worktrees --delete-branches --delete-remote-branches)
423
494
  fi
424
495
  if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then
425
496
  echo "[codex-agent] Warning: automatic worktree cleanup failed." >&2