@imdeadpool/guardex 7.0.14 → 7.0.16

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.
@@ -64,7 +64,7 @@ const REQUIRED_SYSTEM_TOOLS = [
64
64
  },
65
65
  ];
66
66
  const MAINTAINER_RELEASE_REPO = path.resolve(
67
- process.env.GUARDEX_RELEASE_REPO || '/tmp/multiagent-safety',
67
+ process.env.GUARDEX_RELEASE_REPO || path.resolve(__dirname, '..'),
68
68
  );
69
69
  const NPM_BIN = process.env.GUARDEX_NPM_BIN || 'npm';
70
70
  const OPENSPEC_BIN = process.env.GUARDEX_OPENSPEC_BIN || 'openspec';
@@ -77,13 +77,21 @@ const DEFAULT_PROTECTED_BRANCHES = ['dev', 'main', 'master'];
77
77
  const DEFAULT_BASE_BRANCH = 'dev';
78
78
  const DEFAULT_SYNC_STRATEGY = 'rebase';
79
79
  const DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES = 60;
80
+ const COMPOSE_HINT_FILES = [
81
+ 'docker-compose.yml',
82
+ 'docker-compose.yaml',
83
+ 'compose.yml',
84
+ 'compose.yaml',
85
+ ];
80
86
 
81
87
  const TEMPLATE_ROOT = path.resolve(__dirname, '..', 'templates');
82
88
 
83
89
  const TEMPLATE_FILES = [
84
90
  'scripts/agent-branch-start.sh',
85
91
  'scripts/agent-branch-finish.sh',
92
+ 'scripts/agent-branch-merge.sh',
86
93
  'scripts/codex-agent.sh',
94
+ 'scripts/guardex-docker-loader.sh',
87
95
  'scripts/review-bot-watch.sh',
88
96
  'scripts/agent-worktree-prune.sh',
89
97
  'scripts/agent-file-locks.py',
@@ -105,6 +113,8 @@ const TEMPLATE_FILES = [
105
113
  const REQUIRED_WORKFLOW_FILES = [
106
114
  'scripts/agent-branch-start.sh',
107
115
  'scripts/agent-branch-finish.sh',
116
+ 'scripts/agent-branch-merge.sh',
117
+ 'scripts/guardex-docker-loader.sh',
108
118
  'scripts/agent-worktree-prune.sh',
109
119
  'scripts/agent-file-locks.py',
110
120
  'scripts/guardex-env.sh',
@@ -118,6 +128,7 @@ const REQUIRED_PACKAGE_SCRIPTS = {
118
128
  'agent:codex': 'bash ./scripts/codex-agent.sh',
119
129
  'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
120
130
  'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
131
+ 'agent:branch:merge': 'bash ./scripts/agent-branch-merge.sh',
121
132
  'agent:cleanup': 'gx cleanup',
122
133
  'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
123
134
  'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
@@ -133,6 +144,7 @@ const REQUIRED_PACKAGE_SCRIPTS = {
133
144
  'agent:safety:scan': 'gx status --strict',
134
145
  'agent:safety:fix': 'gx setup --repair',
135
146
  'agent:safety:doctor': 'gx doctor',
147
+ 'agent:docker:load': 'bash ./scripts/guardex-docker-loader.sh',
136
148
  'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
137
149
  'agent:finish': 'gx finish --all',
138
150
  };
@@ -140,7 +152,9 @@ const REQUIRED_PACKAGE_SCRIPTS = {
140
152
  const EXECUTABLE_RELATIVE_PATHS = new Set([
141
153
  'scripts/agent-branch-start.sh',
142
154
  'scripts/agent-branch-finish.sh',
155
+ 'scripts/agent-branch-merge.sh',
143
156
  'scripts/codex-agent.sh',
157
+ 'scripts/guardex-docker-loader.sh',
144
158
  'scripts/review-bot-watch.sh',
145
159
  'scripts/agent-worktree-prune.sh',
146
160
  'scripts/agent-file-locks.py',
@@ -161,6 +175,7 @@ const CRITICAL_GUARDRAIL_PATHS = new Set([
161
175
  '.githooks/post-checkout',
162
176
  'scripts/agent-branch-start.sh',
163
177
  'scripts/agent-branch-finish.sh',
178
+ 'scripts/agent-branch-merge.sh',
164
179
  'scripts/agent-worktree-prune.sh',
165
180
  'scripts/codex-agent.sh',
166
181
  'scripts/agent-file-locks.py',
@@ -173,10 +188,18 @@ const AGENTS_MARKER_START = '<!-- multiagent-safety:START -->';
173
188
  const AGENTS_MARKER_END = '<!-- multiagent-safety:END -->';
174
189
  const GITIGNORE_MARKER_START = '# multiagent-safety:START';
175
190
  const GITIGNORE_MARKER_END = '# multiagent-safety:END';
191
+ const CODEX_WORKTREE_RELATIVE_DIR = path.join('.omx', 'agent-worktrees');
192
+ const CLAUDE_WORKTREE_RELATIVE_DIR = path.join('.omc', 'agent-worktrees');
193
+ const AGENT_WORKTREE_RELATIVE_DIRS = [
194
+ CODEX_WORKTREE_RELATIVE_DIR,
195
+ CLAUDE_WORKTREE_RELATIVE_DIR,
196
+ ];
176
197
  const MANAGED_GITIGNORE_PATHS = [
177
198
  '.omx/',
178
199
  '.omc/',
179
200
  'scripts/*',
201
+ 'scripts/agent-branch-start.sh',
202
+ 'scripts/agent-file-locks.py',
180
203
  '.githooks',
181
204
  'oh-my-codex/',
182
205
  '.codex/skills/gitguardex/SKILL.md',
@@ -190,7 +213,9 @@ const OMX_SCAFFOLD_DIRECTORIES = [
190
213
  '.omx/state',
191
214
  '.omx/logs',
192
215
  '.omx/plans',
193
- '.omx/agent-worktrees',
216
+ CODEX_WORKTREE_RELATIVE_DIR,
217
+ '.omc',
218
+ CLAUDE_WORKTREE_RELATIVE_DIR,
194
219
  ];
195
220
  const OMX_SCAFFOLD_FILES = new Map([
196
221
  ['.omx/notepad.md', '\n\n## WORKING MEMORY\n'],
@@ -213,6 +238,7 @@ const SUGGESTIBLE_COMMANDS = [
213
238
  'setup',
214
239
  'doctor',
215
240
  'agents',
241
+ 'merge',
216
242
  'finish',
217
243
  'report',
218
244
  'protect',
@@ -237,9 +263,11 @@ const CLI_COMMAND_DESCRIPTIONS = [
237
263
  ['setup', 'Install, repair, and verify guardrails (flags: --repair, --install-only, --target)'],
238
264
  ['doctor', 'Repair drift + verify (auto-sandboxes on protected main)'],
239
265
  ['protect', 'Manage protected branches (list/add/remove/set/reset)'],
266
+ ['merge', 'Create/reuse an integration lane and merge overlapping agent branches'],
240
267
  ['sync', 'Sync agent branches with origin/<base>'],
241
268
  ['finish', 'Commit + PR + merge completed agent branches (--all, --branch)'],
242
269
  ['cleanup', 'Prune merged/stale agent branches and worktrees'],
270
+ ['release', 'Create or update the current GitHub release with README-generated notes'],
243
271
  ['agents', 'Start/stop repo-scoped review + cleanup bots'],
244
272
  ['prompt', 'Print AI setup checklist (--exec, --snippet)'],
245
273
  ['report', 'Security/safety reports (e.g. OpenSSF scorecard)'],
@@ -259,6 +287,22 @@ const DEPRECATED_COMMAND_ALIASES = new Map([
259
287
  const AGENT_BOT_DESCRIPTIONS = [
260
288
  ['agents', 'Start/stop review + cleanup bots for this repo'],
261
289
  ];
290
+ const DOCTOR_AUTO_FINISH_DETAIL_LIMIT = 6;
291
+ const DOCTOR_AUTO_FINISH_BRANCH_LABEL_MAX = 72;
292
+ const DOCTOR_AUTO_FINISH_MESSAGE_MAX = 160;
293
+
294
+ function envFlagIsTruthy(raw) {
295
+ const lowered = String(raw || '').trim().toLowerCase();
296
+ return lowered === '1' || lowered === 'true' || lowered === 'yes' || lowered === 'on';
297
+ }
298
+
299
+ function isClaudeCodeSession(env = process.env) {
300
+ return envFlagIsTruthy(env.CLAUDECODE) || Boolean(env.CLAUDE_CODE_SESSION_ID);
301
+ }
302
+
303
+ function defaultAgentWorktreeRelativeDir(env = process.env) {
304
+ return isClaudeCodeSession(env) ? CLAUDE_WORKTREE_RELATIVE_DIR : CODEX_WORKTREE_RELATIVE_DIR;
305
+ }
262
306
 
263
307
  const AI_SETUP_PROMPT = `GitGuardex (gx) setup checklist for Codex/Claude in this repo.
264
308
 
@@ -267,13 +311,14 @@ const AI_SETUP_PROMPT = `GitGuardex (gx) setup checklist for Codex/Claude in thi
267
311
  3) Repair: gx doctor
268
312
  4) Task loop: bash scripts/codex-agent.sh "<task>" "<agent>"
269
313
  or branch-start -> python3 scripts/agent-file-locks.py claim -> branch-finish
270
- 5) Finish: gx finish --all
271
- 6) Cleanup: gx cleanup
272
- 7) OpenSpec: /opsx:propose -> /opsx:apply -> /opsx:archive
273
- 8) Optional: gx protect add release staging
274
- 9) Optional: gx sync --check && gx sync
275
- 10) Review bot: install https://github.com/apps/cr-gpt + set OPENAI_API_KEY
276
- 11) Fork sync: install https://github.com/apps/pull + cp .github/pull.yml.example .github/pull.yml
314
+ 5) Integrate: gx merge --branch <agent-a> --branch <agent-b>
315
+ 6) Finish: gx finish --all
316
+ 7) Cleanup: gx cleanup
317
+ 8) OpenSpec: /opsx:propose -> /opsx:apply -> /opsx:archive
318
+ 9) Optional: gx protect add release staging
319
+ 10) Optional: gx sync --check && gx sync
320
+ 11) Review bot: install https://github.com/apps/cr-gpt + set OPENAI_API_KEY
321
+ 12) Fork sync: install https://github.com/apps/pull + cp .github/pull.yml.example .github/pull.yml
277
322
  `;
278
323
 
279
324
  const AI_SETUP_COMMANDS = `npm i -g @imdeadpool/guardex
@@ -282,6 +327,7 @@ gx setup
282
327
  gx doctor
283
328
  bash scripts/codex-agent.sh "<task>" "<agent>"
284
329
  python3 scripts/agent-file-locks.py claim --branch "<agent-branch>" <file...>
330
+ gx merge --branch "<agent-a>" --branch "<agent-b>"
285
331
  gx finish --all
286
332
  gx cleanup
287
333
  gx protect add release staging
@@ -470,6 +516,113 @@ function run(cmd, args, options = {}) {
470
516
  });
471
517
  }
472
518
 
