@imdeadpool/guardex 5.0.8 → 5.0.11

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.
@@ -78,6 +78,7 @@ const CRITICAL_GUARDRAIL_PATHS = new Set([
78
78
  ]);
79
79
 
80
80
  const LOCK_FILE_RELATIVE = '.omx/state/agent-file-locks.json';
81
+ const AGENTS_BOTS_STATE_RELATIVE = '.omx/state/agents-bots.json';
81
82
  const AGENTS_MARKER_START = '<!-- multiagent-safety:START -->';
82
83
  const AGENTS_MARKER_END = '<!-- multiagent-safety:END -->';
83
84
  const GITIGNORE_MARKER_START = '# multiagent-safety:START';
@@ -128,6 +129,8 @@ const SUGGESTIBLE_COMMANDS = [
128
129
  'init',
129
130
  'doctor',
130
131
  'review',
132
+ 'agents',
133
+ 'finish',
131
134
  'report',
132
135
  'copy-prompt',
133
136
  'copy-commands',
@@ -148,11 +151,13 @@ const CLI_COMMAND_DESCRIPTIONS = [
148
151
  ['init', 'Alias of setup (bootstrap + repair guardrails in a git repo)'],
149
152
  ['doctor', 'Repair safety setup drift, then verify repo safety'],
150
153
  ['report', 'Generate security/safety reports (for example: OpenSSF scorecard)'],
154
+ ['finish', 'Auto-commit completed agent branches, then run PR finish flow'],
151
155
  ['copy-prompt', 'Print the AI-ready setup checklist'],
152
156
  ['copy-commands', 'Print setup checklist as executable commands only'],
153
157
  ['protect', 'Manage protected branches (list/add/remove/set/reset)'],
154
158
  ['sync', 'Check or sync agent branches with origin/<base>'],
155
- ['cleanup', 'Cleanup merged agent branches/worktrees (local + remote)'],
159
+ ['cleanup', 'Cleanup agent branches/worktrees (supports idle watch mode)'],
160
+ ['agents', 'Start/stop repo-scoped review + cleanup bots'],
156
161
  ['install', 'Install templates/locks/hooks without running full setup (supports --no-gitignore)'],
157
162
  ['fix', 'Repair broken or missing guardrail files/config (supports --no-gitignore)'],
158
163
  ['scan', 'Report safety issues and exit non-zero on findings'],
@@ -163,6 +168,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
163
168
  ];
164
169
  const AGENT_BOT_DESCRIPTIONS = [
165
170
  ['review', 'Start PR monitor + codex-agent review flow (default interval: 30s)'],
171
+ ['agents', 'Start/stop both review and cleanup bots for this repo'],
166
172
  ];
167
173
 
168
174
  const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-Rex for your repo) in this repository for Codex or Claude.
@@ -198,18 +204,31 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
198
204
  - Finished branches stay available by default for audit/follow-up.
199
205
  Remove them explicitly when done:
200
206
  gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
207
+ - To finalize all completed agent branches in one pass:
208
+ gx finish --all
201
209
 
202
- 6) Optional: create OpenSpec planning workspace:
210
+ 6) OpenSpec default change flow (core profile):
211
+ /opsx:propose <change-name>
212
+ /opsx:apply
213
+ /opsx:archive
214
+ - Full guide: docs/openspec-getting-started.md
215
+
216
+ 7) Optional: enable expanded OpenSpec workflow commands:
217
+ openspec config profile <profile-name>
218
+ openspec update
219
+ - Expanded path: /opsx:new -> /opsx:ff or /opsx:continue -> /opsx:apply -> /opsx:verify -> /opsx:archive
220
+
221
+ 8) Optional: create OpenSpec planning workspace:
203
222
  bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
204
223
 
205
- 7) Optional: protect extra branches:
224
+ 9) Optional: protect extra branches:
206
225
  gx protect add release staging
207
226
 
208
- 8) Optional: sync your current agent branch with latest base branch:
227
+ 10) Optional: sync your current agent branch with latest base branch:
209
228
  gx sync --check
210
229
  gx sync
211
230
 
212
- 9) Optional (GitHub remote cleanup): enable:
231
+ 11) Optional (GitHub remote cleanup): enable:
213
232
  Settings -> General -> Pull Requests -> Automatically delete head branches
