@imdeadpool/guardex 7.0.15 → 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.
package/README.md CHANGED
@@ -529,6 +529,12 @@ npm pack --dry-run
529
529
  <details>
530
530
  <summary><strong>v7.x</strong></summary>
531
531
 
532
+ ### v7.0.16
533
+ - `gx doctor` now keeps nested repo repair runs visibly progressing, and overlapping integration work stays off the protected base branch instead of trying to merge back on `main`.
534
+ - Cleanup and finish flows are less brittle: `codex-agent` no longer waits on PRs that can never exist, and prune cleanup now walks both managed worktree roots so stale sandboxes get removed consistently.
535
+ - Mirror-sync diagnostics are quieter: when the mirror PAT is unset, Guardex now skips the sync path instead of marking the run red, and shared `ralplan` lanes stay easier to identify during handoff/debugging.
536
+ - Bumped `@imdeadpool/guardex` from `7.0.15` → `7.0.16` after npm rejected a republish over the already-published `7.0.15`.
537
+
532
538
  ### v7.0.15
533
539
  - `gx doctor` no longer blocks recursive nested protected-repo repairs on child PR merge waits; nested sandboxes now force `--no-wait-for-merge` so the parent repair loop can continue.
534
540
  - `gx setup` can now refresh managed files from protected `main` through a temporary sandbox branch/worktree, sync the managed outputs back to the visible base checkout, and prune the sandbox afterward.