519
+ function formatElapsedDuration(ms) {
520
+ const durationMs = Number.isFinite(ms) ? Math.max(0, ms) : 0;
521
+ if (durationMs < 1000) {
522
+ return `${Math.round(durationMs)}ms`;
523
+ }
524
+ if (durationMs < 10_000) {
525
+ return `${(durationMs / 1000).toFixed(1)}s`;
526
+ }
527
+ return `${Math.round(durationMs / 1000)}s`;
528
+ }
529
+
530
+ function truncateMiddle(value, maxLength) {
531
+ const text = String(value || '');
532
+ const limit = Number.isFinite(maxLength) ? Math.max(4, maxLength) : 0;
533
+ if (!limit || text.length <= limit) {
534
+ return text;
535
+ }
536
+
537
+ const visible = limit - 1;
538
+ const headLength = Math.ceil(visible / 2);
539
+ const tailLength = Math.floor(visible / 2);
540
+ return `${text.slice(0, headLength)}…${text.slice(text.length - tailLength)}`;
541
+ }
542
+
543
+ function truncateTail(value, maxLength) {
544
+ const text = String(value || '');
545
+ const limit = Number.isFinite(maxLength) ? Math.max(4, maxLength) : 0;
546
+ if (!limit || text.length <= limit) {
547
+ return text;
548
+ }
549
+ return `${text.slice(0, limit - 1)}…`;
550
+ }
551
+
552
+ function compactAutoFinishPathSegments(message) {
553
+ return String(message || '').replace(/\((\/[^)]+)\)/g, (_, rawPath) => {
554
+ if (
555
+ rawPath.includes(`${path.sep}.omx${path.sep}agent-worktrees${path.sep}`) ||
556
+ rawPath.includes(`${path.sep}.omc${path.sep}agent-worktrees${path.sep}`)
557
+ ) {
558
+ return `(${path.basename(rawPath)})`;
559
+ }
560
+ return `(${truncateMiddle(rawPath, 72)})`;
561
+ });
562
+ }
563
+
564
+ function summarizeAutoFinishDetail(detail) {
565
+ const trimmed = String(detail || '').trim();
566
+ const match = trimmed.match(/^\[(\w+)\]\s+([^:]+):\s*(.*)$/);
567
+ if (!match) {
568
+ return truncateTail(compactAutoFinishPathSegments(trimmed), DOCTOR_AUTO_FINISH_MESSAGE_MAX);
569
+ }
570
+
571
+ const [, status, rawBranch, rawMessage] = match;
572
+ const branch = truncateMiddle(rawBranch, DOCTOR_AUTO_FINISH_BRANCH_LABEL_MAX);
573
+ let message = String(rawMessage || '').trim();
574
+
575
+ if (status === 'fail') {
576
+ message = message.replace(/^auto-finish failed\.?\s*/i, '');
577
+ if (/\[agent-sync-guard\]/.test(message) && /Resolve conflicts/i.test(message)) {
578
+ message = 'rebase conflict in finish flow; run rebase --continue or rebase --abort in the source-probe worktree';
579
+ } else if (/unable to compute ahead\/behind/i.test(message)) {
580
+ const aheadBehindMatch = message.match(/unable to compute ahead\/behind(?: \([^)]+\))?/i);
581
+ if (aheadBehindMatch) {
582
+ message = aheadBehindMatch[0];
583
+ }
584
+ } else if (/remote ref does not exist/i.test(message)) {
585
+ message = 'branch merged, but the remote ref was already removed during cleanup';
586
+ }
587
+ }
588
+
589
+ message = compactAutoFinishPathSegments(message)
590
+ .replace(/\s+\|\s+/g, '; ')
591
+ .trim();
592
+
593
+ return `[${status}] ${branch}: ${truncateTail(message, DOCTOR_AUTO_FINISH_MESSAGE_MAX)}`;
594
+ }
595
+
596
+ function printAutoFinishSummary(summary, options = {}) {
597
+ const enabled = Boolean(summary && summary.enabled);
598
+ const details = Array.isArray(summary && summary.details) ? summary.details : [];
599
+ const baseBranch = String(options.baseBranch || summary?.baseBranch || '').trim();
600
+ const verbose = Boolean(options.verbose);
601
+ const detailLimit = Number.isFinite(options.detailLimit)
602
+ ? Math.max(0, options.detailLimit)
603
+ : DOCTOR_AUTO_FINISH_DETAIL_LIMIT;
604
+
605
+ if (enabled) {
606
+ console.log(
607
+ `[${TOOL_NAME}] Auto-finish sweep (base=${baseBranch}): attempted=${summary.attempted}, completed=${summary.completed}, skipped=${summary.skipped}, failed=${summary.failed}`,
608
+ );
609
+ const visibleDetails = verbose ? details : details.slice(0, detailLimit).map(summarizeAutoFinishDetail);
610
+ for (const detail of visibleDetails) {
611
+ console.log(`[${TOOL_NAME}] ${detail}`);
612
+ }
613
+ if (!verbose && details.length > detailLimit) {
614
+ console.log(
615
+ `[${TOOL_NAME}] … ${details.length - detailLimit} more branch result(s). Re-run with --verbose-auto-finish for full details.`,
616
+ );
617
+ }
618
+ return;
619
+ }
620
+
621
+ if (details.length > 0) {
622
+ console.log(`[${TOOL_NAME}] ${verbose ? details[0] : summarizeAutoFinishDetail(details[0])}`);
623
+ }
624
+ }
625
+
473
626
  function gitRun(repoRoot, args, { allowFailure = false } = {}) {
474
627
  const result = run('git', ['-C', repoRoot, ...args]);
475
628
  if (!allowFailure && result.status !== 0) {
@@ -509,8 +662,6 @@ const NESTED_REPO_DEFAULT_SKIP_DIRS = new Set([
509
662
  '.venv',
510
663
  '.pnpm-store',
511
664
  ]);
512
- const NESTED_REPO_WORKTREE_RELATIVE_DIR = path.join('.omx', 'agent-worktrees');
513
-
514
665
  function discoverNestedGitRepos(rootPath, opts = {}) {
515
666
  const maxDepth = Number.isFinite(opts.maxDepth) ? Math.max(1, opts.maxDepth) : NESTED_REPO_DEFAULT_MAX_DEPTH;
516
667
  const extraSkip = new Set(Array.isArray(opts.extraSkip) ? opts.extraSkip : []);
@@ -525,7 +676,7 @@ function discoverNestedGitRepos(rootPath, opts = {}) {
525
676
  return path.resolve(resolvedRoot, raw);
526
677
  })();
527
678
 
528
- const workreeSkipAbsolute = path.join(resolvedRoot, NESTED_REPO_WORKTREE_RELATIVE_DIR);
679
+ const worktreeSkipAbsolutes = AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => path.join(resolvedRoot, relativeDir));
529
680
  const found = new Set();
530
681
  found.add(resolvedRoot);
531
682
 
@@ -557,7 +708,7 @@ function discoverNestedGitRepos(rootPath, opts = {}) {
557
708
 
558
709
  if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
559
710
  if (shouldSkipDir(entry.name)) continue;
560
- if (entryPath === workreeSkipAbsolute) continue;
711
+ if (worktreeSkipAbsolutes.includes(entryPath)) continue;
561
712
  walk(entryPath, depth + 1);
562
713
  }
563
714
  }
@@ -1000,6 +1151,14 @@ function parseCommonArgs(rawArgs, defaults) {
1000
1151
  options.allowProtectedBaseWrite = true;
1001
1152
  continue;
1002
1153
  }
1154
+ if (Object.prototype.hasOwnProperty.call(options, 'waitForMerge') && arg === '--wait-for-merge') {
1155
+ options.waitForMerge = true;
1156
+ continue;
1157
+ }
1158
+ if (Object.prototype.hasOwnProperty.call(options, 'waitForMerge') && arg === '--no-wait-for-merge') {
1159
+ options.waitForMerge = false;
1160
+ continue;
1161
+ }
1003
1162
 
1004
1163
  throw new Error(`Unknown option: ${arg}`);
1005
1164
  }
@@ -1081,7 +1240,7 @@ function parseSetupArgs(rawArgs, defaults) {
1081
1240
  }
1082
1241
 