214
233
  `;
215
234
 
@@ -222,8 +241,11 @@ bash scripts/codex-agent.sh "task" "agent-name"
222
241
  bash scripts/agent-branch-start.sh "task" "agent-name"
223
242
  python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
224
243
  bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)"
244
+ gx finish --all
225
245
  gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
226
246
  bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
247
+ openspec config profile <profile-name>
248
+ openspec update
227
249
  gx protect add release staging
228
250
  gx sync --check
229
251
  gx sync
@@ -628,6 +650,7 @@ function ensurePackageScripts(repoRoot, dryRun) {
628
650
  'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
629
651
  'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
630
652
  'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
653
+ 'agent:finish': `${SHORT_TOOL_NAME} finish --all`,
631
654
  'agent:cleanup': `${SHORT_TOOL_NAME} cleanup`,
632
655
  'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
633
656
  'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
@@ -1180,6 +1203,14 @@ function hasOriginRemote(repoRoot) {
1180
1203
  return run('git', ['-C', repoRoot, 'remote', 'get-url', 'origin']).status === 0;
1181
1204
  }
1182
1205
 
1206
+ function originRemoteLooksLikeGithub(repoRoot) {
1207
+ const originUrl = readGitConfig(repoRoot, 'remote.origin.url');
1208
+ if (!originUrl) {
1209
+ return false;
1210
+ }
1211
+ return /github\.com[:/]/i.test(originUrl);
1212
+ }
1213
+
1183
1214
  function isCommandAvailable(commandName) {
1184
1215
  return run('which', [commandName]).status === 0;
1185
1216
  }
@@ -1211,6 +1242,13 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1211
1242
  note: 'origin remote missing; skipped auto-finish',
1212
1243
  };
1213
1244
  }
1245
+ const explicitGhBin = Boolean(String(process.env.MUSAFETY_GH_BIN || '').trim());
1246
+ if (!explicitGhBin && !originRemoteLooksLikeGithub(blocked.repoRoot)) {
1247
+ return {
1248
+ status: 'skipped',
1249
+ note: 'origin remote is not GitHub; skipped auto-finish PR flow',
1250
+ };
1251
+ }
1214
1252
 
1215
1253
  const ghBin = process.env.MUSAFETY_GH_BIN || 'gh';
1216
1254
  if (!isCommandAvailable(ghBin)) {
@@ -1228,10 +1266,15 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1228
1266
  };
1229
1267
  }
1230
1268
 
1269
+ const rawWaitTimeoutSeconds = Number.parseInt(process.env.MUSAFETY_FINISH_WAIT_TIMEOUT_SECONDS || '1800', 10);
1270
+ const waitTimeoutSeconds =
1271
+ Number.isFinite(rawWaitTimeoutSeconds) && rawWaitTimeoutSeconds >= 30 ? rawWaitTimeoutSeconds : 1800;
1272
+ const finishTimeoutMs = Math.max(180_000, (waitTimeoutSeconds + 60) * 1000);
1273
+
1231
1274
  const finishResult = run(
1232
1275
  'bash',
1233
- [finishScript, '--branch', metadata.branch, '--via-pr'],
1234
- { cwd: metadata.worktreePath, timeout: 180_000 },
1276
+ [finishScript, '--branch', metadata.branch, '--via-pr', '--wait-for-merge'],
1277
+ { cwd: metadata.worktreePath, timeout: finishTimeoutMs },
1235
1278
  );
1236
1279
  if (isSpawnFailure(finishResult)) {
1237
1280
  return {
@@ -1491,7 +1534,18 @@ function runDoctorInSandbox(options, blocked) {
1491
1534
  }
1492
1535
 
1493
1536
  if (typeof nestedResult.status === 'number') {
1494
- process.exitCode = nestedResult.status;
1537
+ let exitCode = nestedResult.status;
1538
+ if (exitCode === 0 && autoCommitResult.status === 'failed') {
1539
+ exitCode = 1;
1540
+ }
1541
+ if (
1542
+ exitCode === 0 &&
1543
+ autoCommitResult.status === 'committed' &&
1544
+ (finishResult.status === 'failed' || finishResult.status === 'pending')
1545
+ ) {
1546
+ exitCode = 1;
1547
+ }
1548
+ process.exitCode = exitCode;
1495
1549
  return;
1496
1550
  }
1497
1551
  process.exitCode = 1;
@@ -1530,6 +1584,69 @@ function parseReviewArgs(rawArgs) {
1530
1584
  };
1531
1585
  }
1532
1586
 
1587
+ function parseAgentsArgs(rawArgs) {
1588
+ const parsed = parseTargetFlag(rawArgs, process.cwd());
1589
+ const [subcommandRaw = '', ...rest] = parsed.args;
1590
+ const subcommand = subcommandRaw || 'status';
1591
+ const options = {
1592
+ target: parsed.target,
1593
+ subcommand,
1594
+ reviewIntervalSeconds: 30,
1595
+ cleanupIntervalSeconds: 60,
1596
+ idleMinutes: 10,
1597
+ };
1598
+
1599
+ for (let index = 0; index < rest.length; index += 1) {
1600
+ const arg = rest[index];
1601
+ if (arg === '--review-interval') {
1602
+ const next = rest[index + 1];
1603
+ if (!next) {
1604
+ throw new Error('--review-interval requires an integer seconds value');
1605
+ }
1606
+ const parsedValue = Number.parseInt(next, 10);
1607
+ if (!Number.isInteger(parsedValue) || parsedValue < 5) {
1608
+ throw new Error('--review-interval must be an integer >= 5 seconds');
1609
+ }
1610
+ options.reviewIntervalSeconds = parsedValue;
1611
+ index += 1;
1612
+ continue;
1613
+ }
1614
+ if (arg === '--cleanup-interval') {
1615
+ const next = rest[index + 1];
1616
+ if (!next) {
1617
+ throw new Error('--cleanup-interval requires an integer seconds value');
1618
+ }
1619
+ const parsedValue = Number.parseInt(next, 10);
1620
+ if (!Number.isInteger(parsedValue) || parsedValue < 5) {
1621
+ throw new Error('--cleanup-interval must be an integer >= 5 seconds');
1622
+ }
1623
+ options.cleanupIntervalSeconds = parsedValue;
1624
+ index += 1;
1625
+ continue;
1626
+ }
1627
+ if (arg === '--idle-minutes') {
1628
+ const next = rest[index + 1];
1629
+ if (!next) {
1630
+ throw new Error('--idle-minutes requires an integer minutes value');
1631
+ }
1632
+ const parsedValue = Number.parseInt(next, 10);
1633
+ if (!Number.isInteger(parsedValue) || parsedValue < 1) {
1634
+ throw new Error('--idle-minutes must be an integer >= 1');
1635
+ }
1636
+ options.idleMinutes = parsedValue;
1637
+ index += 1;
1638
+ continue;
1639
+ }
1640
+ throw new Error(`Unknown option: ${arg}`);
1641
+ }
1642
+
1643
+ if (!['start', 'stop', 'status'].includes(options.subcommand)) {
1644
+ throw new Error(`Unknown agents subcommand: ${options.subcommand}`);
1645
+ }
1646
+
1647
+ return options;
1648
+ }
1649
+
1533
1650
  function parseReportArgs(rawArgs) {
1534
1651
  const options = {
1535
1652
  target: process.cwd(),
@@ -1914,6 +2031,12 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
1914
2031
  summary.details.push('Skipped auto-finish sweep (origin remote missing).');
1915
2032
  return summary;
1916
2033
  }
2034
+ const explicitGhBin = Boolean(String(process.env.MUSAFETY_GH_BIN || '').trim());
2035
+ if (!explicitGhBin && !originRemoteLooksLikeGithub(repoRoot)) {
2036
+ summary.enabled = false;
2037
+ summary.details.push('Skipped auto-finish sweep (origin remote is not GitHub).');
2038
+ return summary;
2039
+ }
1917
2040
 
1918
2041
  const ghBin = process.env.MUSAFETY_GH_BIN || 'gh';
1919
2042
  if (run(ghBin, ['--version']).status !== 0) {
@@ -1973,6 +2096,7 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
1973
2096
  '--base',
1974
2097
  baseBranch,
1975
2098
  '--via-pr',
2099
+ '--wait-for-merge',
1976
2100
  '--cleanup',
1977
2101
  ];
1978
2102
  const finishResult = run('bash', finishArgs, { cwd: repoRoot });
@@ -2232,6 +2356,10 @@ function parseCleanupArgs(rawArgs) {
2232
2356
  forceDirty: false,
2233
2357
  keepRemote: false,
2234
2358
  keepCleanWorktrees: false,
2359
+ idleMinutes: 0,
2360
+ watch: false,
2361
+ intervalSeconds: 60,
2362
+ once: false,
2235
2363
  };
2236
2364
 
2237
2365
  for (let index = 0; index < rawArgs.length; index += 1) {
@@ -2279,12 +2407,426 @@ function parseCleanupArgs(rawArgs) {
2279
2407
  options.keepCleanWorktrees = true;
2280
2408
  continue;
2281
2409
  }
2410
+ if (arg === '--idle-minutes') {
2411
+ const next = rawArgs[index + 1];
2412
+ if (!next) {
2413
+ throw new Error('--idle-minutes requires an integer value');
2414
+ }
2415
+ const parsed = Number.parseInt(next, 10);
2416
+ if (!Number.isInteger(parsed) || parsed < 0) {
2417
+ throw new Error('--idle-minutes must be an integer >= 0');
2418
+ }
2419
+ options.idleMinutes = parsed;
2420
+ index += 1;
2421
+ continue;
2422
+ }
2423
+ if (arg === '--watch') {
2424
+ options.watch = true;
2425
+ continue;
2426
+ }
2427
+ if (arg === '--interval') {
2428
+ const next = rawArgs[index + 1];
2429
+ if (!next) {
2430
+ throw new Error('--interval requires an integer seconds value');
2431
+ }
2432
+ const parsed = Number.parseInt(next, 10);
2433
+ if (!Number.isInteger(parsed) || parsed < 5) {
2434
+ throw new Error('--interval must be an integer >= 5 seconds');
2435
+ }
2436
+ options.intervalSeconds = parsed;
2437
+ index += 1;
2438
+ continue;
2439
+ }
2440
+ if (arg === '--once') {
2441
+ options.once = true;
2442
+ continue;
2443
+ }
2282
2444
  throw new Error(`Unknown option: ${arg}`);
2283
2445
  }
2284
2446
 
2447
+ if (options.watch && options.idleMinutes === 0) {
2448
+ options.idleMinutes = 10;
2449
+ }
2450
+
2285
2451
  return options;
2286
2452
  }
2287
2453
 
2454
+ function parseFinishArgs(rawArgs) {
2455
+ const options = {
2456
+ target: process.cwd(),
2457
+ base: '',
2458
+ branch: '',
2459
+ all: false,
2460
+ dryRun: false,
2461
+ waitForMerge: true,
2462
+ cleanup: true,
2463
+ keepRemote: false,
2464
+ noAutoCommit: false,
2465
+ failFast: false,
2466
+ commitMessage: '',
2467
+ };
2468
+
2469
+ for (let index = 0; index < rawArgs.length; index += 1) {
2470
+ const arg = rawArgs[index];
2471
+ if (arg === '--target') {
2472
+ const next = rawArgs[index + 1];
2473
+ if (!next) {
2474
+ throw new Error('--target requires a path value');
2475
+ }
2476
+ options.target = next;
2477
+ index += 1;
2478
+ continue;
2479
+ }
2480
+ if (arg === '--base') {
2481
+ const next = rawArgs[index + 1];
2482
+ if (!next) {
2483
+ throw new Error('--base requires a branch value');
2484
+ }
2485
+ options.base = next;
2486
+ index += 1;
2487
+ continue;
2488
+ }
2489
+ if (arg === '--branch') {
2490
+ const next = rawArgs[index + 1];
2491
+ if (!next) {
2492
+ throw new Error('--branch requires an agent/* branch value');
2493
+ }
2494
+ options.branch = next;
2495
+ index += 1;
2496
+ continue;
2497
+ }
2498
+ if (arg === '--commit-message') {
2499
+ const next = rawArgs[index + 1];
2500
+ if (!next) {
2501
+ throw new Error('--commit-message requires a value');
2502
+ }
2503
+ options.commitMessage = next;
2504
+ index += 1;
2505
+ continue;
2506
+ }
2507
+ if (arg === '--all') {
2508
+ options.all = true;
2509
+ continue;
2510
+ }
2511
+ if (arg === '--dry-run') {
2512
+ options.dryRun = true;
2513
+ continue;
2514
+ }
2515
+ if (arg === '--wait-for-merge') {
2516
+ options.waitForMerge = true;
2517
+ continue;
2518
+ }
2519
+ if (arg === '--no-wait-for-merge') {
2520
+ options.waitForMerge = false;
2521
+ continue;
2522
+ }
2523
+ if (arg === '--cleanup') {
2524
+ options.cleanup = true;
2525
+ continue;
2526
+ }
2527
+ if (arg === '--no-cleanup') {
2528
+ options.cleanup = false;
2529
+ continue;
2530
+ }
2531
+ if (arg === '--keep-remote') {
2532
+ options.keepRemote = true;
2533
+ continue;
2534
+ }
2535
+ if (arg === '--no-auto-commit') {
2536
+ options.noAutoCommit = true;
2537
+ continue;
2538
+ }
2539
+ if (arg === '--fail-fast') {
2540
+ options.failFast = true;
2541
+ continue;
2542
+ }
2543
+ throw new Error(`Unknown option: ${arg}`);
2544
+ }
2545
+
2546
+ if (options.branch && !options.branch.startsWith('agent/')) {
2547
+ throw new Error(`--branch must reference an agent/* branch (received: ${options.branch})`);
2548
+ }
2549
+
2550
+ return options;
2551
+ }
2552
+
2553
+ function listAgentWorktrees(repoRoot) {
2554
+ const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
2555
+ if (result.status !== 0) {
2556
+ throw new Error('Unable to list git worktrees for finish command');
2557
+ }
2558
+
2559
+ const entries = [];
2560
+ let currentPath = '';
2561
+ let currentBranchRef = '';
2562
+ const lines = String(result.stdout || '').split('\n');
2563
+ for (const line of lines) {
2564
+ if (!line.trim()) {
2565
+ if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
2566
+ entries.push({
2567
+ worktreePath: currentPath,
2568
+ branch: currentBranchRef.replace(/^refs\/heads\//, ''),
2569
+ });
2570
+ }
2571
+ currentPath = '';
2572
+ currentBranchRef = '';
2573
+ continue;
2574
+ }
2575
+ if (line.startsWith('worktree ')) {
2576
+ currentPath = line.slice('worktree '.length).trim();
2577
+ continue;
2578
+ }
2579
+ if (line.startsWith('branch ')) {
2580
+ currentBranchRef = line.slice('branch '.length).trim();
2581
+ continue;
2582
+ }
2583
+ }
2584
+ if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
2585
+ entries.push({
2586
+ worktreePath: currentPath,
2587
+ branch: currentBranchRef.replace(/^refs\/heads\//, ''),
2588
+ });
2589
+ }
2590
+
2591
+ return entries;
2592
+ }
2593
+
2594
+ function listLocalAgentBranchesForFinish(repoRoot) {
2595
+ const result = gitRun(
2596
+ repoRoot,
2597
+ ['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent/'],
2598
+ { allowFailure: true },
2599
+ );
2600
+ if (result.status !== 0) {
2601
+ throw new Error('Unable to list local agent branches');
2602
+ }
2603
+ return uniquePreserveOrder(
2604
+ String(result.stdout || '')
2605
+ .split('\n')
2606
+ .map((line) => line.trim())
2607
+ .filter((line) => line.startsWith('agent/')),
2608
+ );
2609
+ }
2610
+
2611
+ function gitQuietChangeResult(worktreePath, args) {
2612
+ const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
2613
+ if (result.status === 0) {
2614
+ return false;
2615
+ }
2616
+ if (result.status === 1) {
2617
+ return true;
2618
+ }
2619
+ throw new Error(
2620
+ `git ${args.join(' ')} failed in ${worktreePath}: ${(
2621
+ result.stderr || result.stdout || ''
2622
+ ).trim()}`,
2623
+ );
2624
+ }
2625
+
2626
+ function worktreeHasLocalChanges(worktreePath) {
2627
+ const hasUnstaged = gitQuietChangeResult(worktreePath, [
2628
+ 'diff',
2629
+ '--quiet',
2630
+ '--',
2631
+ '.',
2632
+ ':(exclude).omx/state/agent-file-locks.json',
2633
+ ]);
2634
+ if (hasUnstaged) {
2635
+ return true;
2636
+ }
2637
+
2638
+ const hasStaged = gitQuietChangeResult(worktreePath, [
2639
+ 'diff',
2640
+ '--cached',
2641
+ '--quiet',
2642
+ '--',
2643
+ '.',
2644
+ ':(exclude).omx/state/agent-file-locks.json',
2645
+ ]);
2646
+ if (hasStaged) {
2647
+ return true;
2648
+ }
2649
+
2650
+ const untracked = run('git', ['-C', worktreePath, 'ls-files', '--others', '--exclude-standard'], {
2651
+ stdio: 'pipe',
2652
+ });
2653
+ if (untracked.status !== 0) {
2654
+ throw new Error(`Unable to inspect untracked files in ${worktreePath}`);
2655
+ }
2656
+ return String(untracked.stdout || '').trim().length > 0;
2657
+ }
2658
+
2659
+ function gitOutputLines(worktreePath, args) {
2660
+ const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
2661
+ if (result.status !== 0) {
2662
+ throw new Error(
2663
+ `git ${args.join(' ')} failed in ${worktreePath}: ${(
2664
+ result.stderr || result.stdout || ''
2665
+ ).trim()}`,
2666
+ );
2667
+ }
2668
+ return String(result.stdout || '')
2669
+ .split('\n')
2670
+ .map((line) => line.trim())
2671
+ .filter(Boolean);
2672
+ }
2673
+
2674
+ function claimLocksForAutoCommit(repoRoot, worktreePath, branch) {
2675
+ const lockScript = path.join(repoRoot, 'scripts', 'agent-file-locks.py');
2676
+ if (!fs.existsSync(lockScript)) {
2677
+ return;
2678
+ }
2679
+
2680
+ const changedFiles = uniquePreserveOrder([
2681
+ ...gitOutputLines(worktreePath, ['diff', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
2682
+ ...gitOutputLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
2683
+ ...gitOutputLines(worktreePath, ['ls-files', '--others', '--exclude-standard']),
2684
+ ]);
2685
+
2686
+ if (changedFiles.length > 0) {
2687
+ const claim = run('python3', [lockScript, 'claim', '--branch', branch, ...changedFiles], {
2688
+ cwd: repoRoot,
2689
+ stdio: 'pipe',
2690
+ });
2691
+ if (claim.status !== 0) {
2692
+ throw new Error(
2693
+ `Lock claim failed for ${branch}: ${(
2694
+ claim.stderr || claim.stdout || ''
2695
+ ).trim()}`,
2696
+ );
2697
+ }
2698
+ }
2699
+
2700
+ const deletedFiles = uniquePreserveOrder([
2701
+ ...gitOutputLines(worktreePath, [
2702
+ 'diff',
2703
+ '--name-only',
2704
+ '--diff-filter=D',
2705
+ '--',
2706
+ '.',
2707
+ ':(exclude).omx/state/agent-file-locks.json',
2708
+ ]),
2709
+ ...gitOutputLines(worktreePath, [
2710
+ 'diff',
2711
+ '--cached',
2712
+ '--name-only',
2713
+ '--diff-filter=D',
2714
+ '--',
2715
+ '.',
2716
+ ':(exclude).omx/state/agent-file-locks.json',
2717
+ ]),
2718
+ ]);
2719
+
2720
+ if (deletedFiles.length > 0) {
2721
+ const allowDelete = run('python3', [lockScript, 'allow-delete', '--branch', branch, ...deletedFiles], {
2722
+ cwd: repoRoot,
2723
+ stdio: 'pipe',
2724
+ });
2725
+ if (allowDelete.status !== 0) {
2726
+ throw new Error(
2727
+ `Delete-lock grant failed for ${branch}: ${(
2728
+ allowDelete.stderr || allowDelete.stdout || ''
2729
+ ).trim()}`,
2730
+ );
2731
+ }
2732
+ }
2733
+ }
2734
+
2735
+ function branchExists(repoRoot, branch) {
2736
+ const result = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
2737
+ allowFailure: true,
2738
+ });
2739
+ return result.status === 0;
2740
+ }
2741
+
2742
+ function resolveFinishBaseBranch(repoRoot, sourceBranch, explicitBase) {
2743
+ if (explicitBase) {
2744
+ return explicitBase;
2745
+ }
2746
+
2747
+ const branchSpecific = readGitConfig(repoRoot, `branch.${sourceBranch}.musafetyBase`);
2748
+ if (branchSpecific) {
2749
+ return branchSpecific;
2750
+ }
2751
+
2752
+ const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
2753
+ if (configured) {
2754
+ return configured;
2755
+ }
2756
+
2757
+ const current = gitRun(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFailure: true });
2758
+ const currentBranch = String(current.stdout || '').trim();
2759
+ if (current.status === 0 && currentBranch && currentBranch !== 'HEAD' && !currentBranch.startsWith('agent/')) {
2760
+ return currentBranch;
2761
+ }
2762
+
2763
+ return DEFAULT_BASE_BRANCH;
2764
+ }
2765
+
2766
+ function branchMergedIntoBase(repoRoot, branch, baseBranch) {
2767
+ if (!branchExists(repoRoot, baseBranch)) {
2768
+ return false;
2769
+ }
2770
+ const result = gitRun(repoRoot, ['merge-base', '--is-ancestor', branch, baseBranch], {
2771
+ allowFailure: true,
2772
+ });
2773
+ if (result.status === 0) {
2774
+ return true;
2775
+ }
2776
+ if (result.status === 1) {
2777
+ return false;
2778
+ }
2779
+ throw new Error(`Unable to determine merge status for ${branch} -> ${baseBranch}`);
2780
+ }
2781
+
2782
+ function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) {
2783
+ const hasChanges = worktreeHasLocalChanges(worktreePath);
2784
+ if (!hasChanges) {
2785
+ return { changed: false, committed: false };
2786
+ }
2787
+
2788
+ if (options.noAutoCommit) {
2789
+ throw new Error(
2790
+ `Branch '${branch}' has local changes in ${worktreePath}. Re-run without --no-auto-commit or commit manually first.`,
2791
+ );
2792
+ }
2793
+
2794
+ if (options.dryRun) {
2795
+ return { changed: true, committed: false, dryRun: true };
2796
+ }
2797
+
2798
+ claimLocksForAutoCommit(repoRoot, worktreePath, branch);
2799
+
2800
+ const addResult = run('git', ['-C', worktreePath, 'add', '-A'], { stdio: 'pipe' });
2801
+ if (addResult.status !== 0) {
2802
+ throw new Error(`git add failed in ${worktreePath}: ${(addResult.stderr || addResult.stdout || '').trim()}`);
2803
+ }
2804
+
2805
+ const stagedHasChanges = gitQuietChangeResult(worktreePath, [
2806
+ 'diff',
2807
+ '--cached',
2808
+ '--quiet',
2809
+ '--',
2810
+ '.',
2811
+ ':(exclude).omx/state/agent-file-locks.json',
2812
+ ]);
2813
+ if (!stagedHasChanges) {
2814
+ return { changed: true, committed: false };
2815
+ }
2816
+
2817
+ const commitMessage = options.commitMessage || `Auto-finish: ${branch}`;
2818
+ const commitResult = run('git', ['-C', worktreePath, 'commit', '-m', commitMessage], { stdio: 'pipe' });
2819
+ if (commitResult.status !== 0) {
2820
+ throw new Error(
2821
+ `Auto-commit failed on '${branch}': ${(
2822
+ commitResult.stderr || commitResult.stdout || ''
2823
+ ).trim()}`,
2824
+ );
2825
+ }
2826
+
2827
+ return { changed: true, committed: true, message: commitMessage };
2828
+ }
2829
+
2288
2830
  function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
2289
2831
  if (strategy === 'rebase') {
2290
2832
  if (ffOnly) {
@@ -3065,6 +3607,9 @@ function install(rawArgs) {
3065
3607
  printOperations('Install target', payload, options.dryRun);
3066
3608
 
3067
3609
  if (!options.dryRun) {
3610
+ if (!options.skipAgents) {
3611
+ console.log(`[${TOOL_NAME}] AGENTS.md managed policy block is configured by install.`);
3612
+ }
3068
3613
  console.log(`[${TOOL_NAME}] Installed. Next step: ${TOOL_NAME} setup`);
3069
3614
  }
3070
3615
 
@@ -3204,6 +3749,263 @@ function review(rawArgs) {
3204
3749
  process.exitCode = typeof result.status === 'number' ? result.status : 1;
3205
3750
  }
3206
3751
 
3752
+ function agentsStatePathForRepo(repoRoot) {
3753
+ return path.join(repoRoot, AGENTS_BOTS_STATE_RELATIVE);
3754
+ }
3755
+
3756
+ function readAgentsState(repoRoot) {
3757
+ const statePath = agentsStatePathForRepo(repoRoot);
3758
+ if (!fs.existsSync(statePath)) {
3759
+ return null;
3760
+ }
3761
+ try {
3762
+ return JSON.parse(fs.readFileSync(statePath, 'utf8'));
3763
+ } catch (_error) {
3764
+ return null;
3765
+ }
3766
+ }
3767
+
3768
+ function writeAgentsState(repoRoot, state) {
3769
+ const statePath = agentsStatePathForRepo(repoRoot);
3770
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
3771
+ fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
3772
+ }
3773
+
3774
+ function processAlive(pid) {
3775
+ const normalizedPid = Number.parseInt(String(pid || ''), 10);
3776
+ if (!Number.isInteger(normalizedPid) || normalizedPid <= 0) {
3777
+ return false;
3778
+ }
3779
+ try {
3780
+ process.kill(normalizedPid, 0);
3781
+ return true;
3782
+ } catch (_error) {
3783
+ return false;
3784
+ }
3785
+ }
3786
+
3787
+ function sleepSeconds(seconds) {
3788
+ const result = run('sleep', [String(seconds)]);
3789
+ if (isSpawnFailure(result) || result.status !== 0) {
3790
+ throw new Error(`sleep command failed for ${seconds}s`);
3791
+ }
3792
+ }
3793
+
3794
+ function readProcessCommand(pid) {
3795
+ const result = run('ps', ['-o', 'command=', '-p', String(pid)]);
3796
+ if (isSpawnFailure(result) || result.status !== 0) {
3797
+ return '';
3798
+ }
3799
+ return String(result.stdout || '').trim();
3800
+ }
3801
+
3802
+ function stopAgentProcessByPid(pid, expectedToken = '') {
3803
+ const normalizedPid = Number.parseInt(String(pid || ''), 10);
3804
+ if (!Number.isInteger(normalizedPid) || normalizedPid <= 0) {
3805
+ return { status: 'invalid', pid: normalizedPid };
3806
+ }
3807
+ if (!processAlive(normalizedPid)) {
3808
+ return { status: 'not-running', pid: normalizedPid };
3809
+ }
3810
+
3811
+ if (expectedToken) {
3812
+ const cmdline = readProcessCommand(normalizedPid);
3813
+ if (cmdline && !cmdline.includes(expectedToken)) {
3814
+ return { status: 'mismatch', pid: normalizedPid, command: cmdline };
3815
+ }
3816
+ }
3817
+
3818
+ try {
3819
+ process.kill(-normalizedPid, 'SIGTERM');
3820
+ } catch (_error) {
3821
+ try {
3822
+ process.kill(normalizedPid, 'SIGTERM');
3823
+ } catch (_err) {
3824
+ return { status: 'term-failed', pid: normalizedPid };
3825
+ }
3826
+ }
3827
+
3828
+ const deadline = Date.now() + 3_000;
3829
+ while (Date.now() < deadline) {
3830
+ if (!processAlive(normalizedPid)) {
3831
+ return { status: 'stopped', pid: normalizedPid };
3832
+ }
3833
+ sleepSeconds(0.1);
3834
+ }
3835
+
3836
+ try {
3837
+ process.kill(-normalizedPid, 'SIGKILL');
3838
+ } catch (_error) {
3839
+ try {
3840
+ process.kill(normalizedPid, 'SIGKILL');
3841
+ } catch (_err) {
3842
+ return { status: 'kill-failed', pid: normalizedPid };
3843
+ }
3844
+ }
3845
+ sleepSeconds(0.1);
3846
+
3847
+ return {
3848
+ status: processAlive(normalizedPid) ? 'kill-failed' : 'stopped',
3849
+ pid: normalizedPid,
3850
+ };
3851
+ }
3852
+
3853
+ function spawnDetachedAgentProcess({ command, args, cwd, logPath }) {
3854
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
3855
+ const logHandle = fs.openSync(logPath, 'a');
3856
+ fs.writeSync(
3857
+ logHandle,
3858
+ `[${new Date().toISOString()}] spawn: ${command} ${args.join(' ')}\n`,
3859
+ );
3860
+ const child = cp.spawn(command, args, {
3861
+ cwd,
3862
+ detached: true,
3863
+ stdio: ['ignore', logHandle, logHandle],
3864
+ env: process.env,
3865
+ });
3866
+ fs.closeSync(logHandle);
3867
+ if (child.error) {
3868
+ throw child.error;
3869
+ }
3870
+ child.unref();
3871
+ const pid = Number.parseInt(String(child.pid || ''), 10);
3872
+ if (!Number.isInteger(pid) || pid <= 0) {
3873
+ throw new Error(`Failed to spawn detached process for ${command}`);
3874
+ }
3875
+ return pid;
3876
+ }
3877
+
3878
+ function agents(rawArgs) {
3879
+ const options = parseAgentsArgs(rawArgs);
3880
+ const repoRoot = resolveRepoRoot(options.target);
3881
+ const reviewScriptPath = path.join(repoRoot, 'scripts', 'review-bot-watch.sh');
3882
+ const pruneScriptPath = path.join(repoRoot, 'scripts', 'agent-worktree-prune.sh');
3883
+ const statePath = agentsStatePathForRepo(repoRoot);
3884
+
3885
+ if (options.subcommand === 'start') {
3886
+ if (!fs.existsSync(reviewScriptPath)) {
3887
+ throw new Error(
3888
+ `Missing review bot script: ${reviewScriptPath}\n` +
3889
+ `Run '${SHORT_TOOL_NAME} setup --target ${repoRoot}' then '${SHORT_TOOL_NAME} doctor --target ${repoRoot}'.`,
3890
+ );
3891
+ }
3892
+ if (!fs.existsSync(pruneScriptPath)) {
3893
+ throw new Error(
3894
+ `Missing cleanup script: ${pruneScriptPath}\n` +
3895
+ `Run '${SHORT_TOOL_NAME} setup --target ${repoRoot}' then '${SHORT_TOOL_NAME} doctor --target ${repoRoot}'.`,
3896
+ );
3897
+ }
3898
+
3899
+ const existingState = readAgentsState(repoRoot);
3900
+ const existingReviewPid = Number.parseInt(String(existingState?.review?.pid || ''), 10);
3901
+ const existingCleanupPid = Number.parseInt(String(existingState?.cleanup?.pid || ''), 10);
3902
+ const reviewRunning = processAlive(existingReviewPid);
3903
+ const cleanupRunning = processAlive(existingCleanupPid);
3904
+
3905
+ if (reviewRunning && cleanupRunning) {
3906
+ console.log(
3907
+ `[${TOOL_NAME}] Repo agents already running (review pid=${existingReviewPid}, cleanup pid=${existingCleanupPid}).`,
3908
+ );
3909
+ process.exitCode = 0;
3910
+ return;
3911
+ }
3912
+
3913
+ if (reviewRunning) {
3914
+ stopAgentProcessByPid(existingReviewPid, 'review-bot-watch.sh');
3915
+ }
3916
+ if (cleanupRunning) {
3917
+ stopAgentProcessByPid(existingCleanupPid, `${path.basename(__filename)} cleanup`);
3918
+ }
3919
+
3920
+ const reviewLogPath = path.join(repoRoot, '.omx', 'logs', 'agent-review.log');
3921
+ const cleanupLogPath = path.join(repoRoot, '.omx', 'logs', 'agent-cleanup.log');
3922
+ const reviewPid = spawnDetachedAgentProcess({
3923
+ command: 'bash',
3924
+ args: [reviewScriptPath, '--interval', String(options.reviewIntervalSeconds)],
3925
+ cwd: repoRoot,
3926
+ logPath: reviewLogPath,
3927
+ });
3928
+ const cleanupPid = spawnDetachedAgentProcess({
3929
+ command: process.execPath,
3930
+ args: [
3931
+ path.resolve(__filename),
3932
+ 'cleanup',
3933
+ '--target',
3934
+ repoRoot,
3935
+ '--watch',
3936
+ '--interval',
3937
+ String(options.cleanupIntervalSeconds),
3938
+ '--idle-minutes',
3939
+ String(options.idleMinutes),
3940
+ ],
3941
+ cwd: repoRoot,
3942
+ logPath: cleanupLogPath,
3943
+ });
3944
+
3945
+ writeAgentsState(repoRoot, {
3946
+ schemaVersion: 1,
3947
+ repoRoot,
3948
+ startedAt: new Date().toISOString(),
3949
+ review: {
3950
+ pid: reviewPid,
3951
+ intervalSeconds: options.reviewIntervalSeconds,
3952
+ script: reviewScriptPath,
3953
+ logPath: reviewLogPath,
3954
+ },
3955
+ cleanup: {
3956
+ pid: cleanupPid,
3957
+ intervalSeconds: options.cleanupIntervalSeconds,
3958
+ idleMinutes: options.idleMinutes,
3959
+ script: path.resolve(__filename),
3960
+ logPath: cleanupLogPath,
3961
+ },
3962
+ });
3963
+
3964
+ console.log(
3965
+ `[${TOOL_NAME}] Started repo agents in ${repoRoot} (review pid=${reviewPid}, cleanup pid=${cleanupPid}).`,
3966
+ );
3967
+ console.log(`[${TOOL_NAME}] Logs: ${reviewLogPath}, ${cleanupLogPath}`);
3968
+ process.exitCode = 0;
3969
+ return;
3970
+ }
3971
+
3972
+ if (options.subcommand === 'stop') {
3973
+ const existingState = readAgentsState(repoRoot);
3974
+ if (!existingState) {
3975
+ console.log(`[${TOOL_NAME}] Repo agents are not running for ${repoRoot}.`);
3976
+ process.exitCode = 0;
3977
+ return;
3978
+ }
3979
+
3980
+ const reviewStop = stopAgentProcessByPid(existingState?.review?.pid, 'review-bot-watch.sh');
3981
+ const cleanupStop = stopAgentProcessByPid(existingState?.cleanup?.pid, `${path.basename(__filename)} cleanup`);
3982
+
3983
+ if (fs.existsSync(statePath)) {
3984
+ fs.unlinkSync(statePath);
3985
+ }
3986
+
3987
+ console.log(
3988
+ `[${TOOL_NAME}] Stopped repo agents in ${repoRoot} (review=${reviewStop.status}, cleanup=${cleanupStop.status}).`,
3989
+ );
3990
+ process.exitCode = 0;
3991
+ return;
3992
+ }
3993
+
3994
+ const existingState = readAgentsState(repoRoot);
3995
+ if (!existingState) {
3996
+ console.log(`[${TOOL_NAME}] Repo agents status: inactive (${repoRoot})`);
3997
+ process.exitCode = 0;
3998
+ return;
3999
+ }
4000
+
4001
+ const reviewPid = Number.parseInt(String(existingState?.review?.pid || ''), 10);
4002
+ const cleanupPid = Number.parseInt(String(existingState?.cleanup?.pid || ''), 10);
4003
+ console.log(
4004
+ `[${TOOL_NAME}] Repo agents status: review=${processAlive(reviewPid) ? 'running' : 'stopped'}(pid=${reviewPid || 0}), cleanup=${processAlive(cleanupPid) ? 'running' : 'stopped'}(pid=${cleanupPid || 0})`,
4005
+ );
4006
+ process.exitCode = 0;
4007
+ }
4008
+
3207
4009
  function report(rawArgs) {
3208
4010
  const options = parseReportArgs(rawArgs);
3209
4011
  const subcommand = options.subcommand || 'help';
@@ -3382,6 +4184,13 @@ function setup(rawArgs) {
3382
4184
  if (scanResult.errors === 0 && scanResult.warnings === 0) {
3383
4185
  console.log(`[${TOOL_NAME}] ✅ Setup complete.`);
3384
4186
  console.log(`[${TOOL_NAME}] Copy AI setup prompt with: ${SHORT_TOOL_NAME} copy-prompt`);
4187
+ console.log(
4188
+ `[${TOOL_NAME}] OpenSpec core workflow: /opsx:propose -> /opsx:apply -> /opsx:archive`,
4189
+ );
4190
+ console.log(
4191
+ `[${TOOL_NAME}] Optional expanded OpenSpec profile: openspec config profile <profile-name> && openspec update`,
4192
+ );
4193
+ console.log(`[${TOOL_NAME}] OpenSpec guide: docs/openspec-getting-started.md`);
3385
4194
  }
3386
4195
 
3387
4196
  setExitCodeFromScan(scanResult);
@@ -3475,15 +4284,165 @@ function cleanup(rawArgs) {
3475
4284
  if (!options.keepCleanWorktrees) {
3476
4285
  args.push('--only-dirty-worktrees');
3477
4286
  }
4287
+ if (options.idleMinutes > 0) {
4288
+ args.push('--idle-minutes', String(options.idleMinutes));
4289
+ }
3478
4290
  args.push('--delete-branches');
3479
4291
  if (!options.keepRemote) {
3480
4292
  args.push('--delete-remote-branches');
3481
4293
  }
3482
4294
 
3483
- const runResult = run('bash', args, { cwd: repoRoot, stdio: 'inherit' });
3484
- if (runResult.status !== 0) {
3485
- throw new Error('Cleanup command failed');
4295
+ const runCleanupCycle = () => {
4296
+ const runResult = run('bash', args, { cwd: repoRoot, stdio: 'inherit' });
4297
+ if (runResult.status !== 0) {
4298
+ throw new Error('Cleanup command failed');
4299
+ }
4300
+ };
4301
+
4302
+ if (options.watch) {
4303
+ let cycle = 0;
4304
+ while (true) {
4305
+ cycle += 1;
4306
+ console.log(
4307
+ `[${TOOL_NAME}] Cleanup watch cycle=${cycle} (interval=${options.intervalSeconds}s, idleMinutes=${options.idleMinutes}).`,
4308
+ );
4309
+ runCleanupCycle();
4310
+ if (options.once) {
4311
+ break;
4312
+ }
4313
+ const sleepResult = run('sleep', [String(options.intervalSeconds)], { cwd: repoRoot });
4314
+ if (sleepResult.status !== 0) {
4315
+ throw new Error(`Cleanup watch sleep failed (interval=${options.intervalSeconds}s)`);
4316
+ }
4317
+ }
4318
+ process.exitCode = 0;
4319
+ return;
3486
4320
  }
4321
+
4322
+ runCleanupCycle();
4323
+ process.exitCode = 0;
4324
+ }
4325
+
4326
+ function finish(rawArgs) {
4327
+ const options = parseFinishArgs(rawArgs);
4328
+ const repoRoot = resolveRepoRoot(options.target);
4329
+ const finishScript = path.join(repoRoot, 'scripts', 'agent-branch-finish.sh');
4330
+
4331
+ if (!fs.existsSync(finishScript)) {
4332
+ throw new Error(`Missing finish script: ${finishScript}. Run '${SHORT_TOOL_NAME} setup' first.`);
4333
+ }
4334
+
4335
+ const worktreeEntries = listAgentWorktrees(repoRoot);
4336
+ const worktreeByBranch = new Map(worktreeEntries.map((entry) => [entry.branch, entry.worktreePath]));
4337
+
4338
+ let candidateBranches = [];
4339
+ if (options.branch) {
4340
+ if (!branchExists(repoRoot, options.branch)) {
4341
+ throw new Error(`Local branch not found: ${options.branch}`);
4342
+ }
4343
+ candidateBranches = [options.branch];
4344
+ } else {
4345
+ candidateBranches = uniquePreserveOrder([
4346
+ ...listLocalAgentBranchesForFinish(repoRoot),
4347
+ ...worktreeEntries.map((entry) => entry.branch),
4348
+ ]);
4349
+ }
4350
+
4351
+ const candidates = [];
4352
+ for (const branch of candidateBranches) {
4353
+ const worktreePath = worktreeByBranch.get(branch) || '';
4354
+ const baseBranch = resolveFinishBaseBranch(repoRoot, branch, options.base);
4355
+ const hasChanges = worktreePath ? worktreeHasLocalChanges(worktreePath) : false;
4356
+ const alreadyMerged = branchMergedIntoBase(repoRoot, branch, baseBranch);
4357
+ if (options.all || options.branch || hasChanges || !alreadyMerged) {
4358
+ candidates.push({
4359
+ branch,
4360
+ baseBranch,
4361
+ worktreePath,
4362
+ hasChanges,
4363
+ alreadyMerged,
4364
+ });
4365
+ }
4366
+ }
4367
+
4368
+ if (candidates.length === 0) {
4369
+ console.log(`[${TOOL_NAME}] No pending agent branches to finish.`);
4370
+ process.exitCode = 0;
4371
+ return;
4372
+ }
4373
+
4374
+ let succeeded = 0;
4375
+ let failed = 0;
4376
+ let autoCommitted = 0;
4377
+
4378
+ for (const candidate of candidates) {
4379
+ const { branch, baseBranch, worktreePath } = candidate;
4380
+ console.log(
4381
+ `[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}...`,
4382
+ );
4383
+
4384
+ try {
4385
+ let commitState = { changed: false, committed: false };
4386
+ if (worktreePath) {
4387
+ commitState = autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options);
4388
+ }
4389
+
4390
+ if (commitState.committed) {
4391
+ autoCommitted += 1;
4392
+ console.log(`[${TOOL_NAME}] Auto-committed '${branch}' before finish.`);
4393
+ } else if (commitState.changed && commitState.dryRun) {
4394
+ console.log(`[${TOOL_NAME}] [dry-run] Would auto-commit pending changes on '${branch}'.`);
4395
+ }
4396
+
4397
+ const finishArgs = [
4398
+ finishScript,
4399
+ '--branch',
4400
+ branch,
4401
+ '--base',
4402
+ baseBranch,
4403
+ '--via-pr',
4404
+ options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge',
4405
+ options.cleanup ? '--cleanup' : '--no-cleanup',
4406
+ ];
4407
+ if (options.keepRemote) {
4408
+ finishArgs.push('--keep-remote-branch');
4409
+ }
4410
+
4411
+ if (options.dryRun) {
4412
+ console.log(`[${TOOL_NAME}] [dry-run] Would run: bash ${finishArgs.join(' ')}`);
4413
+ succeeded += 1;
4414
+ continue;
4415
+ }
4416
+
4417
+ const finishResult = run('bash', finishArgs, { cwd: repoRoot, stdio: 'pipe' });
4418
+ if (finishResult.stdout) {
4419
+ process.stdout.write(finishResult.stdout);
4420
+ }
4421
+ if (finishResult.stderr) {
4422
+ process.stderr.write(finishResult.stderr);
4423
+ }
4424
+ if (finishResult.status !== 0) {
4425
+ throw new Error(`agent-branch-finish exited with status ${finishResult.status}`);
4426
+ }
4427
+
4428
+ succeeded += 1;
4429
+ } catch (error) {
4430
+ failed += 1;
4431
+ console.error(`[${TOOL_NAME}] Finish failed for '${branch}': ${error.message}`);
4432
+ if (options.failFast) {
4433
+ break;
4434
+ }
4435
+ }
4436
+ }
4437
+
4438
+ console.log(
4439
+ `[${TOOL_NAME}] Finish summary: total=${candidates.length}, success=${succeeded}, failed=${failed}, autoCommitted=${autoCommitted}`,
4440
+ );
4441
+
4442
+ if (failed > 0) {
4443
+ throw new Error('finish command failed for one or more agent branches');
4444
+ }
4445
+
3487
4446
  process.exitCode = 0;
3488
4447
  }
3489
4448
 
@@ -3815,6 +4774,16 @@ function main() {
3815
4774
  return;
3816
4775
  }
3817
4776
 
4777
+ if (command === 'agents') {
4778
+ agents(rest);
4779
+ return;
4780
+ }
4781
+
4782
+ if (command === 'finish') {
4783
+ finish(rest);
4784
+ return;
4785
+ }
4786
+
3818
4787
  if (command === 'report') {
3819
4788
  report(rest);
3820
4789
  return;