@@ -89,6 +89,7 @@ const TEMPLATE_ROOT = path.resolve(__dirname, '..', 'templates');
89
89
  const TEMPLATE_FILES = [
90
90
  'scripts/agent-branch-start.sh',
91
91
  'scripts/agent-branch-finish.sh',
92
+ 'scripts/agent-branch-merge.sh',
92
93
  'scripts/codex-agent.sh',
93
94
  'scripts/guardex-docker-loader.sh',
94
95
  'scripts/review-bot-watch.sh',
@@ -112,6 +113,7 @@ const TEMPLATE_FILES = [
112
113
  const REQUIRED_WORKFLOW_FILES = [
113
114
  'scripts/agent-branch-start.sh',
114
115
  'scripts/agent-branch-finish.sh',
116
+ 'scripts/agent-branch-merge.sh',
115
117
  'scripts/guardex-docker-loader.sh',
116
118
  'scripts/agent-worktree-prune.sh',
117
119
  'scripts/agent-file-locks.py',
@@ -126,6 +128,7 @@ const REQUIRED_PACKAGE_SCRIPTS = {
126
128
  'agent:codex': 'bash ./scripts/codex-agent.sh',
127
129
  'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
128
130
  'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
131
+ 'agent:branch:merge': 'bash ./scripts/agent-branch-merge.sh',
129
132
  'agent:cleanup': 'gx cleanup',
130
133
  'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
131
134
  'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
@@ -149,6 +152,7 @@ const REQUIRED_PACKAGE_SCRIPTS = {
149
152
  const EXECUTABLE_RELATIVE_PATHS = new Set([
150
153
  'scripts/agent-branch-start.sh',
151
154
  'scripts/agent-branch-finish.sh',
155
+ 'scripts/agent-branch-merge.sh',
152
156
  'scripts/codex-agent.sh',
153
157
  'scripts/guardex-docker-loader.sh',
154
158
  'scripts/review-bot-watch.sh',
@@ -171,6 +175,7 @@ const CRITICAL_GUARDRAIL_PATHS = new Set([
171
175
  '.githooks/post-checkout',
172
176
  'scripts/agent-branch-start.sh',
173
177
  'scripts/agent-branch-finish.sh',
178
+ 'scripts/agent-branch-merge.sh',
174
179
  'scripts/agent-worktree-prune.sh',
175
180
  'scripts/codex-agent.sh',
176
181
  'scripts/agent-file-locks.py',
@@ -233,6 +238,7 @@ const SUGGESTIBLE_COMMANDS = [
233
238
  'setup',
234
239
  'doctor',
235
240
  'agents',
241
+ 'merge',
236
242
  'finish',
237
243
  'report',
238
244
  'protect',
@@ -257,6 +263,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
257
263
  ['setup', 'Install, repair, and verify guardrails (flags: --repair, --install-only, --target)'],
258
264
  ['doctor', 'Repair drift + verify (auto-sandboxes on protected main)'],
259
265
  ['protect', 'Manage protected branches (list/add/remove/set/reset)'],
266
+ ['merge', 'Create/reuse an integration lane and merge overlapping agent branches'],
260
267
  ['sync', 'Sync agent branches with origin/<base>'],
261
268
  ['finish', 'Commit + PR + merge completed agent branches (--all, --branch)'],
262
269
  ['cleanup', 'Prune merged/stale agent branches and worktrees'],
@@ -280,6 +287,9 @@ const DEPRECATED_COMMAND_ALIASES = new Map([
280
287
  const AGENT_BOT_DESCRIPTIONS = [
281
288
  ['agents', 'Start/stop review + cleanup bots for this repo'],
282
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;
283
293
 
284
294
  function envFlagIsTruthy(raw) {
285
295
  const lowered = String(raw || '').trim().toLowerCase();
@@ -301,13 +311,14 @@ const AI_SETUP_PROMPT = `GitGuardex (gx) setup checklist for Codex/Claude in thi
301
311
  3) Repair: gx doctor
302
312
  4) Task loop: bash scripts/codex-agent.sh "<task>" "<agent>"
303
313
  or branch-start -> python3 scripts/agent-file-locks.py claim -> branch-finish
304
- 5) Finish: gx finish --all
305
- 6) Cleanup: gx cleanup
306
- 7) OpenSpec: /opsx:propose -> /opsx:apply -> /opsx:archive
307
- 8) Optional: gx protect add release staging
308
- 9) Optional: gx sync --check && gx sync
309
- 10) Review bot: install https://github.com/apps/cr-gpt + set OPENAI_API_KEY
310
- 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
311
322
  `;
312
323
 
313
324
  const AI_SETUP_COMMANDS = `npm i -g @imdeadpool/guardex
@@ -316,6 +327,7 @@ gx setup
316
327
  gx doctor
317
328
  bash scripts/codex-agent.sh "<task>" "<agent>"
318
329
  python3 scripts/agent-file-locks.py claim --branch "<agent-branch>" <file...>
330
+ gx merge --branch "<agent-a>" --branch "<agent-b>"
319
331
  gx finish --all
320
332
  gx cleanup
321
333
  gx protect add release staging
@@ -504,6 +516,113 @@ function run(cmd, args, options = {}) {
504
516
  });
505
517
  }
506
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
+
507
626
  function gitRun(repoRoot, args, { allowFailure = false } = {}) {
508
627
  const result = run('git', ['-C', repoRoot, ...args]);
509
628
  if (!allowFailure && result.status !== 0) {
@@ -1121,7 +1240,7 @@ function parseSetupArgs(rawArgs, defaults) {
1121
1240
  }
1122
1241
 
1123
1242
  function parseDoctorArgs(rawArgs) {
1124
- return parseRepoTraversalArgs(rawArgs, {
1243
+ const doctorDefaults = {
1125
1244
  target: process.cwd(),
1126
1245
  dropStaleLocks: true,
1127
1246
  skipAgents: false,
@@ -1131,7 +1250,24 @@ function parseDoctorArgs(rawArgs) {
1131
1250
  json: false,
1132
1251
  allowProtectedBaseWrite: false,
1133
1252
  waitForMerge: true,
1134
- });
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);
1135
1271
  }
1136
1272
 
1137
1273
  function normalizeWorkspacePath(relativePath) {
@@ -1309,6 +1445,7 @@ function buildSandboxDoctorArgs(options, sandboxTarget) {
1309
1445
  if (options.skipGitignore) args.push('--no-gitignore');
1310
1446
  if (!options.dropStaleLocks) args.push('--keep-stale-locks');
1311
1447
  args.push(options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge');
1448
+ if (options.verboseAutoFinish) args.push('--verbose-auto-finish');
1312
1449
  if (options.json) args.push('--json');
1313
1450
  return args;
1314
1451
  }
@@ -2207,6 +2344,7 @@ function runDoctorInSandbox(options, blocked) {
2207
2344
  postSandboxAutoFinishSummary = autoFinishReadyAgentBranches(blocked.repoRoot, {
2208
2345
  baseBranch: blocked.branch,
2209
2346
  dryRun: options.dryRun,
2347
+ waitForMerge: options.waitForMerge,
2210
2348
  excludeBranches: [metadata.branch],
2211
2349
  });
2212
2350
  }
@@ -2307,16 +2445,10 @@ function runDoctorInSandbox(options, blocked) {
2307
2445
  console.log(`[${TOOL_NAME}] Auto-finish skipped: ${finishResult.note}.`);
2308
2446
  }
2309
2447
 
2310
- if (postSandboxAutoFinishSummary.enabled) {
2311
- console.log(
2312
- `[${TOOL_NAME}] Auto-finish sweep (base=${blocked.branch}): attempted=${postSandboxAutoFinishSummary.attempted}, completed=${postSandboxAutoFinishSummary.completed}, skipped=${postSandboxAutoFinishSummary.skipped}, failed=${postSandboxAutoFinishSummary.failed}`,
2313
- );
2314
- for (const detail of postSandboxAutoFinishSummary.details) {
2315
- console.log(`[${TOOL_NAME}] ${detail}`);
2316
- }
2317
- } else if (postSandboxAutoFinishSummary.details.length > 0) {
2318
- console.log(`[${TOOL_NAME}] ${postSandboxAutoFinishSummary.details[0]}`);
2319
- }
2448
+ printAutoFinishSummary(postSandboxAutoFinishSummary, {
2449
+ baseBranch: blocked.branch,
2450
+ verbose: options.verboseAutoFinish,
2451
+ });
2320
2452
  if (omxScaffoldSyncResult.status === 'synced') {
2321
2453
  console.log(`[${TOOL_NAME}] Synced .omx scaffold back to protected branch workspace.`);
2322
2454
  } else if (omxScaffoldSyncResult.status === 'unchanged') {
@@ -2871,6 +3003,7 @@ function hasSignificantWorkingTreeChanges(worktreePath) {
2871
3003
  function autoFinishReadyAgentBranches(repoRoot, options = {}) {
2872
3004
  const baseBranch = String(options.baseBranch || '').trim();
2873
3005
  const dryRun = Boolean(options.dryRun);
3006
+ const waitForMerge = options.waitForMerge !== false;
2874
3007
  const excludedBranches = new Set(
2875
3008
  Array.isArray(options.excludeBranches)
2876
3009
  ? options.excludeBranches.map((branch) => String(branch || '').trim()).filter(Boolean)
@@ -2989,7 +3122,7 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
2989
3122
  '--base',
2990
3123
  baseBranch,
2991
3124
  '--via-pr',
2992
- '--wait-for-merge',
3125
+ waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge',
2993
3126
  '--cleanup',
2994
3127
  ];
2995
3128
  const finishResult = run('bash', finishArgs, { cwd: repoRoot });
@@ -3419,6 +3552,82 @@ function parseCleanupArgs(rawArgs) {
3419
3552
  return options;
3420
3553
  }
3421
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
+
3422
3631
  function parseFinishArgs(rawArgs) {
3423
3632
  const options = {
3424
3633
  target: process.cwd(),
@@ -5127,31 +5336,38 @@ function doctor(rawArgs) {
5127
5336
 
5128
5337
  const repoResults = [];
5129
5338
  let aggregateExitCode = 0;
5130
- 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}`;
5131
5342
  if (!options.json) {
5132
- console.log(`[${TOOL_NAME}] ── Doctor target: ${repoPath} ──`);
5343
+ console.log(`[${TOOL_NAME}] ── Doctor target: ${repoPath} [${progressLabel}] ──`);
5133
5344
  }
5134
5345
 
5135
- const nestedResult = run(
5136
- process.execPath,
5137
- [
5138
- path.resolve(__filename),
5139
- 'doctor',
5140
- '--single-repo',
5141
- '--target',
5142
- repoPath,
5143
- ...(options.dropStaleLocks ? [] : ['--keep-stale-locks']),
5144
- ...(options.skipAgents ? ['--skip-agents'] : []),
5145
- ...(options.skipPackageJson ? ['--skip-package-json'] : []),
5146
- ...(options.skipGitignore ? ['--no-gitignore'] : []),
5147
- ...(options.dryRun ? ['--dry-run'] : []),
5148
- // Recursive child doctor runs should report pending PR state immediately instead of blocking the parent loop.
5149
- '--no-wait-for-merge',
5150
- ...(options.json ? ['--json'] : []),
5151
- ...(options.allowProtectedBaseWrite ? ['--allow-protected-base-write'] : []),
5152
- ],
5153
- { cwd: topRepoRoot },
5154
- );
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
+ });
5155
5371
  if (isSpawnFailure(nestedResult)) {
5156
5372
  throw nestedResult.error;
5157
5373
  }
@@ -5181,9 +5397,12 @@ function doctor(rawArgs) {
5181
5397
  },
5182
5398
  );
5183
5399
  } else {
5184
- if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
5185
- if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
5186
- 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
+ }
5187
5406
  }
5188
5407
  }
5189
5408
 
@@ -5232,6 +5451,7 @@ function doctor(rawArgs) {
5232
5451
  : autoFinishReadyAgentBranches(scanResult.repoRoot, {
5233
5452
  baseBranch: currentBaseBranch,
5234
5453
  dryRun: singleRepoOptions.dryRun,
5454
+ waitForMerge: singleRepoOptions.waitForMerge,
5235
5455
  });
5236
5456
  const safe = scanResult.guardexEnabled === false || (scanResult.errors === 0 && scanResult.warnings === 0);
5237
5457
  const musafe = safe;
@@ -5273,16 +5493,10 @@ function doctor(rawArgs) {
5273
5493
  setExitCodeFromScan(scanResult);
5274
5494
  return;
5275
5495
  }
5276
- if (autoFinishSummary.enabled) {
5277
- console.log(
5278
- `[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
5279
- );
5280
- for (const detail of autoFinishSummary.details) {
5281
- console.log(`[${TOOL_NAME}] ${detail}`);
5282
- }
5283
- } else if (autoFinishSummary.details.length > 0) {
5284
- console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
5285
- }
5496
+ printAutoFinishSummary(autoFinishSummary, {
5497
+ baseBranch: currentBaseBranch,
5498
+ verbose: singleRepoOptions.verboseAutoFinish,
5499
+ });
5286
5500
  if (safe) {
5287
5501
  console.log(`[${TOOL_NAME}] ✅ Repo is fully safe.`);
5288
5502
  } else {
@@ -6458,6 +6672,46 @@ function cleanup(rawArgs) {
6458
6672
  process.exitCode = 0;
6459
6673
  }
6460
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
+
6461
6715
  function finish(rawArgs) {
6462
6716
  const options = parseFinishArgs(rawArgs);
6463
6717
  const repoRoot = resolveRepoRoot(options.target);
@@ -6944,6 +7198,7 @@ function main() {
6944
7198
  if (command === 'prompt') return prompt(rest);
6945
7199
  if (command === 'doctor') return doctor(rest);
6946
7200
  if (command === 'agents') return agents(rest);
7201
+ if (command === 'merge') return merge(rest);
6947
7202
  if (command === 'finish') return finish(rest);
6948
7203
  if (command === 'report') return report(rest);
6949
7204
  if (command === 'protect') return protect(rest);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imdeadpool/guardex",
3
- "version": "7.0.15",
3
+ "version": "7.0.16",
4
4
  "description": "GitGuardex: hardened multi-agent git guardrails for parallel agent work.",
5
5
  "license": "MIT",
6
6
  "preferGlobal": true,
@@ -15,6 +15,7 @@
15
15
  "agent:codex": "bash ./scripts/codex-agent.sh",
16
16
  "agent:branch:start": "bash ./scripts/agent-branch-start.sh",
17
17
  "agent:branch:finish": "bash ./scripts/agent-branch-finish.sh",
18
+ "agent:branch:merge": "bash ./scripts/agent-branch-merge.sh",
18
19
  "agent:cleanup": "gx cleanup",
19
20
  "agent:hooks:install": "bash ./scripts/install-agent-git-hooks.sh",
20
21
  "agent:locks:claim": "python3 ./scripts/agent-file-locks.py claim",
@@ -0,0 +1,421 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ BASE_BRANCH=""
5
+ BASE_BRANCH_EXPLICIT=0
6
+ TARGET_BRANCH=""
7
+ TASK_NAME=""
8
+ AGENT_NAME="${GUARDEX_MERGE_AGENT_NAME:-codex}"
9
+ declare -a SOURCE_BRANCHES=()
10
+
11
+ usage() {
12
+ cat <<'EOF'
13
+ Usage: scripts/agent-branch-merge.sh --branch <agent/...> [--branch <agent/...> ...] [--into <agent/...>] [--task <task>] [--agent <agent>] [--base <branch>]
14
+
15
+ Examples:
16
+ bash scripts/agent-branch-merge.sh --branch agent/codex/ui-a --branch agent/codex/ui-b
17
+ bash scripts/agent-branch-merge.sh --into agent/codex/owner-lane --branch agent/codex/helper-a --branch agent/codex/helper-b
18
+ EOF
19
+ }
20
+
21
+ sanitize_slug() {
22
+ local raw="$1"
23
+ local fallback="${2:-merge-agent-branches}"
24
+ local slug
25
+ slug="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')"
26
+ if [[ -z "$slug" ]]; then
27
+ slug="$fallback"
28
+ fi
29
+ printf '%s' "$slug"
30
+ }
31
+
32
+ resolve_base_branch() {
33
+ local repo="$1"
34
+ local explicit_target="$2"
35
+ local configured=""
36
+ local branch_base=""
37
+
38
+ if [[ -n "$explicit_target" ]]; then
39
+ branch_base="$(git -C "$repo" config --get "branch.${explicit_target}.guardexBase" || true)"
40
+ if [[ -n "$branch_base" ]]; then
41
+ printf '%s' "$branch_base"
42
+ return 0
43
+ fi
44
+ fi
45
+
46
+ configured="$(git -C "$repo" config --get multiagent.baseBranch || true)"
47
+ if [[ -n "$configured" ]]; then
48
+ printf '%s' "$configured"
49
+ return 0
50
+ fi
51
+
52
+ for fallback in dev main master; do
53
+ if git -C "$repo" show-ref --verify --quiet "refs/heads/${fallback}" \
54
+ || git -C "$repo" show-ref --verify --quiet "refs/remotes/origin/${fallback}"; then
55
+ printf '%s' "$fallback"
56
+ return 0
57
+ fi
58
+ done
59
+
60
+ printf '%s' "dev"
61
+ }
62
+
63
+ get_worktree_for_branch() {
64
+ local repo="$1"
65
+ local branch="$2"
66
+ git -C "$repo" worktree list --porcelain | awk -v target="refs/heads/${branch}" '
67
+ $1 == "worktree" { wt = $2 }
68
+ $1 == "branch" && $2 == target { print wt; exit }
69
+ '
70
+ }
71
+
72
+ is_clean_worktree() {
73
+ local wt="$1"
74
+ git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \
75
+ && git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \
76
+ && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]]
77
+ }
78
+
79
+ has_in_progress_git_op() {
80
+ local wt="$1"
81
+ local git_dir=""
82
+ git_dir="$(git -C "$wt" rev-parse --git-dir 2>/dev/null || true)"
83
+ if [[ -z "$git_dir" ]]; then
84
+ return 1
85
+ fi
86
+ if [[ "$git_dir" != /* ]]; then
87
+ git_dir="$(cd "$wt/$git_dir" 2>/dev/null && pwd -P || true)"
88
+ fi
89
+ if [[ -z "$git_dir" ]]; then
90
+ return 1
91
+ fi
92
+ [[ -f "${git_dir}/MERGE_HEAD" || -d "${git_dir}/rebase-merge" || -d "${git_dir}/rebase-apply" ]]
93
+ }
94
+
95
+ select_unique_worktree_path() {
96
+ local root="$1"
97
+ local name="$2"
98
+ local candidate="${root}/${name}"
99
+ local suffix=2
100
+ while [[ -e "$candidate" ]]; do
101
+ candidate="${root}/${name}-${suffix}"
102
+ suffix=$((suffix + 1))
103
+ done
104
+ printf '%s' "$candidate"
105
+ }
106
+
107
+ branch_exists() {
108
+ local repo="$1"
109
+ local branch="$2"
110
+ git -C "$repo" show-ref --verify --quiet "refs/heads/${branch}"
111
+ }
112
+
113
+ branch_is_agent_lane() {
114
+ local branch="$1"
115
+ [[ "$branch" == agent/* ]]
116
+ }
117
+
118
+ array_contains() {
119
+ local needle="$1"
120
+ shift || true
121
+ local item
122
+ for item in "$@"; do
123
+ if [[ "$item" == "$needle" ]]; then
124
+ return 0
125
+ fi
126
+ done
127
+ return 1
128
+ }
129
+
130
+ collect_branch_files() {
131
+ local repo="$1"
132
+ local base_ref="$2"
133
+ local branch="$3"
134
+ git -C "$repo" diff --name-only "${base_ref}...${branch}" -- . ":(exclude).omx/state/agent-file-locks.json" 2>/dev/null || true
135
+ }
136
+
137
+ while [[ $# -gt 0 ]]; do
138
+ case "$1" in
139
+ --base)
140
+ BASE_BRANCH="${2:-}"
141
+ BASE_BRANCH_EXPLICIT=1
142
+ shift 2
143
+ ;;
144
+ --into)
145
+ TARGET_BRANCH="${2:-}"
146
+ shift 2
147
+ ;;
148
+ --branch)
149
+ SOURCE_BRANCHES+=("${2:-}")
150
+ shift 2
151
+ ;;
152
+ --task)
153
+ TASK_NAME="${2:-}"
154
+ shift 2
155
+ ;;
156
+ --agent)
157
+ AGENT_NAME="${2:-codex}"
158
+ shift 2
159
+ ;;
160
+ -h|--help)
161
+ usage
162
+ exit 0
163
+ ;;
164
+ *)
165
+ echo "[agent-branch-merge] Unknown argument: $1" >&2
166
+ usage >&2
167
+ exit 1
168
+ ;;
169
+ esac
170
+ done
171
+
172
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
173
+ echo "[agent-branch-merge] Not inside a git repository." >&2
174
+ exit 1
175
+ fi
176
+
177
+ repo_root="$(git rev-parse --show-toplevel)"
178
+ common_git_dir_raw="$(git -C "$repo_root" rev-parse --git-common-dir)"
179
+ if [[ "$common_git_dir_raw" == /* ]]; then
180
+ common_git_dir="$common_git_dir_raw"
181
+ else
182
+ common_git_dir="$(cd "$repo_root/$common_git_dir_raw" && pwd -P)"
183
+ fi
184
+ repo_common_root="$(cd "$common_git_dir/.." && pwd -P)"
185
+ agent_worktree_root="${repo_common_root}/.omx/agent-worktrees"
186
+ mkdir -p "$agent_worktree_root"
187
+
188
+ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
189
+ echo "[agent-branch-merge] --base requires a branch value." >&2
190
+ exit 1
191
+ fi
192
+
193
+ if [[ -z "$TARGET_BRANCH" && "${#SOURCE_BRANCHES[@]}" -lt 1 ]]; then
194
+ echo "[agent-branch-merge] Provide at least one --branch <agent/...> source lane." >&2
195
+ exit 1
196
+ fi
197
+
198
+ if [[ -n "$TARGET_BRANCH" ]] && ! branch_is_agent_lane "$TARGET_BRANCH"; then
199
+ echo "[agent-branch-merge] --into must reference an agent/* branch: ${TARGET_BRANCH}" >&2
200
+ exit 1
201
+ fi
202
+
203
+ deduped_sources=()
204
+ for branch in "${SOURCE_BRANCHES[@]}"; do
205
+ if [[ -z "$branch" ]]; then
206
+ echo "[agent-branch-merge] --branch requires an agent/* branch value." >&2
207
+ exit 1
208
+ fi
209
+ if ! branch_is_agent_lane "$branch"; then
210
+ echo "[agent-branch-merge] Source branch must be agent/*: ${branch}" >&2
211
+ exit 1
212
+ fi
213
+ if ! branch_exists "$repo_root" "$branch"; then
214
+ echo "[agent-branch-merge] Local source branch not found: ${branch}" >&2
215
+ exit 1
216
+ fi
217
+ if ! array_contains "$branch" "${deduped_sources[@]}"; then
218
+ deduped_sources+=("$branch")
219
+ fi
220
+ done
221
+ SOURCE_BRANCHES=("${deduped_sources[@]}")
222
+
223
+ if [[ "${#SOURCE_BRANCHES[@]}" -eq 0 ]]; then
224
+ echo "[agent-branch-merge] No unique source branches were provided." >&2
225
+ exit 1
226
+ fi
227
+
228
+ if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
229
+ BASE_BRANCH="$(resolve_base_branch "$repo_root" "$TARGET_BRANCH")"
230
+ fi
231
+
232
+ if [[ -z "$BASE_BRANCH" ]]; then
233
+ echo "[agent-branch-merge] Unable to resolve a base branch." >&2
234
+ exit 1
235
+ fi
236
+
237
+ start_ref="$BASE_BRANCH"
238
+ if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
239
+ git -C "$repo_root" fetch origin "$BASE_BRANCH" --quiet
240
+ start_ref="origin/${BASE_BRANCH}"
241
+ elif ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then
242
+ echo "[agent-branch-merge] Base branch not found locally or on origin: ${BASE_BRANCH}" >&2
243
+ exit 1
244
+ fi
245
+
246
+ target_worktree=""
247
+ target_created=0
248
+
249
+ if [[ -z "$TARGET_BRANCH" ]]; then
250
+ if [[ -z "$TASK_NAME" ]]; then
251
+ first_hint="$(printf '%s' "${SOURCE_BRANCHES[0]}" | sed -E 's#^agent/[^/]+/##; s#^agent/##')"
252
+ source_count="${#SOURCE_BRANCHES[@]}"
253
+ if [[ "$source_count" -gt 1 ]]; then
254
+ TASK_NAME="$(sanitize_slug "merge-${first_hint}-and-$((source_count - 1))-more" "merge-agent-branches")"
255
+ else
256
+ TASK_NAME="$(sanitize_slug "merge-${first_hint}" "merge-agent-branches")"
257
+ fi
258
+ else
259
+ TASK_NAME="$(sanitize_slug "$TASK_NAME" "merge-agent-branches")"
260
+ fi
261
+
262
+ start_output=""
263
+ if ! start_output="$(
264
+ cd "$repo_root"
265
+ env GUARDEX_OPENSPEC_AUTO_INIT=1 bash "scripts/agent-branch-start.sh" "$TASK_NAME" "$AGENT_NAME" "$BASE_BRANCH" 2>&1
266
+ )"; then
267
+ printf '%s\n' "$start_output" >&2
268
+ exit 1
269
+ fi
270
+
271
+ printf '%s\n' "$start_output"
272
+ TARGET_BRANCH="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Created branch: //p' | head -n 1)"
273
+ target_worktree="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | head -n 1)"
274
+ if [[ -z "$TARGET_BRANCH" || -z "$target_worktree" ]]; then
275
+ echo "[agent-branch-merge] Unable to parse target branch/worktree from agent-branch-start output." >&2
276
+ exit 1
277
+ fi
278
+ target_created=1
279
+ else
280
+ if ! branch_exists "$repo_root" "$TARGET_BRANCH"; then
281
+ echo "[agent-branch-merge] Target branch not found: ${TARGET_BRANCH}" >&2
282
+ exit 1
283
+ fi
284
+
285
+ target_worktree="$(get_worktree_for_branch "$repo_root" "$TARGET_BRANCH")"
286
+ if [[ -z "$target_worktree" ]]; then
287
+ target_worktree="$(select_unique_worktree_path "$agent_worktree_root" "${TARGET_BRANCH//\//__}")"
288
+ git -C "$repo_root" worktree add "$target_worktree" "$TARGET_BRANCH" >/dev/null
289
+ target_created=1
290
+ echo "[agent-branch-merge] Attached worktree for target branch '${TARGET_BRANCH}': ${target_worktree}"
291
+ fi
292
+ fi
293
+
294
+ if [[ "$TARGET_BRANCH" == "$BASE_BRANCH" ]]; then
295
+ echo "[agent-branch-merge] Target branch must not equal the protected base branch '${BASE_BRANCH}'." >&2
296
+ exit 1
297
+ fi
298
+
299
+ if ! is_clean_worktree "$target_worktree"; then
300
+ if [[ "$target_created" -eq 1 ]]; then
301
+ echo "[agent-branch-merge] Target worktree has freshly generated scaffold changes; continuing inside the new integration lane."
302
+ else
303
+ echo "[agent-branch-merge] Target worktree is not clean: ${target_worktree}" >&2
304
+ echo "[agent-branch-merge] Commit, stash, or discard local changes before merging agent lanes." >&2
305
+ exit 1
306
+ fi
307
+ fi
308
+
309
+ if has_in_progress_git_op "$target_worktree"; then
310
+ echo "[agent-branch-merge] Target worktree has an in-progress merge/rebase: ${target_worktree}" >&2
311
+ echo "[agent-branch-merge] Resolve or abort that git operation before running the merge workflow." >&2
312
+ exit 1
313
+ fi
314
+
315
+ for source_branch in "${SOURCE_BRANCHES[@]}"; do
316
+ if [[ "$source_branch" == "$TARGET_BRANCH" ]]; then
317
+ echo "[agent-branch-merge] Source branch list includes the target branch: ${source_branch}" >&2
318
+ exit 1
319
+ fi
320
+ source_worktree="$(get_worktree_for_branch "$repo_root" "$source_branch")"
321
+ if [[ -n "$source_worktree" ]] && ! is_clean_worktree "$source_worktree"; then
322
+ echo "[agent-branch-merge] Source worktree is not clean for '${source_branch}': ${source_worktree}" >&2
323
+ echo "[agent-branch-merge] Commit or stash source-lane changes before integration." >&2
324
+ exit 1
325
+ fi
326
+ done
327
+
328
+ pending_branches=()
329
+ for source_branch in "${SOURCE_BRANCHES[@]}"; do
330
+ if git -C "$repo_root" merge-base --is-ancestor "$source_branch" "$TARGET_BRANCH" >/dev/null 2>&1; then
331
+ echo "[agent-branch-merge] Skipping '${source_branch}' because it is already integrated into '${TARGET_BRANCH}'."
332
+ continue
333
+ fi
334
+ pending_branches+=("$source_branch")
335
+ done
336
+
337
+ if [[ "${#pending_branches[@]}" -eq 0 ]]; then
338
+ echo "[agent-branch-merge] No pending source branches remain for target '${TARGET_BRANCH}'."
339
+ echo "[agent-branch-merge] Target worktree: ${target_worktree}"
340
+ exit 0
341
+ fi
342
+
343
+ declare -A file_to_branches=()
344
+ declare -a overlap_files=()
345
+ for source_branch in "${pending_branches[@]}"; do
346
+ while IFS= read -r changed_file; do
347
+ [[ -z "$changed_file" ]] && continue
348
+ existing="${file_to_branches[$changed_file]:-}"
349
+ if [[ -z "$existing" ]]; then
350
+ file_to_branches["$changed_file"]="$source_branch"
351
+ continue
352
+ fi
353
+ if [[ ",${existing}," == *",${source_branch},"* ]]; then
354
+ continue
355
+ fi
356
+ file_to_branches["$changed_file"]="${existing},${source_branch}"
357
+ if ! array_contains "$changed_file" "${overlap_files[@]}"; then
358
+ overlap_files+=("$changed_file")
359
+ fi
360
+ done < <(collect_branch_files "$repo_root" "$start_ref" "$source_branch")
361
+ done
362
+
363
+ echo "[agent-branch-merge] Target branch: ${TARGET_BRANCH}"
364
+ echo "[agent-branch-merge] Target worktree: ${target_worktree}"
365
+ echo "[agent-branch-merge] Base branch: ${BASE_BRANCH} (${start_ref})"
366
+ echo "[agent-branch-merge] Merge order: ${pending_branches[*]}"
367
+
368
+ if [[ "${#overlap_files[@]}" -gt 0 ]]; then
369
+ echo "[agent-branch-merge] Overlapping changed files detected across requested branches:"
370
+ for overlap_file in "${overlap_files[@]}"; do
371
+ branches_csv="${file_to_branches[$overlap_file]}"
372
+ branches_display="$(printf '%s' "$branches_csv" | sed 's/,/, /g')"
373
+ echo " - ${overlap_file} <- ${branches_display}"
374
+ done
375
+ else
376
+ echo "[agent-branch-merge] No overlapping changed files detected across requested branches."
377
+ fi
378
+
379
+ for index in "${!pending_branches[@]}"; do
380
+ source_branch="${pending_branches[$index]}"
381
+ echo "[agent-branch-merge] Merging '${source_branch}' into '${TARGET_BRANCH}'..."
382
+ if git -C "$target_worktree" merge --no-ff --no-edit "$source_branch"; then
383
+ echo "[agent-branch-merge] Merged '${source_branch}'."
384
+ continue
385
+ fi
386
+
387
+ conflict_files="$(git -C "$target_worktree" diff --name-only --diff-filter=U || true)"
388
+ echo "[agent-branch-merge] Merge conflict detected while merging '${source_branch}' into '${TARGET_BRANCH}'." >&2
389
+ echo "[agent-branch-merge] Target worktree: ${target_worktree}" >&2
390
+ if [[ -n "$conflict_files" ]]; then
391
+ echo "[agent-branch-merge] Conflicting files:" >&2
392
+ while IFS= read -r conflict_file; do
393
+ [[ -n "$conflict_file" ]] && echo " - ${conflict_file}" >&2
394
+ done <<< "$conflict_files"
395
+ fi
396
+ echo "[agent-branch-merge] Resolve or abort inside the integration worktree:" >&2
397
+ echo " cd \"${target_worktree}\"" >&2
398
+ echo " git status" >&2
399
+ echo " git add <resolved-files> && git commit" >&2
400
+ echo " # or: git merge --abort" >&2
401
+
402
+ remaining_branches=("${pending_branches[@]:$((index + 1))}")
403
+ if [[ "${#remaining_branches[@]}" -gt 0 ]]; then
404
+ echo "[agent-branch-merge] Remaining branches:" >&2
405
+ for remaining in "${remaining_branches[@]}"; do
406
+ echo " - ${remaining}" >&2
407
+ done
408
+ resume_cmd="gx merge --into ${TARGET_BRANCH} --base ${BASE_BRANCH}"
409
+ for remaining in "${remaining_branches[@]}"; do
410
+ resume_cmd="${resume_cmd} --branch ${remaining}"
411
+ done
412
+ echo "[agent-branch-merge] Resume after resolving with: ${resume_cmd}" >&2
413
+ fi
414
+ exit 1
415
+ done
416
+
417
+ echo "[agent-branch-merge] Merge sequence complete for '${TARGET_BRANCH}'."
418
+ if [[ "$target_created" -eq 1 ]]; then
419
+ echo "[agent-branch-merge] Review and verify in '${target_worktree}', then finish the integration branch when ready."
420
+ fi
421
+ echo "[agent-branch-merge] Next step: bash scripts/agent-branch-finish.sh --branch \"${TARGET_BRANCH}\" --base \"${BASE_BRANCH}\" --via-pr --wait-for-merge --cleanup"
@@ -11,6 +11,7 @@ OPENSPEC_AUTO_INIT_RAW="${GUARDEX_OPENSPEC_AUTO_INIT:-false}"
11
11
  OPENSPEC_PLAN_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_PLAN_SLUG:-}"
12
12
  OPENSPEC_CHANGE_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CHANGE_SLUG:-}"
13
13
  OPENSPEC_CAPABILITY_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CAPABILITY_SLUG:-}"
14
+ OPENSPEC_MASTERPLAN_LABEL_RAW="${GUARDEX_OPENSPEC_MASTERPLAN_LABEL-masterplan}"
14
15
  PRINT_NAME_ONLY=0
15
16
  POSITIONAL_ARGS=()
16
17
 
@@ -226,13 +227,35 @@ normalize_bool() {
226
227
 
227
228
  OPENSPEC_AUTO_INIT="$(normalize_bool "$OPENSPEC_AUTO_INIT_RAW" "1")"
228
229
 
230
+ resolve_openspec_masterplan_label() {
231
+ local raw="${OPENSPEC_MASTERPLAN_LABEL_RAW:-}"
232
+ local label
233
+
234
+ if [[ "$OPENSPEC_AUTO_INIT" -ne 1 ]] || [[ -z "$raw" ]]; then
235
+ printf ''
236
+ return 0
237
+ fi
238
+
239
+ label="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')"
240
+ printf '%s' "$label"
241
+ }
242
+
229
243
  resolve_openspec_plan_slug() {
230
244
  local branch_name="$1"
231
- local task_slug="$2"
245
+ local agent_slug="$2"
246
+ local task_slug="$3"
247
+ local masterplan_label=""
248
+ local branch_leaf=""
232
249
  if [[ -n "$OPENSPEC_PLAN_SLUG_OVERRIDE" ]]; then
233
250
  sanitize_slug "$OPENSPEC_PLAN_SLUG_OVERRIDE" "$task_slug"
234
251
  return 0
235
252
  fi
253
+ masterplan_label="$(resolve_openspec_masterplan_label)"
254
+ if [[ -n "$masterplan_label" ]] && [[ "$branch_name" == "agent/${agent_slug}/"* ]]; then
255
+ branch_leaf="${branch_name#agent/${agent_slug}/}"
256
+ sanitize_slug "agent-${agent_slug}-${masterplan_label}-${branch_leaf}" "$task_slug"
257
+ return 0
258
+ fi
236
259
  sanitize_slug "${branch_name//\//-}" "$task_slug"
237
260
  }
238
261
 
@@ -255,6 +278,22 @@ resolve_openspec_capability_slug() {
255
278
  sanitize_slug "$task_slug" "general-behavior"
256
279
  }
257
280
 
281
+ resolve_worktree_leaf() {
282
+ local branch_name="$1"
283
+ local agent_slug="$2"
284
+ local masterplan_label=""
285
+ local branch_leaf=""
286
+
287
+ masterplan_label="$(resolve_openspec_masterplan_label)"
288
+ if [[ -n "$masterplan_label" ]] && [[ "$branch_name" == "agent/${agent_slug}/"* ]]; then
289
+ branch_leaf="${branch_name#agent/${agent_slug}/}"
290
+ printf 'agent__%s__%s__%s' "$agent_slug" "$masterplan_label" "$branch_leaf"
291
+ return 0
292
+ fi
293
+
294
+ printf '%s' "${branch_name//\//__}"
295
+ }
296
+
258
297
  has_local_changes() {
259
298
  local root="$1"
260
299
  if ! git -C "$root" diff --quiet; then
@@ -497,8 +536,9 @@ done
497
536
 
498
537
  worktree_root="${repo_root}/${WORKTREE_ROOT_REL}"
499
538
  mkdir -p "$worktree_root"
500
- worktree_path="${worktree_root}/${branch_name//\//__}"
501
- openspec_plan_slug="$(resolve_openspec_plan_slug "$branch_name" "$task_slug")"
539
+ worktree_leaf="$(resolve_worktree_leaf "$branch_name" "$agent_slug")"
540
+ worktree_path="${worktree_root}/${worktree_leaf}"
541
+ openspec_plan_slug="$(resolve_openspec_plan_slug "$branch_name" "$agent_slug" "$task_slug")"
502
542
  openspec_change_slug="$(resolve_openspec_change_slug "$branch_name" "$task_slug")"
503
543
  openspec_capability_slug="$(resolve_openspec_capability_slug "$task_slug")"
504
544
 
@@ -14,6 +14,7 @@ OPENSPEC_AUTO_INIT_RAW="${GUARDEX_OPENSPEC_AUTO_INIT:-true}"
14
14
  OPENSPEC_PLAN_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_PLAN_SLUG:-}"
15
15
  OPENSPEC_CHANGE_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CHANGE_SLUG:-}"
16
16
  OPENSPEC_CAPABILITY_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CAPABILITY_SLUG:-}"
17
+ OPENSPEC_MASTERPLAN_LABEL_RAW="${GUARDEX_OPENSPEC_MASTERPLAN_LABEL-masterplan}"
17
18
 
18
19
  normalize_bool() {
19
20
  local raw="${1:-}"
@@ -34,6 +35,19 @@ AUTO_CLEANUP="$(normalize_bool "$AUTO_CLEANUP_RAW" "1")"
34
35
  AUTO_WAIT_FOR_MERGE="$(normalize_bool "$AUTO_WAIT_FOR_MERGE_RAW" "1")"
35
36
  OPENSPEC_AUTO_INIT="$(normalize_bool "$OPENSPEC_AUTO_INIT_RAW" "1")"
36
37
 
38
+ resolve_openspec_masterplan_label() {
39
+ local raw="${OPENSPEC_MASTERPLAN_LABEL_RAW:-}"
40
+ local label
41
+
42
+ if [[ "$OPENSPEC_AUTO_INIT" -ne 1 ]] || [[ -z "$raw" ]]; then
43
+ printf ''
44
+ return 0
45
+ fi
46
+
47
+ label="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')"
48
+ printf '%s' "$label"
49
+ }
50
+
37
51
  if [[ -n "$BASE_BRANCH" ]]; then
38
52
  BASE_BRANCH_EXPLICIT=1
39
53
  fi
@@ -161,11 +175,21 @@ sanitize_slug() {
161
175
  resolve_openspec_plan_slug() {
162
176
  local branch_name="$1"
163
177
  local task_slug
178
+ local masterplan_label=""
179
+ local branch_role=""
180
+ local branch_leaf=""
164
181
  task_slug="$(sanitize_slug "$TASK_NAME" "task")"
165
182
  if [[ -n "$OPENSPEC_PLAN_SLUG_OVERRIDE" ]]; then
166
183
  sanitize_slug "$OPENSPEC_PLAN_SLUG_OVERRIDE" "$task_slug"
167
184
  return 0
168
185
  fi
186
+ masterplan_label="$(resolve_openspec_masterplan_label)"
187
+ if [[ -n "$masterplan_label" ]] && [[ "$branch_name" =~ ^agent/([^/]+)/(.+)$ ]]; then
188
+ branch_role="${BASH_REMATCH[1]}"
189
+ branch_leaf="${BASH_REMATCH[2]}"
190
+ sanitize_slug "agent-${branch_role}-${masterplan_label}-${branch_leaf}" "$task_slug"
191
+ return 0
192
+ fi
169
193
  sanitize_slug "${branch_name//\//-}" "$task_slug"
170
194
  }
171
195
 
@@ -190,6 +214,23 @@ resolve_openspec_capability_slug() {
190
214
  sanitize_slug "$task_slug" "general-behavior"
191
215
  }
192
216
 
217
+ resolve_worktree_leaf() {
218
+ local branch_name="$1"
219
+ local masterplan_label=""
220
+ local branch_role=""
221
+ local branch_leaf=""
222
+
223
+ masterplan_label="$(resolve_openspec_masterplan_label)"
224
+ if [[ -n "$masterplan_label" ]] && [[ "$branch_name" =~ ^agent/([^/]+)/(.+)$ ]]; then
225
+ branch_role="${BASH_REMATCH[1]}"
226
+ branch_leaf="${BASH_REMATCH[2]}"
227
+ printf 'agent__%s__%s__%s' "$branch_role" "$masterplan_label" "$branch_leaf"
228
+ return 0
229
+ fi
230
+
231
+ printf '%s' "${branch_name//\//__}"
232
+ }
233
+
193
234
  hydrate_local_helper_in_worktree() {
194
235
  local worktree="$1"
195
236
  local relative_path="$2"
@@ -314,7 +355,7 @@ start_sandbox_fallback() {
314
355
 
315
356
  worktree_root="${repo_root}/.omx/agent-worktrees"
316
357
  mkdir -p "$worktree_root"
317
- worktree_path="${worktree_root}/${branch_name//\//__}"
358
+ worktree_path="${worktree_root}/$(resolve_worktree_leaf "$branch_name")"
318
359
  if [[ -e "$worktree_path" ]]; then
319
360
  echo "[codex-agent] Fallback worktree path already exists: $worktree_path" >&2
320
361
  return 1
@@ -346,7 +387,11 @@ initial_repo_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/nu
346
387
  start_output=""
347
388
  start_status=0
348
389
  set +e
349
- start_output="$(GUARDEX_OPENSPEC_AUTO_INIT=0 bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}" 2>&1)"
390
+ start_output="$(
391
+ GUARDEX_OPENSPEC_AUTO_INIT="$OPENSPEC_AUTO_INIT" \
392
+ GUARDEX_OPENSPEC_MASTERPLAN_LABEL="$OPENSPEC_MASTERPLAN_LABEL_RAW" \
393
+ bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}" 2>&1
394
+ )"
350
395
  start_status=$?
351
396
  set -e
352
397
 
@@ -54,6 +54,10 @@ write_if_missing "$PLAN_DIR/README.md" "# Plan Workspace: ${PLAN_SLUG}
54
54
 
55
55
  Durable pre-implementation planning workspace.
56
56
 
57
+ Each role folder includes a copyable \`prompt.md\` for joined Codex helpers.
58
+ Helpers reuse the owner branch/worktree, claim the role files they touch, and
59
+ leave PR merge + sandbox cleanup to the owner change lane.
60
+
57
61
  Use this command to update checkpoints:
58
62
 
59
63
  \`\`\`bash
@@ -89,10 +93,39 @@ for role in "${ROLES[@]}"; do
89
93
  write_if_missing "$ROLE_DIR/README.md" "# ${role}
90
94
 
91
95
  Role workspace for \`${role}\`.
96
+ "
97
+
98
+ write_if_missing "$ROLE_DIR/prompt.md" "# ${role} prompt
99
+
100
+ You are the \`${role}\` lane for shared plan \`${PLAN_SLUG}\`.
101
+
102
+ ## Scope
103
+
104
+ - Work inside \`openspec/plan/${PLAN_SLUG}/${role}/\` plus directly-related shared plan files you explicitly claim.
105
+ - Reuse the owner's branch/worktree instead of creating a separate sandbox unless the owner says otherwise.
106
+
107
+ ## Ownership
108
+
109
+ - Before editing, claim this role's files in the shared owner lane:
110
+ \`python3 scripts/agent-file-locks.py claim --branch <owner-branch> openspec/plan/${PLAN_SLUG}/${role}/README.md openspec/plan/${PLAN_SLUG}/${role}/prompt.md openspec/plan/${PLAN_SLUG}/${role}/tasks.md openspec/plan/${PLAN_SLUG}/checkpoints.md\`
111
+ - Record branch, worktree, and scope in \`tasks.md\`.
112
+ - Do not change another role's files without reassignment.
113
+
114
+ ## Deliverables
115
+
116
+ - Complete the role checklist in \`tasks.md\`.
117
+ - Leave a handoff with files changed, verification, and risks.
118
+ - The owner alone runs the change completion flow and sandbox cleanup after change tasks 4.1-4.3 are done.
92
119
  "
93
120
 
94
121
  write_if_missing "$ROLE_DIR/tasks.md" "# ${role} tasks
95
122
 
123
+ ## Ownership
124
+
125
+ - [ ] Claim this role's files in the shared owner branch/worktree before editing.
126
+ - [ ] Record branch, worktree, and scope for this role.
127
+ - [ ] Copy or hand off \`prompt.md\` when another agent joins this role.
128
+
96
129
  ## 1. Spec
97
130
 
98
131
  - [ ] Define requirements and scope for ${role}
@@ -111,6 +144,15 @@ Role workspace for \`${role}\`.
111
144
  ## 4. Checkpoints
112
145
 
113
146
  - [ ] Publish checkpoint update for this role
147
+
148
+ ## 5. Collaboration
149
+
150
+ - [ ] Leave a role handoff with files changed, verification, and risks.
151
+ - [ ] Owner records \`accept\`, \`revise\`, or \`reject\` for joined output, or marks \`N/A\` if no helper joined.
152
+
153
+ ## 6. Completion
154
+
155
+ - [ ] Keep sandbox cleanup blocked until change tasks 4.1-4.3 are complete.
114
156
  "
115
157
  done
116
158