1083
1242
  function parseDoctorArgs(rawArgs) {
1084
- return parseRepoTraversalArgs(rawArgs, {
1243
+ const doctorDefaults = {
1085
1244
  target: process.cwd(),
1086
1245
  dropStaleLocks: true,
1087
1246
  skipAgents: false,
@@ -1090,7 +1249,25 @@ function parseDoctorArgs(rawArgs) {
1090
1249
  dryRun: false,
1091
1250
  json: false,
1092
1251
  allowProtectedBaseWrite: false,
1093
- });
1252
+ waitForMerge: true,
1253
+ verboseAutoFinish: false,
1254
+ };
1255
+ const forwardedArgs = [];
1256
+
1257
+ for (let index = 0; index < rawArgs.length; index += 1) {
1258
+ const arg = rawArgs[index];
1259
+ if (arg === '--verbose-auto-finish') {
1260
+ doctorDefaults.verboseAutoFinish = true;
1261
+ continue;
1262
+ }
1263
+ if (arg === '--compact-auto-finish') {
1264
+ doctorDefaults.verboseAutoFinish = false;
1265
+ continue;
1266
+ }
1267
+ forwardedArgs.push(arg);
1268
+ }
1269
+
1270
+ return parseRepoTraversalArgs(forwardedArgs, doctorDefaults);
1094
1271
  }
1095
1272
 
1096
1273
  function normalizeWorkspacePath(relativePath) {
@@ -1102,16 +1279,15 @@ function buildParentWorkspaceView(repoRoot) {
1102
1279
  const workspaceFileName = `${path.basename(repoRoot)}-branches.code-workspace`;
1103
1280
  const workspacePath = path.join(parentDir, workspaceFileName);
1104
1281
  const repoRelativePath = normalizeWorkspacePath(path.relative(parentDir, repoRoot) || '.');
1105
- const worktreesRelativePath = normalizeWorkspacePath(
1106
- path.join(repoRelativePath === '.' ? '' : repoRelativePath, '.omx', 'agent-worktrees'),
1107
- );
1108
1282
 
1109
1283
  return {
1110
1284
  workspacePath,
1111
1285
  payload: {
1112
1286
  folders: [
1113
1287
  { path: repoRelativePath },
1114
- { path: worktreesRelativePath },
1288
+ ...AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => ({
1289
+ path: normalizeWorkspacePath(path.join(repoRelativePath === '.' ? '' : repoRelativePath, relativeDir)),
1290
+ })),
1115
1291
  ],
1116
1292
  settings: {
1117
1293
  'scm.alwaysShowRepositories': true,
@@ -1195,6 +1371,40 @@ function assertProtectedMainWriteAllowed(options, commandName) {
1195
1371
  );
1196
1372
  }
1197
1373
 
1374
+ function runSetupBootstrapInternal(options) {
1375
+ const installPayload = runInstallInternal(options);
1376
+ installPayload.operations.push(
1377
+ ensureSetupProtectedBranches(installPayload.repoRoot, Boolean(options.dryRun)),
1378
+ );
1379
+
1380
+ let parentWorkspace = null;
1381
+ if (options.parentWorkspaceView) {
1382
+ installPayload.operations.push(
1383
+ ensureParentWorkspaceView(installPayload.repoRoot, Boolean(options.dryRun)),
1384
+ );
1385
+ if (!options.dryRun) {
1386
+ parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot);
1387
+ }
1388
+ }
1389
+
1390
+ const fixPayload = runFixInternal({
1391
+ target: installPayload.repoRoot,
1392
+ dryRun: options.dryRun,
1393
+ force: options.force,
1394
+ dropStaleLocks: true,
1395
+ skipAgents: options.skipAgents,
1396
+ skipPackageJson: options.skipPackageJson,
1397
+ skipGitignore: options.skipGitignore,
1398
+ allowProtectedBaseWrite: options.allowProtectedBaseWrite,
1399
+ });
1400
+
1401
+ return {
1402
+ installPayload,
1403
+ fixPayload,
1404
+ parentWorkspace,
1405
+ };
1406
+ }
1407
+
1198
1408
  function extractAgentBranchStartMetadata(output) {
1199
1409
  const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m);
1200
1410
  const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m);
@@ -1208,7 +1418,7 @@ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
1208
1418
  const resolvedTarget = path.resolve(targetPath);
1209
1419
  const relativeTarget = path.relative(repoRoot, resolvedTarget);
1210
1420
  if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
1211
- throw new Error(`doctor target must stay inside repo root when sandboxing: ${resolvedTarget}`);
1421
+ throw new Error(`sandbox target must stay inside repo root: ${resolvedTarget}`);
1212
1422
  }
1213
1423
  if (!relativeTarget || relativeTarget === '.') {
1214
1424
  return worktreePath;
@@ -1216,6 +1426,16 @@ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
1216
1426
  return path.join(worktreePath, relativeTarget);
1217
1427
  }
1218
1428
 
1429
+ function buildSandboxSetupArgs(options, sandboxTarget) {
1430
+ const args = ['setup', '--target', sandboxTarget, '--no-global-install', '--no-recursive'];
1431
+ if (options.force) args.push('--force');
1432
+ if (options.skipAgents) args.push('--skip-agents');
1433
+ if (options.skipPackageJson) args.push('--skip-package-json');
1434
+ if (options.skipGitignore) args.push('--no-gitignore');
1435
+ if (options.dryRun) args.push('--dry-run');
1436
+ return args;
1437
+ }
1438
+
1219
1439
  function buildSandboxDoctorArgs(options, sandboxTarget) {
1220
1440
  const args = ['doctor', '--target', sandboxTarget];
1221
1441
  if (options.dryRun) args.push('--dry-run');
@@ -1224,6 +1444,8 @@ function buildSandboxDoctorArgs(options, sandboxTarget) {
1224
1444
  if (options.skipPackageJson) args.push('--skip-package-json');
1225
1445
  if (options.skipGitignore) args.push('--no-gitignore');
1226
1446
  if (!options.dropStaleLocks) args.push('--keep-stale-locks');
1447
+ args.push(options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge');
1448
+ if (options.verboseAutoFinish) args.push('--verbose-auto-finish');
1227
1449
  if (options.json) args.push('--json');
1228
1450
  return args;
1229
1451
  }
@@ -1259,7 +1481,7 @@ function ensureRepoBranch(repoRoot, branch) {
1259
1481
  return { ok: true, changed: true };
1260
1482
  }
1261
1483
 
1262
- function doctorSandboxBranchPrefix() {
1484
+ function protectedBaseSandboxBranchPrefix() {
1263
1485
  const now = new Date();
1264
1486
  const stamp = [
1265
1487
  now.getUTCFullYear(),
@@ -1273,15 +1495,15 @@ function doctorSandboxBranchPrefix() {
1273
1495
  return `agent/gx/${stamp}`;
1274
1496
  }
1275
1497
 
1276
- function doctorSandboxWorktreePath(repoRoot, branchName) {
1277
- return path.join(repoRoot, '.omx', 'agent-worktrees', branchName.replace(/\//g, '__'));
1498
+ function protectedBaseSandboxWorktreePath(repoRoot, branchName) {
1499
+ return path.join(repoRoot, defaultAgentWorktreeRelativeDir(), branchName.replace(/\//g, '__'));
1278
1500
  }
1279
1501
 
1280
1502
  function gitRefExists(repoRoot, ref) {
1281
1503
  return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0;
1282
1504
  }
1283
1505
 
1284
- function resolveDoctorSandboxStartRef(repoRoot, baseBranch) {
1506
+ function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) {
1285
1507
  run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 });
1286
1508
  if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) {
1287
1509
  return `origin/${baseBranch}`;
@@ -1289,18 +1511,21 @@ function resolveDoctorSandboxStartRef(repoRoot, baseBranch) {
1289
1511
  if (gitRefExists(repoRoot, `refs/heads/${baseBranch}`)) {
1290
1512
  return baseBranch;
1291
1513
  }
1292
- throw new Error(`Unable to find base ref for sandbox doctor: ${baseBranch}`);
1514
+ if (currentBranchName(repoRoot) === baseBranch) {
1515
+ return null;
1516
+ }
1517
+ throw new Error(`Unable to find base ref for sandbox bootstrap: ${baseBranch}`);
1293
1518
  }
1294
1519
 
1295
- function startDoctorSandboxFallback(blocked) {
1296
- const branchPrefix = doctorSandboxBranchPrefix();
1520
+ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
1521
+ const branchPrefix = protectedBaseSandboxBranchPrefix();
1297
1522
  let selectedBranch = '';
1298
1523
  let selectedWorktreePath = '';
1299
1524
 
1300
1525
  for (let attempt = 0; attempt < 30; attempt += 1) {
1301
- const suffix = attempt === 0 ? 'gx-doctor' : `${attempt + 1}-gx-doctor`;
1526
+ const suffix = attempt === 0 ? sandboxSuffix : `${attempt + 1}-${sandboxSuffix}`;
1302
1527
  const candidateBranch = `${branchPrefix}-${suffix}`;
1303
- const candidateWorktreePath = doctorSandboxWorktreePath(blocked.repoRoot, candidateBranch);
1528
+ const candidateWorktreePath = protectedBaseSandboxWorktreePath(blocked.repoRoot, candidateBranch);
1304
1529
  if (gitRefExists(blocked.repoRoot, `refs/heads/${candidateBranch}`)) {
1305
1530
  continue;
1306
1531
  }
@@ -1313,20 +1538,36 @@ function startDoctorSandboxFallback(blocked) {
1313
1538
  }
1314
1539
 
1315
1540
  if (!selectedBranch || !selectedWorktreePath) {
1316
- throw new Error('Unable to allocate unique sandbox branch/worktree for doctor');
1541
+ throw new Error('Unable to allocate unique sandbox branch/worktree');
1317
1542
  }
1318
1543
 
1319
1544
  fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true });
1320
- const startRef = resolveDoctorSandboxStartRef(blocked.repoRoot, blocked.branch);
1321
- const addResult = run(
1322
- 'git',
1323
- ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef],
1324
- );
1545
+ const startRef = resolveProtectedBaseSandboxStartRef(blocked.repoRoot, blocked.branch);
1546
+ const addArgs = startRef
1547
+ ? ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef]
1548
+ : ['-C', blocked.repoRoot, 'worktree', 'add', '--orphan', selectedWorktreePath];
1549
+ const addResult = run('git', addArgs);
1325
1550
  if (isSpawnFailure(addResult)) {
1326
1551
  throw addResult.error;
1327
1552
  }
1328
1553
  if (addResult.status !== 0) {
1329
- throw new Error((addResult.stderr || addResult.stdout || 'failed to create doctor sandbox').trim());
1554
+ throw new Error((addResult.stderr || addResult.stdout || 'failed to create sandbox').trim());
1555
+ }
1556
+
1557
+ if (!startRef) {
1558
+ const renameResult = run(
1559
+ 'git',
1560
+ ['-C', selectedWorktreePath, 'branch', '-m', selectedBranch],
1561
+ { timeout: 20_000 },
1562
+ );
1563
+ if (isSpawnFailure(renameResult)) {
1564
+ throw renameResult.error;
1565
+ }
1566
+ if (renameResult.status !== 0) {
1567
+ throw new Error(
1568
+ (renameResult.stderr || renameResult.stdout || 'failed to name orphan sandbox branch').trim(),
1569
+ );
1570
+ }
1330
1571
  }
1331
1572
 
1332
1573
  return {
@@ -1341,16 +1582,20 @@ function startDoctorSandboxFallback(blocked) {
1341
1582
  };
1342
1583
  }
1343
1584
 
1344
- function startDoctorSandbox(blocked) {
1585
+ function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) {
1586
+ if (sandboxSuffix === 'gx-doctor') {
1587
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
1588
+ }
1589
+
1345
1590
  const startScript = path.join(blocked.repoRoot, 'scripts', 'agent-branch-start.sh');
1346
1591
  if (!fs.existsSync(startScript)) {
1347
- return startDoctorSandboxFallback(blocked);
1592
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
1348
1593
  }
1349
1594
 
1350
1595
  const startResult = run('bash', [
1351
1596
  startScript,
1352
1597
  '--task',
1353
- `${SHORT_TOOL_NAME}-doctor`,
1598
+ taskName,
1354
1599
  '--agent',
1355
1600
  SHORT_TOOL_NAME,
1356
1601
  '--base',
@@ -1360,7 +1605,7 @@ function startDoctorSandbox(blocked) {
1360
1605
  throw startResult.error;
1361
1606
  }
1362
1607
  if (startResult.status !== 0) {
1363
- return startDoctorSandboxFallback(blocked);
1608
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
1364
1609
  }
1365
1610
 
1366
1611
  const metadata = extractAgentBranchStartMetadata(startResult.stdout);
@@ -1375,11 +1620,11 @@ function startDoctorSandbox(blocked) {
1375
1620
  if (!restoreResult.ok) {
1376
1621
  const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim();
1377
1622
  throw new Error(
1378
- `doctor sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
1623
+ `sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
1379
1624
  (detail ? `\n${detail}` : ''),
1380
1625
  );
1381
1626
  }
1382
- return startDoctorSandboxFallback(blocked);
1627
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
1383
1628
  }
1384
1629
 
1385
1630
  return {
@@ -1389,6 +1634,59 @@ function startDoctorSandbox(blocked) {
1389
1634
  };
1390
1635
  }
1391
1636
 
1637
+ function cleanupProtectedBaseSandbox(repoRoot, metadata) {
1638
+ const result = {
1639
+ worktree: 'skipped',
1640
+ branch: 'skipped',
1641
+ note: 'missing sandbox metadata',
1642
+ };
1643
+
1644
+ if (!metadata?.worktreePath || !metadata?.branch) {
1645
+ return result;
1646
+ }
1647
+
1648
+ if (fs.existsSync(metadata.worktreePath)) {
1649
+ const removeResult = run(
1650
+ 'git',
1651
+ ['-C', repoRoot, 'worktree', 'remove', '--force', metadata.worktreePath],
1652
+ { timeout: 30_000 },
1653
+ );
1654
+ if (isSpawnFailure(removeResult)) {
1655
+ throw removeResult.error;
1656
+ }
1657
+ if (removeResult.status !== 0) {
1658
+ throw new Error(
1659
+ (removeResult.stderr || removeResult.stdout || 'failed to remove sandbox worktree').trim(),
1660
+ );
1661
+ }
1662
+ result.worktree = 'removed';
1663
+ } else {
1664
+ result.worktree = 'missing';
1665
+ }
1666
+
1667
+ if (gitRefExists(repoRoot, `refs/heads/${metadata.branch}`)) {
1668
+ const branchDeleteResult = run(
1669
+ 'git',
1670
+ ['-C', repoRoot, 'branch', '-D', metadata.branch],
1671
+ { timeout: 20_000 },
1672
+ );
1673
+ if (isSpawnFailure(branchDeleteResult)) {
1674
+ throw branchDeleteResult.error;
1675
+ }
1676
+ if (branchDeleteResult.status !== 0) {
1677
+ throw new Error(
1678
+ (branchDeleteResult.stderr || branchDeleteResult.stdout || 'failed to delete sandbox branch').trim(),
1679
+ );
1680
+ }
1681
+ result.branch = 'deleted';
1682
+ } else {
1683
+ result.branch = 'missing';
1684
+ }
1685
+
1686
+ result.note = 'sandbox worktree pruned';
1687
+ return result;
1688
+ }
1689
+
1392
1690
  function parseGitPathList(output) {
1393
1691
  return String(output || '')
1394
1692
  .split('\n')
@@ -1427,6 +1725,59 @@ function collectDoctorDeletedPaths(worktreePath) {
1427
1725
  return Array.from(deleted);
1428
1726
  }
1429
1727
 
1728
+ function collectWorktreeDirtyPaths(worktreePath) {
1729
+ const dirty = new Set();
1730
+ const commands = [
1731
+ ['diff', '--name-only'],
1732
+ ['diff', '--cached', '--name-only'],
1733
+ ['ls-files', '--others', '--exclude-standard'],
1734
+ ];
1735
+ for (const gitArgs of commands) {
1736
+ const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
1737
+ for (const filePath of parseGitPathList(result.stdout)) {
1738
+ dirty.add(filePath);
1739
+ }
1740
+ }
1741
+ return Array.from(dirty);
1742
+ }
1743
+
1744
+ function collectDoctorForceAddPaths(worktreePath) {
1745
+ return TEMPLATE_FILES
1746
+ .map((entry) => toDestinationPath(entry))
1747
+ .filter((relativePath) => relativePath.startsWith('scripts/') || relativePath.startsWith('.githooks/'))
1748
+ .filter((relativePath) => fs.existsSync(path.join(worktreePath, relativePath)));
1749
+ }
1750
+
1751
+ function stripDoctorSandboxLocks(rawContent, branchName) {
1752
+ if (!rawContent || !branchName) {
1753
+ return rawContent;
1754
+ }
1755
+ try {
1756
+ const parsed = JSON.parse(rawContent);
1757
+ const locks = parsed && typeof parsed === 'object' && parsed.locks && typeof parsed.locks === 'object'
1758
+ ? parsed.locks
1759
+ : null;
1760
+ if (!locks) {
1761
+ return rawContent;
1762
+ }
1763
+ let changed = false;
1764
+ const filteredLocks = {};
1765
+ for (const [filePath, lockInfo] of Object.entries(locks)) {
1766
+ if (lockInfo && lockInfo.branch === branchName) {
1767
+ changed = true;
1768
+ continue;
1769
+ }
1770
+ filteredLocks[filePath] = lockInfo;
1771
+ }
1772
+ if (!changed) {
1773
+ return rawContent;
1774
+ }
1775
+ return `${JSON.stringify({ ...parsed, locks: filteredLocks }, null, 2)}\n`;
1776
+ } catch {
1777
+ return rawContent;
1778
+ }
1779
+ }
1780
+
1430
1781
  function claimDoctorChangedLocks(metadata) {
1431
1782
  const lockScript = path.join(metadata.worktreePath, 'scripts', 'agent-file-locks.py');
1432
1783
  if (!fs.existsSync(lockScript) || !metadata.branch) {
@@ -1438,7 +1789,10 @@ function claimDoctorChangedLocks(metadata) {
1438
1789
  };
1439
1790
  }
1440
1791
 
1441
- const changedPaths = collectDoctorChangedPaths(metadata.worktreePath);
1792
+ const changedPaths = Array.from(new Set([
1793
+ ...collectDoctorChangedPaths(metadata.worktreePath),
1794
+ ...collectDoctorForceAddPaths(metadata.worktreePath),
1795
+ ]));
1442
1796
  const deletedPaths = collectDoctorDeletedPaths(metadata.worktreePath);
1443
1797
  if (changedPaths.length > 0) {
1444
1798
  run('python3', [lockScript, 'claim', '--branch', metadata.branch, ...changedPaths], {
@@ -1470,7 +1824,19 @@ function autoCommitDoctorSandboxChanges(metadata) {
1470
1824
  }
1471
1825
 
1472
1826
  claimDoctorChangedLocks(metadata);
1473
- run('git', ['-C', metadata.worktreePath, 'add', '-A'], { timeout: 20_000 });
1827
+ run(
1828
+ 'git',
1829
+ ['-C', metadata.worktreePath, 'add', '-A', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
1830
+ { timeout: 20_000 },
1831
+ );
1832
+ const forceAddPaths = collectDoctorForceAddPaths(metadata.worktreePath);
1833
+ if (forceAddPaths.length > 0) {
1834
+ run(
1835
+ 'git',
1836
+ ['-C', metadata.worktreePath, 'add', '-f', '--', ...forceAddPaths],
1837
+ { timeout: 20_000 },
1838
+ );
1839
+ }
1474
1840
  const staged = run(
1475
1841
  'git',
1476
1842
  ['-C', metadata.worktreePath, 'diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
@@ -1535,7 +1901,7 @@ function doctorFinishFlowIsPending(output) {
1535
1901
  );
1536
1902
  }
1537
1903
 
1538
- function finishDoctorSandboxBranch(blocked, metadata) {
1904
+ function finishDoctorSandboxBranch(blocked, metadata, options = {}) {
1539
1905
  const finishScript = path.join(metadata.worktreePath, 'scripts', 'agent-branch-finish.sh');
1540
1906
  if (!fs.existsSync(finishScript)) {
1541
1907
  return {
@@ -1577,10 +1943,11 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1577
1943
  const waitTimeoutSeconds =
1578
1944
  Number.isFinite(rawWaitTimeoutSeconds) && rawWaitTimeoutSeconds >= 30 ? rawWaitTimeoutSeconds : 1800;
1579
1945
  const finishTimeoutMs = Math.max(180_000, (waitTimeoutSeconds + 60) * 1000);
1946
+ const waitForMergeArg = options.waitForMerge === false ? '--no-wait-for-merge' : '--wait-for-merge';
1580
1947
 
1581
1948
  const finishResult = run(
1582
1949
  'bash',
1583
- [finishScript, '--branch', metadata.branch, '--base', blocked.branch, '--via-pr', '--wait-for-merge'],
1950
+ [finishScript, '--branch', metadata.branch, '--base', blocked.branch, '--via-pr', waitForMergeArg],
1584
1951
  { cwd: metadata.worktreePath, timeout: finishTimeoutMs },
1585
1952
  );
1586
1953
  if (isSpawnFailure(finishResult)) {
@@ -1619,35 +1986,186 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1619
1986
  };
1620
1987
  }
1621
1988
 
1622
- function syncProtectedBaseDoctorRepairs(options, blocked) {
1623
- const fixPayload = runFixInternal({
1624
- ...options,
1625
- target: blocked.repoRoot,
1626
- allowProtectedBaseWrite: true,
1627
- });
1628
- const changedOperations = fixPayload.operations.filter(
1629
- (operation) => !['unchanged', 'skipped'].includes(operation.status),
1989
+ function mergeDoctorSandboxRepairsBackToProtectedBase(options, blocked, metadata, autoCommitResult, finishResult) {
1990
+ if (options.dryRun) {
1991
+ return {
1992
+ status: autoCommitResult.status === 'committed' ? 'would-merge' : 'skipped',
1993
+ note: autoCommitResult.status === 'committed'
1994
+ ? 'dry run: would fast-forward tracked doctor repairs into the protected base workspace'
1995
+ : 'dry run skips tracked repair merge',
1996
+ };
1997
+ }
1998
+
1999
+ if (autoCommitResult.status !== 'committed') {
2000
+ return {
2001
+ status: autoCommitResult.status === 'no-changes' ? 'unchanged' : 'skipped',
2002
+ note: autoCommitResult.status === 'no-changes'
2003
+ ? 'no tracked doctor repairs needed in the protected base workspace'
2004
+ : 'tracked doctor repair merge skipped',
2005
+ };
2006
+ }
2007
+
2008
+ if (finishResult.status !== 'skipped') {
2009
+ return {
2010
+ status: 'skipped',
2011
+ note: finishResult.status === 'failed'
2012
+ ? 'tracked doctor repairs remain in the sandbox after finish failure'
2013
+ : 'tracked doctor repairs are being delivered through the sandbox finish flow',
2014
+ };
2015
+ }
2016
+
2017
+ const allowedPaths = new Set([
2018
+ ...(autoCommitResult.stagedFiles || []),
2019
+ ...OMX_SCAFFOLD_DIRECTORIES,
2020
+ ...Array.from(OMX_SCAFFOLD_FILES.keys()),
2021
+ ...TEMPLATE_FILES.map((entry) => toDestinationPath(entry)),
2022
+ 'bin',
2023
+ 'package.json',
2024
+ '.gitignore',
2025
+ 'AGENTS.md',
2026
+ ]);
2027
+ const dirtyPaths = collectWorktreeDirtyPaths(blocked.repoRoot);
2028
+ let stashRef = '';
2029
+ if (dirtyPaths.length > 0) {
2030
+ const unexpectedPaths = dirtyPaths.filter((filePath) => {
2031
+ if (allowedPaths.has(filePath)) {
2032
+ return false;
2033
+ }
2034
+ return !AGENT_WORKTREE_RELATIVE_DIRS.some(
2035
+ (relativeDir) => filePath === relativeDir || filePath.startsWith(`${relativeDir}/`),
2036
+ );
2037
+ });
2038
+ if (unexpectedPaths.length > 0) {
2039
+ return {
2040
+ status: 'failed',
2041
+ note: `protected branch workspace has unrelated local changes: ${unexpectedPaths.join(', ')}`,
2042
+ };
2043
+ }
2044
+ const stashMessage = `guardex-doctor-merge-${Date.now()}`;
2045
+ const stashResult = run(
2046
+ 'git',
2047
+ ['-C', blocked.repoRoot, 'stash', 'push', '--all', '--message', stashMessage],
2048
+ { timeout: 30_000 },
2049
+ );
2050
+ if (isSpawnFailure(stashResult)) {
2051
+ return {
2052
+ status: 'failed',
2053
+ note: 'could not stash protected branch doctor drift before merge',
2054
+ stdout: stashResult.stdout || '',
2055
+ stderr: stashResult.stderr || '',
2056
+ };
2057
+ }
2058
+ if (stashResult.status !== 0) {
2059
+ return {
2060
+ status: 'failed',
2061
+ note: 'stashing protected branch doctor drift failed',
2062
+ stdout: stashResult.stdout || '',
2063
+ stderr: stashResult.stderr || '',
2064
+ };
2065
+ }
2066
+
2067
+ const stashLookup = run(
2068
+ 'git',
2069
+ ['-C', blocked.repoRoot, 'stash', 'list'],
2070
+ { timeout: 20_000 },
2071
+ );
2072
+ stashRef = String(stashLookup.stdout || '')
2073
+ .split('\n')
2074
+ .find((line) => line.includes(stashMessage))
2075
+ ?.split(':')[0]
2076
+ ?.trim() || '';
2077
+ }
2078
+
2079
+ const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
2080
+ if (!restoreResult.ok) {
2081
+ if (stashRef) {
2082
+ run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
2083
+ }
2084
+ return {
2085
+ status: 'failed',
2086
+ note: `could not restore protected branch '${blocked.branch}' before applying sandbox repairs`,
2087
+ stdout: restoreResult.stdout || '',
2088
+ stderr: restoreResult.stderr || '',
2089
+ };
2090
+ }
2091
+
2092
+ const mergeResult = run(
2093
+ 'git',
2094
+ ['-C', blocked.repoRoot, 'merge', '--ff-only', metadata.branch],
2095
+ { timeout: 30_000 },
1630
2096
  );
1631
- const hookChanged = fixPayload.hookResult?.status && fixPayload.hookResult.status !== 'unchanged';
1632
- const changedCount = changedOperations.length + (hookChanged ? 1 : 0);
2097
+ if (isSpawnFailure(mergeResult)) {
2098
+ if (stashRef) {
2099
+ run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
2100
+ }
2101
+ return {
2102
+ status: 'failed',
2103
+ note: 'tracked doctor repair merge errored',
2104
+ stdout: mergeResult.stdout || '',
2105
+ stderr: mergeResult.stderr || '',
2106
+ };
2107
+ }
2108
+ if (mergeResult.status !== 0) {
2109
+ if (stashRef) {
2110
+ run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
2111
+ }
2112
+ return {
2113
+ status: 'failed',
2114
+ note: 'tracked doctor repair merge failed',
2115
+ stdout: mergeResult.stdout || '',
2116
+ stderr: mergeResult.stderr || '',
2117
+ };
2118
+ }
2119
+
2120
+ let cleanupResult;
2121
+ try {
2122
+ cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
2123
+ } catch (error) {
2124
+ return {
2125
+ status: 'failed',
2126
+ note: `tracked doctor repair merge succeeded but sandbox cleanup failed: ${error.message}`,
2127
+ stdout: mergeResult.stdout || '',
2128
+ stderr: mergeResult.stderr || '',
2129
+ };
2130
+ }
1633
2131
 
1634
- if (changedCount === 0) {
2132
+ let hookRefreshResult;
2133
+ try {
2134
+ hookRefreshResult = configureHooks(blocked.repoRoot, false);
2135
+ } catch (error) {
1635
2136
  return {
1636
- status: 'unchanged',
1637
- note: 'managed repair files already aligned in protected branch workspace',
1638
- fixPayload,
2137
+ status: 'failed',
2138
+ note: `tracked doctor repair merge succeeded but local hook refresh failed: ${error.message}`,
2139
+ stdout: mergeResult.stdout || '',
2140
+ stderr: mergeResult.stderr || '',
1639
2141
  };
1640
2142
  }
1641
2143
 
2144
+ if (stashRef) {
2145
+ run('git', ['-C', blocked.repoRoot, 'stash', 'drop', stashRef], { timeout: 20_000 });
2146
+ }
2147
+
1642
2148
  return {
1643
- status: options.dryRun ? 'would-sync' : 'synced',
1644
- note: `${options.dryRun ? 'would sync' : 'synced'} ${changedCount} managed repair item(s)`,
1645
- fixPayload,
2149
+ status: 'merged',
2150
+ note: 'fast-forwarded tracked doctor repairs into the protected base workspace',
2151
+ stdout: mergeResult.stdout || '',
2152
+ stderr: mergeResult.stderr || '',
2153
+ cleanup: cleanupResult,
2154
+ hookRefresh: hookRefreshResult,
1646
2155
  };
1647
2156
  }
1648
2157
 
2158
+ function syncDoctorLocalSupportFiles(repoRoot, dryRun) {
2159
+ return TEMPLATE_FILES
2160
+ .filter((entry) => entry.startsWith('codex/') || entry.startsWith('claude/'))
2161
+ .map((entry) => ensureTemplateFilePresent(repoRoot, entry, dryRun));
2162
+ }
2163
+
1649
2164
  function runDoctorInSandbox(options, blocked) {
1650
- const startResult = startDoctorSandbox(blocked);
2165
+ const startResult = startProtectedBaseSandbox(blocked, {
2166
+ taskName: `${SHORT_TOOL_NAME}-doctor`,
2167
+ sandboxSuffix: 'gx-doctor',
2168
+ });
1651
2169
  const metadata = startResult.metadata;
1652
2170
 
1653
2171
  const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
@@ -1677,6 +2195,7 @@ function runDoctorInSandbox(options, blocked) {
1677
2195
  status: 'skipped',
1678
2196
  note: 'sandbox doctor did not complete successfully',
1679
2197
  };
2198
+ let sandboxLockContent = null;
1680
2199
  let postSandboxAutoFinishSummary = {
1681
2200
  enabled: false,
1682
2201
  attempted: 0,
@@ -1690,7 +2209,6 @@ function runDoctorInSandbox(options, blocked) {
1690
2209
  note: 'sandbox doctor did not complete successfully',
1691
2210
  };
1692
2211
  if (nestedResult.status === 0) {
1693
- protectedBaseRepairSyncResult = syncProtectedBaseDoctorRepairs(options, blocked);
1694
2212
  const omxScaffoldOps = ensureOmxScaffold(blocked.repoRoot, Boolean(options.dryRun));
1695
2213
  const changedOmxPaths = omxScaffoldOps.filter((operation) => operation.status !== 'unchanged');
1696
2214
  if (changedOmxPaths.length === 0) {
@@ -1710,7 +2228,7 @@ function runDoctorInSandbox(options, blocked) {
1710
2228
  if (!options.dryRun) {
1711
2229
  autoCommitResult = autoCommitDoctorSandboxChanges(metadata);
1712
2230
  if (autoCommitResult.status === 'committed') {
1713
- finishResult = finishDoctorSandboxBranch(blocked, metadata);
2231
+ finishResult = finishDoctorSandboxBranch(blocked, metadata, options);
1714
2232
  } else if (autoCommitResult.status === 'no-changes') {
1715
2233
  finishResult = {
1716
2234
  status: 'skipped',
@@ -1746,7 +2264,11 @@ function runDoctorInSandbox(options, blocked) {
1746
2264
  note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
1747
2265
  };
1748
2266
  } else {
1749
- const sourceContent = fs.readFileSync(sandboxLockPath, 'utf8');
2267
+ const sourceContent = stripDoctorSandboxLocks(
2268
+ fs.readFileSync(sandboxLockPath, 'utf8'),
2269
+ metadata.branch,
2270
+ );
2271
+ sandboxLockContent = sourceContent;
1750
2272
  const destinationContent = fs.readFileSync(baseLockPath, 'utf8');
1751
2273
  if (sourceContent === destinationContent) {
1752
2274
  lockSyncResult = {
@@ -1763,9 +2285,66 @@ function runDoctorInSandbox(options, blocked) {
1763
2285
  }
1764
2286
  }
1765
2287
 
2288
+ protectedBaseRepairSyncResult = mergeDoctorSandboxRepairsBackToProtectedBase(
2289
+ options,
2290
+ blocked,
2291
+ metadata,
2292
+ autoCommitResult,
2293
+ finishResult,
2294
+ );
2295
+
2296
+ syncDoctorLocalSupportFiles(blocked.repoRoot, Boolean(options.dryRun));
2297
+
2298
+ const postMergeOmxScaffoldOps = ensureOmxScaffold(blocked.repoRoot, Boolean(options.dryRun));
2299
+ const postMergeChangedOmxPaths = postMergeOmxScaffoldOps.filter((operation) => operation.status !== 'unchanged');
2300
+ if (postMergeChangedOmxPaths.length === 0) {
2301
+ omxScaffoldSyncResult = {
2302
+ status: 'unchanged',
2303
+ note: '.omx scaffold already in sync',
2304
+ operations: postMergeOmxScaffoldOps,
2305
+ };
2306
+ } else {
2307
+ omxScaffoldSyncResult = {
2308
+ status: options.dryRun ? 'would-sync' : 'synced',
2309
+ note: `${options.dryRun ? 'would sync' : 'synced'} ${postMergeChangedOmxPaths.length} .omx path(s)`,
2310
+ operations: postMergeOmxScaffoldOps,
2311
+ };
2312
+ }
2313
+
2314
+ const postMergeBaseLockPath = path.join(blocked.repoRoot, LOCK_FILE_RELATIVE);
2315
+ if (sandboxLockContent === null) {
2316
+ lockSyncResult = {
2317
+ status: 'skipped',
2318
+ note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
2319
+ };
2320
+ } else if (!fs.existsSync(postMergeBaseLockPath)) {
2321
+ fs.mkdirSync(path.dirname(postMergeBaseLockPath), { recursive: true });
2322
+ fs.writeFileSync(postMergeBaseLockPath, sandboxLockContent, 'utf8');
2323
+ lockSyncResult = {
2324
+ status: 'synced',
2325
+ note: `${LOCK_FILE_RELATIVE} recreated from sandbox`,
2326
+ };
2327
+ } else {
2328
+ const destinationContent = fs.readFileSync(postMergeBaseLockPath, 'utf8');
2329
+ if (sandboxLockContent === destinationContent) {
2330
+ lockSyncResult = {
2331
+ status: 'unchanged',
2332
+ note: `${LOCK_FILE_RELATIVE} already in sync`,
2333
+ };
2334
+ } else {
2335
+ fs.mkdirSync(path.dirname(postMergeBaseLockPath), { recursive: true });
2336
+ fs.writeFileSync(postMergeBaseLockPath, sandboxLockContent, 'utf8');
2337
+ lockSyncResult = {
2338
+ status: 'synced',
2339
+ note: `${LOCK_FILE_RELATIVE} synced from sandbox`,
2340
+ };
2341
+ }
2342
+ }
2343
+
1766
2344
  postSandboxAutoFinishSummary = autoFinishReadyAgentBranches(blocked.repoRoot, {
1767
2345
  baseBranch: blocked.branch,
1768
2346
  dryRun: options.dryRun,
2347
+ waitForMerge: options.waitForMerge,
1769
2348
  excludeBranches: [metadata.branch],
1770
2349
  });
1771
2350
  }
@@ -1820,14 +2399,28 @@ function runDoctorInSandbox(options, blocked) {
1820
2399
  console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit skipped: ${autoCommitResult.note}.`);
1821
2400
  }
1822
2401
 
1823
- if (protectedBaseRepairSyncResult.status === 'synced') {
1824
- console.log(`[${TOOL_NAME}] Synced repaired managed files back to protected branch workspace.`);
2402
+ if (protectedBaseRepairSyncResult.status === 'merged') {
2403
+ console.log(`[${TOOL_NAME}] Fast-forwarded tracked doctor repairs into the protected branch workspace.`);
1825
2404
  } else if (protectedBaseRepairSyncResult.status === 'unchanged') {
1826
- console.log(`[${TOOL_NAME}] Protected branch workspace already had the repaired managed files.`);
1827
- } else if (protectedBaseRepairSyncResult.status === 'would-sync') {
1828
- console.log(`[${TOOL_NAME}] Dry run: would sync repaired managed files back to protected branch workspace.`);
2405
+ console.log(`[${TOOL_NAME}] Protected branch workspace already had the tracked doctor repairs.`);
2406
+ } else if (protectedBaseRepairSyncResult.status === 'would-merge') {
2407
+ console.log(`[${TOOL_NAME}] Dry run: would fast-forward tracked doctor repairs into the protected branch workspace.`);
2408
+ } else if (protectedBaseRepairSyncResult.status === 'failed') {
2409
+ console.log(`[${TOOL_NAME}] Protected branch tracked repair merge failed: ${protectedBaseRepairSyncResult.note}.`);
2410
+ if (protectedBaseRepairSyncResult.stdout) process.stdout.write(protectedBaseRepairSyncResult.stdout);
2411
+ if (protectedBaseRepairSyncResult.stderr) process.stderr.write(protectedBaseRepairSyncResult.stderr);
2412
+ } else {
2413
+ console.log(`[${TOOL_NAME}] Protected branch tracked repair merge skipped: ${protectedBaseRepairSyncResult.note}.`);
2414
+ }
2415
+
2416
+ if (lockSyncResult.status === 'synced') {
2417
+ console.log(
2418
+ `[${TOOL_NAME}] Synced repaired lock registry back to protected branch workspace (${LOCK_FILE_RELATIVE}).`,
2419
+ );
2420
+ } else if (lockSyncResult.status === 'unchanged') {
2421
+ console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
1829
2422
  } else {
1830
- console.log(`[${TOOL_NAME}] Protected branch workspace repair sync skipped: ${protectedBaseRepairSyncResult.note}.`);
2423
+ console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${lockSyncResult.note}.`);
1831
2424
  }
1832
2425
 
1833
2426
  if (finishResult.status === 'completed') {
@@ -1845,32 +2438,17 @@ function runDoctorInSandbox(options, blocked) {
1845
2438
  if (finishResult.stderr) process.stderr.write(finishResult.stderr);
1846
2439
  } else if (finishResult.status === 'failed') {
1847
2440
  console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
2441
+ console.log(`[guardex] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
1848
2442
  if (finishResult.stdout) process.stdout.write(finishResult.stdout);
1849
2443
  if (finishResult.stderr) process.stderr.write(finishResult.stderr);
1850
2444
  } else {
1851
2445
  console.log(`[${TOOL_NAME}] Auto-finish skipped: ${finishResult.note}.`);
1852
2446
  }
1853
2447
 
1854
- if (lockSyncResult.status === 'synced') {
1855
- console.log(
1856
- `[${TOOL_NAME}] Synced repaired lock registry back to protected branch workspace (${LOCK_FILE_RELATIVE}).`,
1857
- );
1858
- } else if (lockSyncResult.status === 'unchanged') {
1859
- console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
1860
- } else {
1861
- console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${lockSyncResult.note}.`);
1862
- }
1863
-
1864
- if (postSandboxAutoFinishSummary.enabled) {
1865
- console.log(
1866
- `[${TOOL_NAME}] Auto-finish sweep (base=${blocked.branch}): attempted=${postSandboxAutoFinishSummary.attempted}, completed=${postSandboxAutoFinishSummary.completed}, skipped=${postSandboxAutoFinishSummary.skipped}, failed=${postSandboxAutoFinishSummary.failed}`,
1867
- );
1868
- for (const detail of postSandboxAutoFinishSummary.details) {
1869
- console.log(`[${TOOL_NAME}] ${detail}`);
1870
- }
1871
- } else if (postSandboxAutoFinishSummary.details.length > 0) {
1872
- console.log(`[${TOOL_NAME}] ${postSandboxAutoFinishSummary.details[0]}`);
1873
- }
2448
+ printAutoFinishSummary(postSandboxAutoFinishSummary, {
2449
+ baseBranch: blocked.branch,
2450
+ verbose: options.verboseAutoFinish,
2451
+ });
1874
2452
  if (omxScaffoldSyncResult.status === 'synced') {
1875
2453
  console.log(`[${TOOL_NAME}] Synced .omx scaffold back to protected branch workspace.`);
1876
2454
  } else if (omxScaffoldSyncResult.status === 'unchanged') {
@@ -1883,22 +2461,99 @@ function runDoctorInSandbox(options, blocked) {
1883
2461
  }
1884
2462
  }
1885
2463
 
1886
- if (typeof nestedResult.status === 'number') {
1887
- let exitCode = nestedResult.status;
1888
- if (exitCode === 0 && autoCommitResult.status === 'failed') {
1889
- exitCode = 1;
1890
- }
1891
- if (
1892
- exitCode === 0 &&
1893
- autoCommitResult.status === 'committed' &&
1894
- (finishResult.status === 'failed' || finishResult.status === 'pending')
1895
- ) {
1896
- exitCode = 1;
2464
+ if (typeof nestedResult.status === 'number') {
2465
+ let exitCode = nestedResult.status;
2466
+ if (exitCode === 0 && autoCommitResult.status === 'failed') {
2467
+ exitCode = 1;
2468
+ }
2469
+ if (
2470
+ exitCode === 0 &&
2471
+ autoCommitResult.status === 'committed' &&
2472
+ (finishResult.status === 'failed' || finishResult.status === 'pending')
2473
+ ) {
2474
+ exitCode = 1;
2475
+ }
2476
+ if (exitCode === 0 && protectedBaseRepairSyncResult.status === 'failed') {
2477
+ exitCode = 1;
2478
+ }
2479
+ process.exitCode = exitCode;
2480
+ return;
2481
+ }
2482
+ process.exitCode = 1;
2483
+ }
2484
+
2485
+ function runSetupInSandbox(options, blocked, repoLabel = '') {
2486
+ const startResult = startProtectedBaseSandbox(blocked, {
2487
+ taskName: `${SHORT_TOOL_NAME}-setup`,
2488
+ sandboxSuffix: 'gx-setup',
2489
+ });
2490
+ const metadata = startResult.metadata;
2491
+
2492
+ if (startResult.stdout) process.stdout.write(startResult.stdout);
2493
+ if (startResult.stderr) process.stderr.write(startResult.stderr);
2494
+ console.log(
2495
+ `[${TOOL_NAME}] setup blocked on protected branch '${blocked.branch}' in an initialized repo; ` +
2496
+ 'refreshing through a sandbox worktree and syncing managed bootstrap files back locally.',
2497
+ );
2498
+
2499
+ const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
2500
+ const nestedResult = run(
2501
+ process.execPath,
2502
+ [__filename, ...buildSandboxSetupArgs(options, sandboxTarget)],
2503
+ { cwd: metadata.worktreePath },
2504
+ );
2505
+ if (isSpawnFailure(nestedResult)) {
2506
+ throw nestedResult.error;
2507
+ }
2508
+ if (nestedResult.status !== 0) {
2509
+ if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
2510
+ if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
2511
+ throw new Error(
2512
+ `sandboxed setup failed for protected branch '${blocked.branch}'. ` +
2513
+ `Inspect sandbox at ${metadata.worktreePath}`,
2514
+ );
2515
+ }
2516
+
2517
+ const syncOptions = {
2518
+ ...options,
2519
+ target: blocked.repoRoot,
2520
+ recursive: false,
2521
+ allowProtectedBaseWrite: true,
2522
+ };
2523
+ const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(syncOptions);
2524
+ printOperations(`Setup/install${repoLabel}`, installPayload, syncOptions.dryRun);
2525
+ printOperations(`Setup/fix${repoLabel}`, fixPayload, syncOptions.dryRun);
2526
+ if (!syncOptions.dryRun && parentWorkspace) {
2527
+ console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
2528
+ }
2529
+
2530
+ const scanResult = runScanInternal({ target: blocked.repoRoot, json: false });
2531
+ const currentBaseBranch = currentBranchName(scanResult.repoRoot);
2532
+ const autoFinishSummary = autoFinishReadyAgentBranches(scanResult.repoRoot, {
2533
+ baseBranch: currentBaseBranch,
2534
+ dryRun: syncOptions.dryRun,
2535
+ });
2536
+ printScanResult(scanResult, false);
2537
+ if (autoFinishSummary.enabled) {
2538
+ console.log(
2539
+ `[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
2540
+ );
2541
+ for (const detail of autoFinishSummary.details) {
2542
+ console.log(`[${TOOL_NAME}] ${detail}`);
1897
2543
  }
1898
- process.exitCode = exitCode;
1899
- return;
2544
+ } else if (autoFinishSummary.details.length > 0) {
2545
+ console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
1900
2546
  }
1901
- process.exitCode = 1;
2547
+
2548
+ const cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
2549
+ console.log(
2550
+ `[${TOOL_NAME}] Protected-base setup sandbox cleanup: ${cleanupResult.note} ` +
2551
+ `(worktree=${cleanupResult.worktree}, branch=${cleanupResult.branch}).`,
2552
+ );
2553
+
2554
+ return {
2555
+ scanResult,
2556
+ };
1902
2557
  }
1903
2558
 
1904
2559
  function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) {
@@ -2082,6 +2737,19 @@ function inferGithubRepoFromOrigin(repoRoot) {
2082
2737
  return `github.com/${slug}`;
2083
2738
  }
2084
2739
 
2740
+ function inferGithubRepoSlug(rawValue) {
2741
+ const raw = String(rawValue || '').trim();
2742
+ if (!raw) return '';
2743
+ const match = raw.match(/github\.com[:/](.+?)(?:\.git)?$/i);
2744
+ if (!match) return '';
2745
+ const slug = String(match[1] || '')
2746
+ .replace(/^\/+/, '')
2747
+ .replace(/^github\.com\//i, '')
2748
+ .trim();
2749
+ if (!slug || !slug.includes('/')) return '';
2750
+ return slug;
2751
+ }
2752
+
2085
2753
  function resolveScorecardRepo(repoRoot, explicitRepo) {
2086
2754
  if (explicitRepo) {
2087
2755
  return explicitRepo.trim();
@@ -2335,6 +3003,7 @@ function hasSignificantWorkingTreeChanges(worktreePath) {
2335
3003
  function autoFinishReadyAgentBranches(repoRoot, options = {}) {
2336
3004
  const baseBranch = String(options.baseBranch || '').trim();
2337
3005
  const dryRun = Boolean(options.dryRun);
3006
+ const waitForMerge = options.waitForMerge !== false;
2338
3007
  const excludedBranches = new Set(
2339
3008
  Array.isArray(options.excludeBranches)
2340
3009
  ? options.excludeBranches.map((branch) => String(branch || '').trim()).filter(Boolean)
@@ -2453,7 +3122,7 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
2453
3122
  '--base',
2454
3123
  baseBranch,
2455
3124
  '--via-pr',
2456
- '--wait-for-merge',
3125
+ waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge',
2457
3126
  '--cleanup',
2458
3127
  ];
2459
3128
  const finishResult = run('bash', finishArgs, { cwd: repoRoot });
@@ -2565,6 +3234,66 @@ function currentBranchName(repoRoot) {
2565
3234
  return branch;
2566
3235
  }
2567
3236
 
3237
+ function repoHasHeadCommit(repoRoot) {
3238
+ return gitRun(repoRoot, ['rev-parse', '--verify', 'HEAD'], { allowFailure: true }).status === 0;
3239
+ }
3240
+
3241
+ function readBranchDisplayName(repoRoot) {
3242
+ const symbolic = gitRun(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { allowFailure: true });
3243
+ if (symbolic.status === 0) {
3244
+ const branch = String(symbolic.stdout || '').trim();
3245
+ if (!branch) {
3246
+ return '(unknown)';
3247
+ }
3248
+ return repoHasHeadCommit(repoRoot) ? branch : `${branch} (unborn; no commits yet)`;
3249
+ }
3250
+
3251
+ const detached = gitRun(repoRoot, ['rev-parse', '--short', 'HEAD'], { allowFailure: true });
3252
+ if (detached.status === 0) {
3253
+ return `(detached at ${String(detached.stdout || '').trim()})`;
3254
+ }
3255
+ return '(unknown)';
3256
+ }
3257
+
3258
+ function repoHasOriginRemote(repoRoot) {
3259
+ return gitRun(repoRoot, ['remote', 'get-url', 'origin'], { allowFailure: true }).status === 0;
3260
+ }
3261
+
3262
+ function detectComposeHintFiles(repoRoot) {
3263
+ return COMPOSE_HINT_FILES.filter((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
3264
+ }
3265
+
3266
+ function printSetupRepoHints(repoRoot, baseBranch, repoLabel = '') {
3267
+ const branchDisplay = readBranchDisplayName(repoRoot);
3268
+ const hasHeadCommit = repoHasHeadCommit(repoRoot);
3269
+ const hasOrigin = repoHasOriginRemote(repoRoot);
3270
+ const composeFiles = detectComposeHintFiles(repoRoot);
3271
+ if (hasHeadCommit && hasOrigin && composeFiles.length === 0) {
3272
+ return;
3273
+ }
3274
+
3275
+ const label = repoLabel ? ` ${repoLabel}` : '';
3276
+ if (!hasHeadCommit) {
3277
+ console.log(`[${TOOL_NAME}] Fresh repo onboarding${label}: current branch is ${branchDisplay}.`);
3278
+ console.log(`[${TOOL_NAME}] Bootstrap commit${label}: git add . && git commit -m "bootstrap gitguardex"`);
3279
+ console.log(
3280
+ `[${TOOL_NAME}] First agent flow${label}: ` +
3281
+ `bash scripts/agent-branch-start.sh "<task>" "codex" -> ` +
3282
+ `python3 scripts/agent-file-locks.py claim --branch "$(git branch --show-current)" <file...> -> ` +
3283
+ `bash scripts/agent-branch-finish.sh --branch "$(git branch --show-current)" --base ${baseBranch} --via-pr --wait-for-merge`,
3284
+ );
3285
+ }
3286
+ if (!hasOrigin) {
3287
+ console.log(`[${TOOL_NAME}] No origin remote${label}: finish and auto-merge flows stay local until you add one.`);
3288
+ }
3289
+ if (composeFiles.length > 0) {
3290
+ console.log(
3291
+ `[${TOOL_NAME}] Docker Compose helper${label}: detected ${composeFiles.join(', ')}. ` +
3292
+ `Set GUARDEX_DOCKER_SERVICE and run 'bash scripts/guardex-docker-loader.sh -- <command...>'.`,
3293
+ );
3294
+ }
3295
+ }
3296
+
2568
3297
  function workingTreeIsDirty(repoRoot) {
2569
3298
  const result = gitRun(repoRoot, ['status', '--porcelain'], { allowFailure: true });
2570
3299
  if (result.status !== 0) {
@@ -2823,6 +3552,82 @@ function parseCleanupArgs(rawArgs) {
2823
3552
  return options;
2824
3553
  }
2825
3554
 
3555
+ function parseMergeArgs(rawArgs) {
3556
+ const options = {
3557
+ target: process.cwd(),
3558
+ base: '',
3559
+ into: '',
3560
+ branches: [],
3561
+ task: '',
3562
+ agent: '',
3563
+ };
3564
+
3565
+ for (let index = 0; index < rawArgs.length; index += 1) {
3566
+ const arg = rawArgs[index];
3567
+ if (arg === '--target') {
3568
+ const next = rawArgs[index + 1];
3569
+ if (!next) {
3570
+ throw new Error('--target requires a path value');
3571
+ }
3572
+ options.target = next;
3573
+ index += 1;
3574
+ continue;
3575
+ }
3576
+ if (arg === '--base') {
3577
+ const next = rawArgs[index + 1];
3578
+ if (!next) {
3579
+ throw new Error('--base requires a branch value');
3580
+ }
3581
+ options.base = next;
3582
+ index += 1;
3583
+ continue;
3584
+ }
3585
+ if (arg === '--into') {
3586
+ const next = rawArgs[index + 1];
3587
+ if (!next) {
3588
+ throw new Error('--into requires an agent/* branch value');
3589
+ }
3590
+ options.into = next;
3591
+ index += 1;
3592
+ continue;
3593
+ }
3594
+ if (arg === '--branch') {
3595
+ const next = rawArgs[index + 1];
3596
+ if (!next) {
3597
+ throw new Error('--branch requires an agent/* branch value');
3598
+ }
3599
+ options.branches.push(next);
3600
+ index += 1;
3601
+ continue;
3602
+ }
3603
+ if (arg === '--task') {
3604
+ const next = rawArgs[index + 1];
3605
+ if (!next) {
3606
+ throw new Error('--task requires a task value');
3607
+ }
3608
+ options.task = next;
3609
+ index += 1;
3610
+ continue;
3611
+ }
3612
+ if (arg === '--agent') {
3613
+ const next = rawArgs[index + 1];
3614
+ if (!next) {
3615
+ throw new Error('--agent requires an agent value');
3616
+ }
3617
+ options.agent = next;
3618
+ index += 1;
3619
+ continue;
3620
+ }
3621
+ throw new Error(`Unknown option: ${arg}`);
3622
+ }
3623
+
3624
+ if (options.branches.length === 0) {
3625
+ throw new Error('merge requires at least one --branch <agent/*> input');
3626
+ }
3627
+
3628
+ return options;
3629
+ }
3630
+
2826
3631
  function parseFinishArgs(rawArgs) {
2827
3632
  const options = {
2828
3633
  target: process.cwd(),
@@ -3376,6 +4181,17 @@ function parseVersionString(version) {
3376
4181
  ];
3377
4182
  }
3378
4183
 
4184
+ function compareParsedVersions(left, right) {
4185
+ if (!left || !right) return 0;
4186
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
4187
+ const leftValue = left[index] || 0;
4188
+ const rightValue = right[index] || 0;
4189
+ if (leftValue > rightValue) return 1;
4190
+ if (leftValue < rightValue) return -1;
4191
+ }
4192
+ return 0;
4193
+ }
4194
+
3379
4195
  function isNewerVersion(latest, current) {
3380
4196
  const latestParts = parseVersionString(latest);
3381
4197
  const currentParts = parseVersionString(current);
@@ -3384,11 +4200,7 @@ function isNewerVersion(latest, current) {
3384
4200
  return String(latest || '').trim() !== String(current || '').trim();
3385
4201
  }
3386
4202
 
3387
- for (let index = 0; index < latestParts.length; index += 1) {
3388
- if (latestParts[index] > currentParts[index]) return true;
3389
- if (latestParts[index] < currentParts[index]) return false;
3390
- }
3391
- return false;
4203
+ return compareParsedVersions(latestParts, currentParts) > 0;
3392
4204
  }
3393
4205
 
3394
4206
  function parseNpmVersionOutput(stdout) {
@@ -4078,8 +4890,7 @@ function runFixInternal(options) {
4078
4890
  function runScanInternal(options) {
4079
4891
  const repoRoot = resolveRepoRoot(options.target);
4080
4892
  const guardexToggle = resolveGuardexRepoToggle(repoRoot);
4081
- const currentBranchResult = gitRun(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFailure: true });
4082
- const branch = currentBranchResult.status === 0 ? currentBranchResult.stdout.trim() : '(unknown)';
4893
+ const branch = readBranchDisplayName(repoRoot);
4083
4894
  if (!guardexToggle.enabled) {
4084
4895
  return {
4085
4896
  repoRoot,
@@ -4525,29 +5336,38 @@ function doctor(rawArgs) {
4525
5336
 
4526
5337
  const repoResults = [];
4527
5338
  let aggregateExitCode = 0;
4528
- for (const repoPath of discoveredRepos) {
5339
+ for (let repoIndex = 0; repoIndex < discoveredRepos.length; repoIndex += 1) {
5340
+ const repoPath = discoveredRepos[repoIndex];
5341
+ const progressLabel = `${repoIndex + 1}/${discoveredRepos.length}`;
4529
5342
  if (!options.json) {
4530
- console.log(`[${TOOL_NAME}] ── Doctor target: ${repoPath} ──`);
5343
+ console.log(`[${TOOL_NAME}] ── Doctor target: ${repoPath} [${progressLabel}] ──`);
4531
5344
  }
4532
5345
 
4533
- const nestedResult = run(
4534
- process.execPath,
4535
- [
4536
- path.resolve(__filename),
4537
- 'doctor',
4538
- '--single-repo',
4539
- '--target',
4540
- repoPath,
4541
- ...(options.dropStaleLocks ? [] : ['--keep-stale-locks']),
4542
- ...(options.skipAgents ? ['--skip-agents'] : []),
4543
- ...(options.skipPackageJson ? ['--skip-package-json'] : []),
4544
- ...(options.skipGitignore ? ['--no-gitignore'] : []),
4545
- ...(options.dryRun ? ['--dry-run'] : []),
4546
- ...(options.json ? ['--json'] : []),
4547
- ...(options.allowProtectedBaseWrite ? ['--allow-protected-base-write'] : []),
4548
- ],
4549
- { cwd: topRepoRoot },
4550
- );
5346
+ const childArgs = [
5347
+ path.resolve(__filename),
5348
+ 'doctor',
5349
+ '--single-repo',
5350
+ '--target',
5351
+ repoPath,
5352
+ ...(options.dropStaleLocks ? [] : ['--keep-stale-locks']),
5353
+ ...(options.skipAgents ? ['--skip-agents'] : []),
5354
+ ...(options.skipPackageJson ? ['--skip-package-json'] : []),
5355
+ ...(options.skipGitignore ? ['--no-gitignore'] : []),
5356
+ ...(options.dryRun ? ['--dry-run'] : []),
5357
+ // Recursive child doctor runs should report pending PR state immediately instead of blocking the parent loop.
5358
+ '--no-wait-for-merge',
5359
+ ...(options.verboseAutoFinish ? ['--verbose-auto-finish'] : []),
5360
+ ...(options.json ? ['--json'] : []),
5361
+ ...(options.allowProtectedBaseWrite ? ['--allow-protected-base-write'] : []),
5362
+ ];
5363
+ const startedAt = Date.now();
5364
+ const nestedResult = options.json
5365
+ ? run(process.execPath, childArgs, { cwd: topRepoRoot })
5366
+ : cp.spawnSync(process.execPath, childArgs, {
5367
+ cwd: topRepoRoot,
5368
+ encoding: 'utf8',
5369
+ stdio: 'inherit',
5370
+ });
4551
5371
  if (isSpawnFailure(nestedResult)) {
4552
5372
  throw nestedResult.error;
4553
5373
  }
@@ -4577,9 +5397,12 @@ function doctor(rawArgs) {
4577
5397
  },
4578
5398
  );
4579
5399
  } else {
4580
- if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
4581
- if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
4582
- process.stdout.write('\n');
5400
+ console.log(
5401
+ `[${TOOL_NAME}] Doctor target complete: ${repoPath} [${progressLabel}] in ${formatElapsedDuration(Date.now() - startedAt)}.`,
5402
+ );
5403
+ if (repoIndex < discoveredRepos.length - 1) {
5404
+ process.stdout.write('\n');
5405
+ }
4583
5406
  }
4584
5407
  }
4585
5408
 
@@ -4628,6 +5451,7 @@ function doctor(rawArgs) {
4628
5451
  : autoFinishReadyAgentBranches(scanResult.repoRoot, {
4629
5452
  baseBranch: currentBaseBranch,
4630
5453
  dryRun: singleRepoOptions.dryRun,
5454
+ waitForMerge: singleRepoOptions.waitForMerge,
4631
5455
  });
4632
5456
  const safe = scanResult.guardexEnabled === false || (scanResult.errors === 0 && scanResult.warnings === 0);
4633
5457
  const musafe = safe;
@@ -4662,23 +5486,17 @@ function doctor(rawArgs) {
4662
5486
  return;
4663
5487
  }
4664
5488
 
4665
- printOperations('Doctor/fix', fixPayload, singleRepoOptions.dryRun);
5489
+ printOperations('Doctor/fix', fixPayload, options.dryRun);
4666
5490
  printScanResult(scanResult, false);
4667
5491
  if (scanResult.guardexEnabled === false) {
4668
5492
  console.log(`[${TOOL_NAME}] Repo-local Guardex enforcement is intentionally disabled.`);
4669
5493
  setExitCodeFromScan(scanResult);
4670
5494
  return;
4671
5495
  }
4672
- if (autoFinishSummary.enabled) {
4673
- console.log(
4674
- `[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
4675
- );
4676
- for (const detail of autoFinishSummary.details) {
4677
- console.log(`[${TOOL_NAME}] ${detail}`);
4678
- }
4679
- } else if (autoFinishSummary.details.length > 0) {
4680
- console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
4681
- }
5496
+ printAutoFinishSummary(autoFinishSummary, {
5497
+ baseBranch: currentBaseBranch,
5498
+ verbose: singleRepoOptions.verboseAutoFinish,
5499
+ });
4682
5500
  if (safe) {
4683
5501
  console.log(`[${TOOL_NAME}] ✅ Repo is fully safe.`);
4684
5502
  } else {
@@ -5171,31 +5989,24 @@ function setup(rawArgs) {
5171
5989
  console.log(`[${TOOL_NAME}] ── Setup target: ${repoPath} ──`);
5172
5990
  }
5173
5991
 
5174
- assertProtectedMainWriteAllowed(perRepoOptions, 'setup');
5175
- const installPayload = runInstallInternal(perRepoOptions);
5176
- installPayload.operations.push(ensureSetupProtectedBranches(installPayload.repoRoot, Boolean(perRepoOptions.dryRun)));
5177
- if (perRepoOptions.parentWorkspaceView) {
5178
- installPayload.operations.push(ensureParentWorkspaceView(installPayload.repoRoot, Boolean(perRepoOptions.dryRun)));
5992
+ const blocked = protectedBaseWriteBlock(perRepoOptions);
5993
+ if (blocked) {
5994
+ const sandboxResult = runSetupInSandbox(perRepoOptions, blocked, repoLabel);
5995
+ aggregateErrors += sandboxResult.scanResult.errors;
5996
+ aggregateWarnings += sandboxResult.scanResult.warnings;
5997
+ lastScanResult = sandboxResult.scanResult;
5998
+ continue;
5179
5999
  }
5180
- printOperations(`Setup/install${repoLabel}`, installPayload, perRepoOptions.dryRun);
5181
6000
 
5182
- const fixPayload = runFixInternal({
5183
- target: repoPath,
5184
- dryRun: perRepoOptions.dryRun,
5185
- force: perRepoOptions.force,
5186
- dropStaleLocks: true,
5187
- skipAgents: perRepoOptions.skipAgents,
5188
- skipPackageJson: perRepoOptions.skipPackageJson,
5189
- skipGitignore: perRepoOptions.skipGitignore,
5190
- });
6001
+ const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(perRepoOptions);
6002
+ printOperations(`Setup/install${repoLabel}`, installPayload, perRepoOptions.dryRun);
5191
6003
  printOperations(`Setup/fix${repoLabel}`, fixPayload, perRepoOptions.dryRun);
5192
6004
 
5193
6005
  if (perRepoOptions.dryRun) {
5194
6006
  continue;
5195
6007
  }
5196
6008
 
5197
- if (perRepoOptions.parentWorkspaceView) {
5198
- const parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot);
6009
+ if (parentWorkspace) {
5199
6010
  console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
5200
6011
  }
5201
6012
 
@@ -5216,6 +6027,7 @@ function setup(rawArgs) {
5216
6027
  } else if (autoFinishSummary.details.length > 0) {
5217
6028
  console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
5218
6029
  }
6030
+ printSetupRepoHints(scanResult.repoRoot, currentBaseBranch, repoLabel);
5219
6031
 
5220
6032
  aggregateErrors += scanResult.errors;
5221
6033
  aggregateWarnings += scanResult.warnings;
@@ -5275,6 +6087,156 @@ function ensureCleanWorkingTree(repoRoot) {
5275
6087
  }
5276
6088
  }
5277
6089
 
6090
+ function readReleaseRepoPackageJson(repoRoot) {
6091
+ const manifestPath = path.join(repoRoot, 'package.json');
6092
+ if (!fs.existsSync(manifestPath)) {
6093
+ throw new Error(`Release blocked: package.json missing in ${repoRoot}`);
6094
+ }
6095
+
6096
+ try {
6097
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
6098
+ } catch (error) {
6099
+ throw new Error(`Release blocked: unable to parse package.json in ${repoRoot}: ${error.message}`);
6100
+ }
6101
+ }
6102
+
6103
+ function resolveReleaseGithubRepo(repoRoot) {
6104
+ const releasePackageJson = readReleaseRepoPackageJson(repoRoot);
6105
+ const fromManifest = inferGithubRepoSlug(
6106
+ releasePackageJson.repository &&
6107
+ (releasePackageJson.repository.url || releasePackageJson.repository),
6108
+ );
6109
+ if (fromManifest) {
6110
+ return fromManifest;
6111
+ }
6112
+
6113
+ const fromOrigin = inferGithubRepoSlug(readGitConfig(repoRoot, 'remote.origin.url'));
6114
+ if (fromOrigin) {
6115
+ return fromOrigin;
6116
+ }
6117
+
6118
+ throw new Error(
6119
+ 'Release blocked: unable to resolve GitHub repo from package.json repository URL or origin remote.',
6120
+ );
6121
+ }
6122
+
6123
+ function readRepoReadme(repoRoot) {
6124
+ const readmePath = path.join(repoRoot, 'README.md');
6125
+ if (!fs.existsSync(readmePath)) {
6126
+ throw new Error(`Release blocked: README.md missing in ${repoRoot}`);
6127
+ }
6128
+ return fs.readFileSync(readmePath, 'utf8');
6129
+ }
6130
+
6131
+ function parseReadmeReleaseEntries(readmeContent) {
6132
+ const releaseNotesIndex = String(readmeContent || '').indexOf('## Release notes');
6133
+ if (releaseNotesIndex < 0) {
6134
+ throw new Error('Release blocked: README.md is missing the "## Release notes" section');
6135
+ }
6136
+
6137
+ const releaseNotesContent = String(readmeContent || '').slice(releaseNotesIndex);
6138
+ const entries = [];
6139
+ const lines = releaseNotesContent.split(/\r?\n/);
6140
+ let currentTag = '';
6141
+ let currentLines = [];
6142
+
6143
+ function flushEntry() {
6144
+ if (!currentTag) {
6145
+ return;
6146
+ }
6147
+ const body = currentLines.join('\n').trim();
6148
+ if (body) {
6149
+ entries.push({ tag: currentTag, body, version: parseVersionString(currentTag) });
6150
+ }
6151
+ currentTag = '';
6152
+ currentLines = [];
6153
+ }
6154
+
6155
+ for (const line of lines) {
6156
+ const headingMatch = line.match(/^###\s+(v\d+\.\d+\.\d+)\s*$/);
6157
+ if (headingMatch) {
6158
+ flushEntry();
6159
+ currentTag = headingMatch[1];
6160
+ continue;
6161
+ }
6162
+
6163
+ if (!currentTag) {
6164
+ continue;
6165
+ }
6166
+
6167
+ if (/^<\/details>\s*$/.test(line) || /^##\s+/.test(line)) {
6168
+ flushEntry();
6169
+ continue;
6170
+ }
6171
+
6172
+ currentLines.push(line);
6173
+ }
6174
+
6175
+ flushEntry();
6176
+
6177
+ if (entries.length === 0) {
6178
+ throw new Error('Release blocked: README.md did not yield any versioned release-note sections');
6179
+ }
6180
+
6181
+ return entries;
6182
+ }
6183
+
6184
+ function resolvePreviousPublishedReleaseTag(repoSlug, currentTag) {
6185
+ const result = run(GH_BIN, ['release', 'list', '--repo', repoSlug, '--limit', '20'], {
6186
+ timeout: 20_000,
6187
+ });
6188
+ if (result.error) {
6189
+ throw new Error(`Release blocked: unable to run '${GH_BIN} release list': ${result.error.message}`);
6190
+ }
6191
+ if (result.status !== 0) {
6192
+ const details = (result.stderr || result.stdout || '').trim();
6193
+ throw new Error(`Release blocked: unable to list GitHub releases.${details ? `\n${details}` : ''}`);
6194
+ }
6195
+
6196
+ const tags = String(result.stdout || '')
6197
+ .split('\n')
6198
+ .map((line) => line.split('\t')[0].trim())
6199
+ .filter(Boolean);
6200
+
6201
+ return tags.find((tag) => tag !== currentTag) || '';
6202
+ }
6203
+
6204
+ function selectReleaseEntriesForWindow(entries, currentTag, previousTag) {
6205
+ const currentVersion = parseVersionString(currentTag);
6206
+ if (!currentVersion) {
6207
+ throw new Error(`Release blocked: invalid current version tag '${currentTag}'`);
6208
+ }
6209
+ const previousVersion = previousTag ? parseVersionString(previousTag) : null;
6210
+
6211
+ const selected = entries.filter((entry) => {
6212
+ if (!entry.version) return false;
6213
+ if (compareParsedVersions(entry.version, currentVersion) > 0) return false;
6214
+ if (!previousVersion) return entry.tag === currentTag;
6215
+ return compareParsedVersions(entry.version, previousVersion) > 0;
6216
+ });
6217
+
6218
+ if (!selected.some((entry) => entry.tag === currentTag)) {
6219
+ throw new Error(`Release blocked: README.md is missing release notes for ${currentTag}`);
6220
+ }
6221
+
6222
+ return selected;
6223
+ }
6224
+
6225
+ function renderGeneratedReleaseNotes(entries, currentTag, previousTag) {
6226
+ const intro = previousTag ? `Changes since ${previousTag}.` : `Changes in ${currentTag}.`;
6227
+ const sections = entries
6228
+ .map((entry) => `### ${entry.tag}\n${entry.body}`)
6229
+ .join('\n\n');
6230
+ return `GitGuardex ${currentTag}\n\n${intro}\n\n${sections}`;
6231
+ }
6232
+
6233
+ function buildReleaseNotesFromReadme(repoRoot, currentTag, previousTag) {
6234
+ const readme = readRepoReadme(repoRoot);
6235
+ const entries = parseReadmeReleaseEntries(readme);
6236
+ const selected = selectReleaseEntriesForWindow(entries, currentTag, previousTag);
6237
+ return renderGeneratedReleaseNotes(selected, currentTag, previousTag);
6238
+ }
6239
+
5278
6240
  function release(rawArgs) {
5279
6241
  if (rawArgs.length > 0) {
5280
6242
  throw new Error(`Unknown option: ${rawArgs[0]}`);
@@ -5290,13 +6252,74 @@ function release(rawArgs) {
5290
6252
  ensureMainBranch(repoRoot);
5291
6253
  ensureCleanWorkingTree(repoRoot);
5292
6254
 
5293
- console.log(`[${TOOL_NAME}] Releasing ${packageJson.name}@${packageJson.version} from ${repoRoot}`);
5294
- const publishResult = run(NPM_BIN, ['publish'], { cwd: repoRoot, stdio: 'inherit' });
5295
- if (publishResult.status !== 0) {
5296
- throw new Error('npm publish failed');
6255
+ if (!isCommandAvailable(GH_BIN)) {
6256
+ throw new Error(`Release blocked: '${GH_BIN}' is not available`);
6257
+ }
6258
+
6259
+ const ghAuthStatus = run(GH_BIN, ['auth', 'status'], { timeout: 20_000 });
6260
+ if (ghAuthStatus.error) {
6261
+ throw new Error(`Release blocked: unable to run '${GH_BIN} auth status': ${ghAuthStatus.error.message}`);
6262
+ }
6263
+ if (ghAuthStatus.status !== 0) {
6264
+ const details = (ghAuthStatus.stderr || ghAuthStatus.stdout || '').trim();
6265
+ throw new Error(`Release blocked: '${GH_BIN}' auth is unavailable.${details ? `\n${details}` : ''}`);
6266
+ }
6267
+
6268
+ const releasePackageJson = readReleaseRepoPackageJson(repoRoot);
6269
+ const repoSlug = resolveReleaseGithubRepo(repoRoot);
6270
+ const currentTag = `v${releasePackageJson.version}`;
6271
+ const previousTag = resolvePreviousPublishedReleaseTag(repoSlug, currentTag);
6272
+ const notes = buildReleaseNotesFromReadme(repoRoot, currentTag, previousTag);
6273
+ const headCommit = gitRun(repoRoot, ['rev-parse', 'HEAD']).stdout.trim();
6274
+
6275
+ const existingRelease = run(GH_BIN, ['release', 'view', currentTag, '--repo', repoSlug], {
6276
+ timeout: 20_000,
6277
+ });
6278
+ if (existingRelease.error) {
6279
+ throw new Error(`Release blocked: unable to run '${GH_BIN} release view': ${existingRelease.error.message}`);
6280
+ }
6281
+
6282
+ const releaseArgs =
6283
+ existingRelease.status === 0
6284
+ ? ['release', 'edit', currentTag, '--repo', repoSlug, '--title', currentTag, '--notes', notes]
6285
+ : [
6286
+ 'release',
6287
+ 'create',
6288
+ currentTag,
6289
+ '--repo',
6290
+ repoSlug,
6291
+ '--target',
6292
+ headCommit,
6293
+ '--title',
6294
+ currentTag,
6295
+ '--notes',
6296
+ notes,
6297
+ ];
6298
+
6299
+ console.log(
6300
+ `[${TOOL_NAME}] ${existingRelease.status === 0 ? 'Updating' : 'Creating'} GitHub release ${currentTag} on ${repoSlug}`,
6301
+ );
6302
+ if (previousTag) {
6303
+ console.log(`[${TOOL_NAME}] Aggregating README release notes newer than ${previousTag}.`);
6304
+ } else {
6305
+ console.log(`[${TOOL_NAME}] No earlier published GitHub release found; using only ${currentTag}.`);
6306
+ }
6307
+
6308
+ const releaseResult = run(GH_BIN, releaseArgs, { cwd: repoRoot, timeout: 60_000 });
6309
+ if (releaseResult.error) {
6310
+ throw new Error(`Release blocked: unable to run '${GH_BIN} release': ${releaseResult.error.message}`);
6311
+ }
6312
+ if (releaseResult.status !== 0) {
6313
+ const details = (releaseResult.stderr || releaseResult.stdout || '').trim();
6314
+ throw new Error(`GitHub release command failed.${details ? `\n${details}` : ''}`);
6315
+ }
6316
+
6317
+ const releaseUrl = String(releaseResult.stdout || '').trim();
6318
+ if (releaseUrl) {
6319
+ console.log(releaseUrl);
5297
6320
  }
5298
6321
 
5299
- console.log(`[${TOOL_NAME}] ✅ Publish complete.`);
6322
+ console.log(`[${TOOL_NAME}] ✅ GitHub release ${currentTag} is synced to the README history.`);
5300
6323
  process.exitCode = 0;
5301
6324
  }
5302
6325
 
@@ -5649,6 +6672,46 @@ function cleanup(rawArgs) {
5649
6672
  process.exitCode = 0;
5650
6673
  }
5651
6674
 
6675
+ function merge(rawArgs) {
6676
+ const options = parseMergeArgs(rawArgs);
6677
+ const repoRoot = resolveRepoRoot(options.target);
6678
+ const mergeScript = path.join(repoRoot, 'scripts', 'agent-branch-merge.sh');
6679
+
6680
+ if (!fs.existsSync(mergeScript)) {
6681
+ throw new Error(`Missing merge script: ${mergeScript}. Run '${SHORT_TOOL_NAME} setup' first.`);
6682
+ }
6683
+
6684
+ const args = [mergeScript];
6685
+ if (options.base) {
6686
+ args.push('--base', options.base);
6687
+ }
6688
+ if (options.into) {
6689
+ args.push('--into', options.into);
6690
+ }
6691
+ if (options.task) {
6692
+ args.push('--task', options.task);
6693
+ }
6694
+ if (options.agent) {
6695
+ args.push('--agent', options.agent);
6696
+ }
6697
+ for (const branch of options.branches) {
6698
+ args.push('--branch', branch);
6699
+ }
6700
+
6701
+ const mergeResult = run('bash', args, { cwd: repoRoot, stdio: 'pipe' });
6702
+ if (mergeResult.stdout) {
6703
+ process.stdout.write(mergeResult.stdout);
6704
+ }
6705
+ if (mergeResult.stderr) {
6706
+ process.stderr.write(mergeResult.stderr);
6707
+ }
6708
+ if (mergeResult.status !== 0) {
6709
+ throw new Error(`merge command failed with status ${mergeResult.status}`);
6710
+ }
6711
+
6712
+ process.exitCode = 0;
6713
+ }
6714
+
5652
6715
  function finish(rawArgs) {
5653
6716
  const options = parseFinishArgs(rawArgs);
5654
6717
  const repoRoot = resolveRepoRoot(options.target);
@@ -6100,6 +7163,7 @@ function main() {
6100
7163
  }
6101
7164
 
6102
7165
  if (command === '--version' || command === '-v' || command === 'version') {
7166
+ maybeSelfUpdateBeforeStatus();
6103
7167
  console.log(packageJson.version);
6104
7168
  return;
6105
7169
  }
@@ -6134,6 +7198,7 @@ function main() {
6134
7198
  if (command === 'prompt') return prompt(rest);
6135
7199
  if (command === 'doctor') return doctor(rest);
6136
7200
  if (command === 'agents') return agents(rest);
7201
+ if (command === 'merge') return merge(rest);
6137
7202
  if (command === 'finish') return finish(rest);
6138
7203
  if (command === 'report') return report(rest);
6139
7204
  if (command === 'protect') return protect(rest);