@imdeadpool/guardex 5.0.7 → 5.0.9

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.
@@ -83,6 +83,7 @@ const AGENTS_MARKER_END = '<!-- multiagent-safety:END -->';
83
83
  const GITIGNORE_MARKER_START = '# multiagent-safety:START';
84
84
  const GITIGNORE_MARKER_END = '# multiagent-safety:END';
85
85
  const MANAGED_GITIGNORE_PATHS = [
86
+ '.omx/',
86
87
  'scripts/agent-branch-start.sh',
87
88
  'scripts/agent-branch-finish.sh',
88
89
  'scripts/codex-agent.sh',
@@ -98,6 +99,17 @@ const MANAGED_GITIGNORE_PATHS = [
98
99
  '.claude/commands/guardex.md',
99
100
  LOCK_FILE_RELATIVE,
100
101
  ];
102
+ const OMX_SCAFFOLD_DIRECTORIES = [
103
+ '.omx',
104
+ '.omx/state',
105
+ '.omx/logs',
106
+ '.omx/plans',
107
+ '.omx/agent-worktrees',
108
+ ];
109
+ const OMX_SCAFFOLD_FILES = new Map([
110
+ ['.omx/notepad.md', '\n\n## WORKING MEMORY\n'],
111
+ ['.omx/project-memory.json', '{}\n'],
112
+ ]);
101
113
  const COMMAND_TYPO_ALIASES = new Map([
102
114
  ['relaese', 'release'],
103
115
  ['realaese', 'release'],
@@ -115,6 +127,8 @@ const SUGGESTIBLE_COMMANDS = [
115
127
  'setup',
116
128
  'init',
117
129
  'doctor',
130
+ 'review',
131
+ 'finish',
118
132
  'report',
119
133
  'copy-prompt',
120
134
  'copy-commands',
@@ -135,6 +149,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
135
149
  ['init', 'Alias of setup (bootstrap + repair guardrails in a git repo)'],
136
150
  ['doctor', 'Repair safety setup drift, then verify repo safety'],
137
151
  ['report', 'Generate security/safety reports (for example: OpenSSF scorecard)'],
152
+ ['finish', 'Auto-commit completed agent branches, then run PR finish flow'],
138
153
  ['copy-prompt', 'Print the AI-ready setup checklist'],
139
154
  ['copy-commands', 'Print setup checklist as executable commands only'],
140
155
  ['protect', 'Manage protected branches (list/add/remove/set/reset)'],
@@ -149,8 +164,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
149
164
  ['version', 'Print GuardeX version'],
150
165
  ];
151
166
  const AGENT_BOT_DESCRIPTIONS = [
152
- ['review', 'Monitor open PRs targeting current branch and dispatch codex-agent review flow'],
153
- ['start', 'bash scripts/review-bot-watch.sh --interval 30'],
167
+ ['review', 'Start PR monitor + codex-agent review flow (default interval: 30s)'],
154
168
  ];
155
169
 
156
170
  const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-Rex for your repo) in this repository for Codex or Claude.
@@ -172,7 +186,10 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
172
186
  3) If setup reports warnings/errors, repair + re-check:
173
187
  gx doctor
174
188
 
175
- 4) Confirm next safe agent workflow commands:
189
+ 4) Optional: start continuous PR monitor from this repo:
190
+ gx review --interval 30
191
+
192
+ 5) Confirm next safe agent workflow commands:
176
193
  bash scripts/codex-agent.sh "task" "agent-name"
177
194
  bash scripts/agent-branch-start.sh "task" "agent-name"
178
195
  python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
@@ -183,18 +200,20 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
183
200
  - Finished branches stay available by default for audit/follow-up.
184
201
  Remove them explicitly when done:
185
202
  gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
203
+ - To finalize all completed agent branches in one pass:
204
+ gx finish --all
186
205
 
187
- 5) Optional: create OpenSpec planning workspace:
206
+ 6) Optional: create OpenSpec planning workspace:
188
207
  bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
189
208
 
190
- 6) Optional: protect extra branches:
209
+ 7) Optional: protect extra branches:
191
210
  gx protect add release staging
192
211
 
193
- 7) Optional: sync your current agent branch with latest base branch:
212
+ 8) Optional: sync your current agent branch with latest base branch:
194
213
  gx sync --check
195
214
  gx sync
196
215
 
197
- 8) Optional (GitHub remote cleanup): enable:
216
+ 9) Optional (GitHub remote cleanup): enable:
198
217
  Settings -> General -> Pull Requests -> Automatically delete head branches
199
218
  `;
200
219
 
@@ -202,10 +221,12 @@ const AI_SETUP_COMMANDS = `npm i -g @imdeadpool/guardex
202
221
  gh --version
203
222
  gx setup
204
223
  gx doctor
224
+ gx review --interval 30
205
225
  bash scripts/codex-agent.sh "task" "agent-name"
206
226
  bash scripts/agent-branch-start.sh "task" "agent-name"
207
227
  python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
208
228
  bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)"
229
+ gx finish --all
209
230
  gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
210
231
  bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
211
232
  gx protect add release staging
@@ -349,7 +370,9 @@ NOTES
349
370
  - ${SHORT_TOOL_NAME} init is an alias of ${SHORT_TOOL_NAME} setup
350
371
  - ${TOOL_NAME} setup asks for Y/N approval before global installs
351
372
  - ${TOOL_NAME} setup checks GitHub CLI (gh) and prints install guidance if missing
373
+ - For other repos: ${SHORT_TOOL_NAME} setup --target <repo-path> then ${SHORT_TOOL_NAME} doctor --target <repo-path>
352
374
  - In initialized repos, setup/install/fix block in-place writes on protected main by default
375
+ - setup/doctor auto-finish clean pending agent/* branches via PR flow into the current local base branch
353
376
  - doctor auto-runs in a sandbox agent branch/worktree on protected main and tries auto-finish PR flow
354
377
  - agent-branch-finish merges by default and keeps agent branches/worktrees until explicit cleanup
355
378
  - use '${SHORT_TOOL_NAME} cleanup' to remove merged agent branches/worktrees (optionally remote refs too)
@@ -359,7 +382,8 @@ NOTES
359
382
  console.log(`
360
383
  [${TOOL_NAME}] No git repository detected in current directory.
361
384
  [${TOOL_NAME}] Start from a repo root, or pass an explicit target:
362
- ${TOOL_NAME} setup --target <path-to-git-repo>`);
385
+ ${TOOL_NAME} setup --target <path-to-git-repo>
386
+ ${TOOL_NAME} doctor --target <path-to-git-repo>`);
363
387
  }
364
388
  }
365
389
 
@@ -502,6 +526,45 @@ function lockFilePath(repoRoot) {
502
526
  return path.join(repoRoot, LOCK_FILE_RELATIVE);
503
527
  }
504
528
 
529
+ function ensureOmxScaffold(repoRoot, dryRun) {
530
+ const operations = [];
531
+
532
+ for (const relativeDir of OMX_SCAFFOLD_DIRECTORIES) {
533
+ const absoluteDir = path.join(repoRoot, relativeDir);
534
+ if (fs.existsSync(absoluteDir)) {
535
+ if (!fs.statSync(absoluteDir).isDirectory()) {
536
+ throw new Error(`Expected directory at ${relativeDir} but found a file.`);
537
+ }
538
+ operations.push({ status: 'unchanged', file: relativeDir });
539
+ continue;
540
+ }
541
+
542
+ if (!dryRun) {
543
+ fs.mkdirSync(absoluteDir, { recursive: true });
544
+ }
545
+ operations.push({ status: 'created', file: relativeDir });
546
+ }
547
+
548
+ for (const [relativeFile, defaultContent] of OMX_SCAFFOLD_FILES.entries()) {
549
+ const absoluteFile = path.join(repoRoot, relativeFile);
550
+ if (fs.existsSync(absoluteFile)) {
551
+ if (!fs.statSync(absoluteFile).isFile()) {
552
+ throw new Error(`Expected file at ${relativeFile} but found a directory.`);
553
+ }
554
+ operations.push({ status: 'unchanged', file: relativeFile });
555
+ continue;
556
+ }
557
+
558
+ if (!dryRun) {
559
+ fs.mkdirSync(path.dirname(absoluteFile), { recursive: true });
560
+ fs.writeFileSync(absoluteFile, defaultContent, 'utf8');
561
+ }
562
+ operations.push({ status: 'created', file: relativeFile });
563
+ }
564
+
565
+ return operations;
566
+ }
567
+
505
568
  function ensureLockRegistry(repoRoot, dryRun) {
506
569
  const absolutePath = lockFilePath(repoRoot);
507
570
  if (fs.existsSync(absolutePath)) {
@@ -570,6 +633,7 @@ function ensurePackageScripts(repoRoot, dryRun) {
570
633
  'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
571
634
  'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
572
635
  'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
636
+ 'agent:finish': `${SHORT_TOOL_NAME} finish --all`,
573
637
  'agent:cleanup': `${SHORT_TOOL_NAME} cleanup`,
574
638
  'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
575
639
  'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
@@ -844,6 +908,33 @@ function isSpawnFailure(result) {
844
908
  return Boolean(result?.error) && typeof result?.status !== 'number';
845
909
  }
846
910
 
911
+ function ensureRepoBranch(repoRoot, branch) {
912
+ const current = currentBranchName(repoRoot);
913
+ if (current === branch) {
914
+ return { ok: true, changed: false };
915
+ }
916
+
917
+ const checkoutResult = run('git', ['-C', repoRoot, 'checkout', branch], { timeout: 20_000 });
918
+ if (isSpawnFailure(checkoutResult)) {
919
+ return {
920
+ ok: false,
921
+ changed: false,
922
+ stdout: checkoutResult.stdout || '',
923
+ stderr: checkoutResult.stderr || '',
924
+ };
925
+ }
926
+ if (checkoutResult.status !== 0) {
927
+ return {
928
+ ok: false,
929
+ changed: false,
930
+ stdout: checkoutResult.stdout || '',
931
+ stderr: checkoutResult.stderr || '',
932
+ };
933
+ }
934
+
935
+ return { ok: true, changed: true };
936
+ }
937
+
847
938
  function doctorSandboxBranchPrefix() {
848
939
  const now = new Date();
849
940
  const stamp = [
@@ -945,12 +1036,26 @@ function startDoctorSandbox(blocked) {
945
1036
  throw startResult.error;
946
1037
  }
947
1038
  if (startResult.status !== 0) {
948
- throw new Error((startResult.stderr || startResult.stdout || 'failed to start doctor sandbox').trim());
1039
+ return startDoctorSandboxFallback(blocked);
949
1040
  }
950
1041
 
951
1042
  const metadata = extractAgentBranchStartMetadata(startResult.stdout);
952
- if (!metadata.worktreePath) {
953
- throw new Error(`Failed to parse sandbox worktree from agent-branch-start output:\n${startResult.stdout}`);
1043
+ const currentBranch = currentBranchName(blocked.repoRoot);
1044
+ const worktreePath = metadata.worktreePath ? path.resolve(metadata.worktreePath) : '';
1045
+ const repoRootPath = path.resolve(blocked.repoRoot);
1046
+ const hasSafeWorktree = Boolean(worktreePath) && worktreePath !== repoRootPath;
1047
+ const branchChanged = Boolean(currentBranch) && currentBranch !== blocked.branch;
1048
+
1049
+ if (!hasSafeWorktree || branchChanged) {
1050
+ const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
1051
+ if (!restoreResult.ok) {
1052
+ const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim();
1053
+ throw new Error(
1054
+ `doctor sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
1055
+ (detail ? `\n${detail}` : ''),
1056
+ );
1057
+ }
1058
+ return startDoctorSandboxFallback(blocked);
954
1059
  }
955
1060
 
956
1061
  return {
@@ -1081,6 +1186,14 @@ function hasOriginRemote(repoRoot) {
1081
1186
  return run('git', ['-C', repoRoot, 'remote', 'get-url', 'origin']).status === 0;
1082
1187
  }
1083
1188
 
1189
+ function originRemoteLooksLikeGithub(repoRoot) {
1190
+ const originUrl = readGitConfig(repoRoot, 'remote.origin.url');
1191
+ if (!originUrl) {
1192
+ return false;
1193
+ }
1194
+ return /github\.com[:/]/i.test(originUrl);
1195
+ }
1196
+
1084
1197
  function isCommandAvailable(commandName) {
1085
1198
  return run('which', [commandName]).status === 0;
1086
1199
  }
@@ -1112,6 +1225,13 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1112
1225
  note: 'origin remote missing; skipped auto-finish',
1113
1226
  };
1114
1227
  }
1228
+ const explicitGhBin = Boolean(String(process.env.MUSAFETY_GH_BIN || '').trim());
1229
+ if (!explicitGhBin && !originRemoteLooksLikeGithub(blocked.repoRoot)) {
1230
+ return {
1231
+ status: 'skipped',
1232
+ note: 'origin remote is not GitHub; skipped auto-finish PR flow',
1233
+ };
1234
+ }
1115
1235
 
1116
1236
  const ghBin = process.env.MUSAFETY_GH_BIN || 'gh';
1117
1237
  if (!isCommandAvailable(ghBin)) {
@@ -1129,10 +1249,15 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1129
1249
  };
1130
1250
  }
1131
1251
 
1252
+ const rawWaitTimeoutSeconds = Number.parseInt(process.env.MUSAFETY_FINISH_WAIT_TIMEOUT_SECONDS || '1800', 10);
1253
+ const waitTimeoutSeconds =
1254
+ Number.isFinite(rawWaitTimeoutSeconds) && rawWaitTimeoutSeconds >= 30 ? rawWaitTimeoutSeconds : 1800;
1255
+ const finishTimeoutMs = Math.max(180_000, (waitTimeoutSeconds + 60) * 1000);
1256
+
1132
1257
  const finishResult = run(
1133
1258
  'bash',
1134
- [finishScript, '--branch', metadata.branch, '--via-pr'],
1135
- { cwd: metadata.worktreePath, timeout: 180_000 },
1259
+ [finishScript, '--branch', metadata.branch, '--via-pr', '--wait-for-merge'],
1260
+ { cwd: metadata.worktreePath, timeout: finishTimeoutMs },
1136
1261
  );
1137
1262
  if (isSpawnFailure(finishResult)) {
1138
1263
  return {
@@ -1197,7 +1322,35 @@ function runDoctorInSandbox(options, blocked) {
1197
1322
  status: 'skipped',
1198
1323
  note: 'sandbox doctor did not complete successfully',
1199
1324
  };
1325
+ let postSandboxAutoFinishSummary = {
1326
+ enabled: false,
1327
+ attempted: 0,
1328
+ completed: 0,
1329
+ skipped: 0,
1330
+ failed: 0,
1331
+ details: ['Skipped auto-finish sweep (sandbox doctor did not complete successfully).'],
1332
+ };
1333
+ let omxScaffoldSyncResult = {
1334
+ status: 'skipped',
1335
+ note: 'sandbox doctor did not complete successfully',
1336
+ };
1200
1337
  if (nestedResult.status === 0) {
1338
+ const omxScaffoldOps = ensureOmxScaffold(blocked.repoRoot, Boolean(options.dryRun));
1339
+ const changedOmxPaths = omxScaffoldOps.filter((operation) => operation.status !== 'unchanged');
1340
+ if (changedOmxPaths.length === 0) {
1341
+ omxScaffoldSyncResult = {
1342
+ status: 'unchanged',
1343
+ note: '.omx scaffold already in sync',
1344
+ operations: omxScaffoldOps,
1345
+ };
1346
+ } else {
1347
+ omxScaffoldSyncResult = {
1348
+ status: options.dryRun ? 'would-sync' : 'synced',
1349
+ note: `${options.dryRun ? 'would sync' : 'synced'} ${changedOmxPaths.length} .omx path(s)`,
1350
+ operations: omxScaffoldOps,
1351
+ };
1352
+ }
1353
+
1201
1354
  if (!options.dryRun) {
1202
1355
  autoCommitResult = autoCommitDoctorSandboxChanges(metadata);
1203
1356
  if (autoCommitResult.status === 'committed') {
@@ -1253,6 +1406,12 @@ function runDoctorInSandbox(options, blocked) {
1253
1406
  };
1254
1407
  }
1255
1408
  }
1409
+
1410
+ postSandboxAutoFinishSummary = autoFinishReadyAgentBranches(blocked.repoRoot, {
1411
+ baseBranch: blocked.branch,
1412
+ dryRun: options.dryRun,
1413
+ excludeBranches: [metadata.branch],
1414
+ });
1256
1415
  }
1257
1416
 
1258
1417
  if (options.json) {
@@ -1264,9 +1423,11 @@ function runDoctorInSandbox(options, blocked) {
1264
1423
  JSON.stringify(
1265
1424
  {
1266
1425
  ...parsed,
1426
+ sandboxOmxScaffoldSync: omxScaffoldSyncResult,
1267
1427
  sandboxLockSync: lockSyncResult,
1268
1428
  sandboxAutoCommit: autoCommitResult,
1269
1429
  sandboxFinish: finishResult,
1430
+ autoFinish: postSandboxAutoFinishSummary,
1270
1431
  },
1271
1432
  null,
1272
1433
  2,
@@ -1332,11 +1493,42 @@ function runDoctorInSandbox(options, blocked) {
1332
1493
  } else {
1333
1494
  console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${lockSyncResult.note}.`);
1334
1495
  }
1496
+
1497
+ if (postSandboxAutoFinishSummary.enabled) {
1498
+ console.log(
1499
+ `[${TOOL_NAME}] Auto-finish sweep (base=${blocked.branch}): attempted=${postSandboxAutoFinishSummary.attempted}, completed=${postSandboxAutoFinishSummary.completed}, skipped=${postSandboxAutoFinishSummary.skipped}, failed=${postSandboxAutoFinishSummary.failed}`,
1500
+ );
1501
+ for (const detail of postSandboxAutoFinishSummary.details) {
1502
+ console.log(`[${TOOL_NAME}] ${detail}`);
1503
+ }
1504
+ } else if (postSandboxAutoFinishSummary.details.length > 0) {
1505
+ console.log(`[${TOOL_NAME}] ${postSandboxAutoFinishSummary.details[0]}`);
1506
+ }
1507
+ if (omxScaffoldSyncResult.status === 'synced') {
1508
+ console.log(`[${TOOL_NAME}] Synced .omx scaffold back to protected branch workspace.`);
1509
+ } else if (omxScaffoldSyncResult.status === 'unchanged') {
1510
+ console.log(`[${TOOL_NAME}] .omx scaffold already aligned in protected branch workspace.`);
1511
+ } else if (omxScaffoldSyncResult.status === 'would-sync') {
1512
+ console.log(`[${TOOL_NAME}] Dry run: would sync .omx scaffold back to protected branch workspace.`);
1513
+ } else {
1514
+ console.log(`[${TOOL_NAME}] .omx scaffold sync skipped: ${omxScaffoldSyncResult.note}.`);
1515
+ }
1335
1516
  }
1336
1517
  }
1337
1518
 
1338
1519
  if (typeof nestedResult.status === 'number') {
1339
- process.exitCode = nestedResult.status;
1520
+ let exitCode = nestedResult.status;
1521
+ if (exitCode === 0 && autoCommitResult.status === 'failed') {
1522
+ exitCode = 1;
1523
+ }
1524
+ if (
1525
+ exitCode === 0 &&
1526
+ autoCommitResult.status === 'committed' &&
1527
+ (finishResult.status === 'failed' || finishResult.status === 'pending')
1528
+ ) {
1529
+ exitCode = 1;
1530
+ }
1531
+ process.exitCode = exitCode;
1340
1532
  return;
1341
1533
  }
1342
1534
  process.exitCode = 1;
@@ -1363,6 +1555,18 @@ function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) {
1363
1555
  return { target, args: remaining };
1364
1556
  }
1365
1557
 
1558
+ function parseReviewArgs(rawArgs) {
1559
+ const parsed = parseTargetFlag(rawArgs, process.cwd());
1560
+ const passthroughArgs = [...parsed.args];
1561
+ if (passthroughArgs[0] === 'start') {
1562
+ passthroughArgs.shift();
1563
+ }
1564
+ return {
1565
+ target: parsed.target,
1566
+ passthroughArgs,
1567
+ };
1568
+ }
1569
+
1366
1570
  function parseReportArgs(rawArgs) {
1367
1571
  const options = {
1368
1572
  target: process.cwd(),
@@ -1628,6 +1832,210 @@ function listLocalUserBranches(repoRoot) {
1628
1832
  return [branchName];
1629
1833
  }
1630
1834
 
1835
+ function listLocalAgentBranches(repoRoot) {
1836
+ const result = gitRun(
1837
+ repoRoot,
1838
+ ['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent/'],
1839
+ { allowFailure: true },
1840
+ );
1841
+ if (result.status !== 0) {
1842
+ return [];
1843
+ }
1844
+ return uniquePreserveOrder(
1845
+ String(result.stdout || '')
1846
+ .split('\n')
1847
+ .map((item) => item.trim())
1848
+ .filter(Boolean),
1849
+ );
1850
+ }
1851
+
1852
+ function mapWorktreePathsByBranch(repoRoot) {
1853
+ const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
1854
+ const map = new Map();
1855
+ if (result.status !== 0) {
1856
+ return map;
1857
+ }
1858
+
1859
+ const lines = String(result.stdout || '').split('\n');
1860
+ let currentWorktree = '';
1861
+ for (const line of lines) {
1862
+ if (line.startsWith('worktree ')) {
1863
+ currentWorktree = line.slice('worktree '.length).trim();
1864
+ continue;
1865
+ }
1866
+ if (line.startsWith('branch refs/heads/')) {
1867
+ const branchName = line.slice('branch refs/heads/'.length).trim();
1868
+ if (currentWorktree && branchName) {
1869
+ map.set(branchName, currentWorktree);
1870
+ }
1871
+ }
1872
+ }
1873
+ return map;
1874
+ }
1875
+
1876
+ function hasSignificantWorkingTreeChanges(worktreePath) {
1877
+ const result = run('git', ['-C', worktreePath, 'status', '--porcelain']);
1878
+ if (result.status !== 0) {
1879
+ return true;
1880
+ }
1881
+
1882
+ const lines = String(result.stdout || '')
1883
+ .split('\n')
1884
+ .map((line) => line.trimEnd())
1885
+ .filter((line) => line.length > 0);
1886
+
1887
+ for (const line of lines) {
1888
+ const pathPart = (line.length > 3 ? line.slice(3) : '').trim();
1889
+ if (!pathPart) continue;
1890
+ if (pathPart === LOCK_FILE_RELATIVE) continue;
1891
+ if (pathPart.startsWith(`${LOCK_FILE_RELATIVE} -> `)) continue;
1892
+ if (pathPart.endsWith(` -> ${LOCK_FILE_RELATIVE}`)) continue;
1893
+ return true;
1894
+ }
1895
+ return false;
1896
+ }
1897
+
1898
+ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
1899
+ const baseBranch = String(options.baseBranch || '').trim();
1900
+ const dryRun = Boolean(options.dryRun);
1901
+ const excludedBranches = new Set(
1902
+ Array.isArray(options.excludeBranches)
1903
+ ? options.excludeBranches.map((branch) => String(branch || '').trim()).filter(Boolean)
1904
+ : [],
1905
+ );
1906
+
1907
+ const summary = {
1908
+ enabled: true,
1909
+ baseBranch,
1910
+ attempted: 0,
1911
+ completed: 0,
1912
+ skipped: 0,
1913
+ failed: 0,
1914
+ details: [],
1915
+ };
1916
+
1917
+ if (!baseBranch || baseBranch === 'HEAD' || baseBranch.startsWith('agent/')) {
1918
+ summary.enabled = false;
1919
+ summary.details.push('Skipped auto-finish sweep (base branch is missing or not a non-agent local branch).');
1920
+ return summary;
1921
+ }
1922
+
1923
+ if (String(process.env.MUSAFETY_DOCTOR_SANDBOX || '') === '1') {
1924
+ summary.enabled = false;
1925
+ summary.details.push('Skipped auto-finish sweep inside doctor sandbox pass.');
1926
+ return summary;
1927
+ }
1928
+
1929
+ if (String(process.env.MUSAFETY_SKIP_AUTO_FINISH_READY_BRANCHES || '') === '1') {
1930
+ summary.enabled = false;
1931
+ summary.details.push('Skipped auto-finish sweep (MUSAFETY_SKIP_AUTO_FINISH_READY_BRANCHES=1).');
1932
+ return summary;
1933
+ }
1934
+
1935
+ if (dryRun) {
1936
+ summary.enabled = false;
1937
+ summary.details.push('Skipped auto-finish sweep in dry-run mode.');
1938
+ return summary;
1939
+ }
1940
+
1941
+ const finishScript = path.join(repoRoot, 'scripts', 'agent-branch-finish.sh');
1942
+ if (!fs.existsSync(finishScript)) {
1943
+ summary.enabled = false;
1944
+ summary.details.push(`Skipped auto-finish sweep (missing ${path.relative(repoRoot, finishScript)}).`);
1945
+ return summary;
1946
+ }
1947
+
1948
+ const hasOrigin = gitRun(repoRoot, ['remote', 'get-url', 'origin'], { allowFailure: true }).status === 0;
1949
+ if (!hasOrigin) {
1950
+ summary.enabled = false;
1951
+ summary.details.push('Skipped auto-finish sweep (origin remote missing).');
1952
+ return summary;
1953
+ }
1954
+ const explicitGhBin = Boolean(String(process.env.MUSAFETY_GH_BIN || '').trim());
1955
+ if (!explicitGhBin && !originRemoteLooksLikeGithub(repoRoot)) {
1956
+ summary.enabled = false;
1957
+ summary.details.push('Skipped auto-finish sweep (origin remote is not GitHub).');
1958
+ return summary;
1959
+ }
1960
+
1961
+ const ghBin = process.env.MUSAFETY_GH_BIN || 'gh';
1962
+ if (run(ghBin, ['--version']).status !== 0) {
1963
+ summary.enabled = false;
1964
+ summary.details.push(`Skipped auto-finish sweep (${ghBin} not available).`);
1965
+ return summary;
1966
+ }
1967
+
1968
+ const branchWorktrees = mapWorktreePathsByBranch(repoRoot);
1969
+ const agentBranches = listLocalAgentBranches(repoRoot);
1970
+ if (agentBranches.length === 0) {
1971
+ summary.enabled = false;
1972
+ summary.details.push('No local agent branches found for auto-finish sweep.');
1973
+ return summary;
1974
+ }
1975
+
1976
+ for (const branch of agentBranches) {
1977
+ if (excludedBranches.has(branch)) {
1978
+ summary.skipped += 1;
1979
+ summary.details.push(`[skip] ${branch}: excluded from this auto-finish sweep.`);
1980
+ continue;
1981
+ }
1982
+
1983
+ if (branch === baseBranch) {
1984
+ summary.skipped += 1;
1985
+ summary.details.push(`[skip] ${branch}: source branch equals base branch.`);
1986
+ continue;
1987
+ }
1988
+
1989
+ let counts;
1990
+ try {
1991
+ counts = aheadBehind(repoRoot, branch, baseBranch);
1992
+ } catch (error) {
1993
+ summary.failed += 1;
1994
+ summary.details.push(`[fail] ${branch}: unable to compute ahead/behind (${error.message}).`);
1995
+ continue;
1996
+ }
1997
+
1998
+ if (counts.ahead <= 0) {
1999
+ summary.skipped += 1;
2000
+ summary.details.push(`[skip] ${branch}: already merged into ${baseBranch}.`);
2001
+ continue;
2002
+ }
2003
+
2004
+ const branchWorktree = branchWorktrees.get(branch) || '';
2005
+ if (branchWorktree && hasSignificantWorkingTreeChanges(branchWorktree)) {
2006
+ summary.skipped += 1;
2007
+ summary.details.push(`[skip] ${branch}: dirty worktree (${branchWorktree}).`);
2008
+ continue;
2009
+ }
2010
+
2011
+ summary.attempted += 1;
2012
+ const finishArgs = [
2013
+ finishScript,
2014
+ '--branch',
2015
+ branch,
2016
+ '--base',
2017
+ baseBranch,
2018
+ '--via-pr',
2019
+ '--wait-for-merge',
2020
+ '--cleanup',
2021
+ ];
2022
+ const finishResult = run('bash', finishArgs, { cwd: repoRoot });
2023
+ const combinedOutput = [finishResult.stdout || '', finishResult.stderr || ''].join('\n').trim();
2024
+
2025
+ if (finishResult.status === 0) {
2026
+ summary.completed += 1;
2027
+ summary.details.push(`[done] ${branch}: auto-finish completed.`);
2028
+ continue;
2029
+ }
2030
+
2031
+ summary.failed += 1;
2032
+ const tail = combinedOutput ? ` ${combinedOutput.split('\n').slice(-2).join(' | ')}` : '';
2033
+ summary.details.push(`[fail] ${branch}: auto-finish failed.${tail}`);
2034
+ }
2035
+
2036
+ return summary;
2037
+ }
2038
+
1631
2039
  function ensureSetupProtectedBranches(repoRoot, dryRun) {
1632
2040
  const localUserBranches = listLocalUserBranches(repoRoot);
1633
2041
  if (localUserBranches.length === 0) {
@@ -1921,6 +2329,382 @@ function parseCleanupArgs(rawArgs) {
1921
2329
  return options;
1922
2330
  }
1923
2331
 
2332
+ function parseFinishArgs(rawArgs) {
2333
+ const options = {
2334
+ target: process.cwd(),
2335
+ base: '',
2336
+ branch: '',
2337
+ all: false,
2338
+ dryRun: false,
2339
+ waitForMerge: true,
2340
+ cleanup: true,
2341
+ keepRemote: false,
2342
+ noAutoCommit: false,
2343
+ failFast: false,
2344
+ commitMessage: '',
2345
+ };
2346
+
2347
+ for (let index = 0; index < rawArgs.length; index += 1) {
2348
+ const arg = rawArgs[index];
2349
+ if (arg === '--target') {
2350
+ const next = rawArgs[index + 1];
2351
+ if (!next) {
2352
+ throw new Error('--target requires a path value');
2353
+ }
2354
+ options.target = next;
2355
+ index += 1;
2356
+ continue;
2357
+ }
2358
+ if (arg === '--base') {
2359
+ const next = rawArgs[index + 1];
2360
+ if (!next) {
2361
+ throw new Error('--base requires a branch value');
2362
+ }
2363
+ options.base = next;
2364
+ index += 1;
2365
+ continue;
2366
+ }
2367
+ if (arg === '--branch') {
2368
+ const next = rawArgs[index + 1];
2369
+ if (!next) {
2370
+ throw new Error('--branch requires an agent/* branch value');
2371
+ }
2372
+ options.branch = next;
2373
+ index += 1;
2374
+ continue;
2375
+ }
2376
+ if (arg === '--commit-message') {
2377
+ const next = rawArgs[index + 1];
2378
+ if (!next) {
2379
+ throw new Error('--commit-message requires a value');
2380
+ }
2381
+ options.commitMessage = next;
2382
+ index += 1;
2383
+ continue;
2384
+ }
2385
+ if (arg === '--all') {
2386
+ options.all = true;
2387
+ continue;
2388
+ }
2389
+ if (arg === '--dry-run') {
2390
+ options.dryRun = true;
2391
+ continue;
2392
+ }
2393
+ if (arg === '--wait-for-merge') {
2394
+ options.waitForMerge = true;
2395
+ continue;
2396
+ }
2397
+ if (arg === '--no-wait-for-merge') {
2398
+ options.waitForMerge = false;
2399
+ continue;
2400
+ }
2401
+ if (arg === '--cleanup') {
2402
+ options.cleanup = true;
2403
+ continue;
2404
+ }
2405
+ if (arg === '--no-cleanup') {
2406
+ options.cleanup = false;
2407
+ continue;
2408
+ }
2409
+ if (arg === '--keep-remote') {
2410
+ options.keepRemote = true;
2411
+ continue;
2412
+ }
2413
+ if (arg === '--no-auto-commit') {
2414
+ options.noAutoCommit = true;
2415
+ continue;
2416
+ }
2417
+ if (arg === '--fail-fast') {
2418
+ options.failFast = true;
2419
+ continue;
2420
+ }
2421
+ throw new Error(`Unknown option: ${arg}`);
2422
+ }
2423
+
2424
+ if (options.branch && !options.branch.startsWith('agent/')) {
2425
+ throw new Error(`--branch must reference an agent/* branch (received: ${options.branch})`);
2426
+ }
2427
+
2428
+ return options;
2429
+ }
2430
+
2431
+ function listAgentWorktrees(repoRoot) {
2432
+ const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
2433
+ if (result.status !== 0) {
2434
+ throw new Error('Unable to list git worktrees for finish command');
2435
+ }
2436
+
2437
+ const entries = [];
2438
+ let currentPath = '';
2439
+ let currentBranchRef = '';
2440
+ const lines = String(result.stdout || '').split('\n');
2441
+ for (const line of lines) {
2442
+ if (!line.trim()) {
2443
+ if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
2444
+ entries.push({
2445
+ worktreePath: currentPath,
2446
+ branch: currentBranchRef.replace(/^refs\/heads\//, ''),
2447
+ });
2448
+ }
2449
+ currentPath = '';
2450
+ currentBranchRef = '';
2451
+ continue;
2452
+ }
2453
+ if (line.startsWith('worktree ')) {
2454
+ currentPath = line.slice('worktree '.length).trim();
2455
+ continue;
2456
+ }
2457
+ if (line.startsWith('branch ')) {
2458
+ currentBranchRef = line.slice('branch '.length).trim();
2459
+ continue;
2460
+ }
2461
+ }
2462
+ if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
2463
+ entries.push({
2464
+ worktreePath: currentPath,
2465
+ branch: currentBranchRef.replace(/^refs\/heads\//, ''),
2466
+ });
2467
+ }
2468
+
2469
+ return entries;
2470
+ }
2471
+
2472
+ function listLocalAgentBranchesForFinish(repoRoot) {
2473
+ const result = gitRun(
2474
+ repoRoot,
2475
+ ['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent/'],
2476
+ { allowFailure: true },
2477
+ );
2478
+ if (result.status !== 0) {
2479
+ throw new Error('Unable to list local agent branches');
2480
+ }
2481
+ return uniquePreserveOrder(
2482
+ String(result.stdout || '')
2483
+ .split('\n')
2484
+ .map((line) => line.trim())
2485
+ .filter((line) => line.startsWith('agent/')),
2486
+ );
2487
+ }
2488
+
2489
+ function gitQuietChangeResult(worktreePath, args) {
2490
+ const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
2491
+ if (result.status === 0) {
2492
+ return false;
2493
+ }
2494
+ if (result.status === 1) {
2495
+ return true;
2496
+ }
2497
+ throw new Error(
2498
+ `git ${args.join(' ')} failed in ${worktreePath}: ${(
2499
+ result.stderr || result.stdout || ''
2500
+ ).trim()}`,
2501
+ );
2502
+ }
2503
+
2504
+ function worktreeHasLocalChanges(worktreePath) {
2505
+ const hasUnstaged = gitQuietChangeResult(worktreePath, [
2506
+ 'diff',
2507
+ '--quiet',
2508
+ '--',
2509
+ '.',
2510
+ ':(exclude).omx/state/agent-file-locks.json',
2511
+ ]);
2512
+ if (hasUnstaged) {
2513
+ return true;
2514
+ }
2515
+
2516
+ const hasStaged = gitQuietChangeResult(worktreePath, [
2517
+ 'diff',
2518
+ '--cached',
2519
+ '--quiet',
2520
+ '--',
2521
+ '.',
2522
+ ':(exclude).omx/state/agent-file-locks.json',
2523
+ ]);
2524
+ if (hasStaged) {
2525
+ return true;
2526
+ }
2527
+
2528
+ const untracked = run('git', ['-C', worktreePath, 'ls-files', '--others', '--exclude-standard'], {
2529
+ stdio: 'pipe',
2530
+ });
2531
+ if (untracked.status !== 0) {
2532
+ throw new Error(`Unable to inspect untracked files in ${worktreePath}`);
2533
+ }
2534
+ return String(untracked.stdout || '').trim().length > 0;
2535
+ }
2536
+
2537
+ function gitOutputLines(worktreePath, args) {
2538
+ const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
2539
+ if (result.status !== 0) {
2540
+ throw new Error(
2541
+ `git ${args.join(' ')} failed in ${worktreePath}: ${(
2542
+ result.stderr || result.stdout || ''
2543
+ ).trim()}`,
2544
+ );
2545
+ }
2546
+ return String(result.stdout || '')
2547
+ .split('\n')
2548
+ .map((line) => line.trim())
2549
+ .filter(Boolean);
2550
+ }
2551
+
2552
+ function claimLocksForAutoCommit(repoRoot, worktreePath, branch) {
2553
+ const lockScript = path.join(repoRoot, 'scripts', 'agent-file-locks.py');
2554
+ if (!fs.existsSync(lockScript)) {
2555
+ return;
2556
+ }
2557
+
2558
+ const changedFiles = uniquePreserveOrder([
2559
+ ...gitOutputLines(worktreePath, ['diff', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
2560
+ ...gitOutputLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
2561
+ ...gitOutputLines(worktreePath, ['ls-files', '--others', '--exclude-standard']),
2562
+ ]);
2563
+
2564
+ if (changedFiles.length > 0) {
2565
+ const claim = run('python3', [lockScript, 'claim', '--branch', branch, ...changedFiles], {
2566
+ cwd: repoRoot,
2567
+ stdio: 'pipe',
2568
+ });
2569
+ if (claim.status !== 0) {
2570
+ throw new Error(
2571
+ `Lock claim failed for ${branch}: ${(
2572
+ claim.stderr || claim.stdout || ''
2573
+ ).trim()}`,
2574
+ );
2575
+ }
2576
+ }
2577
+
2578
+ const deletedFiles = uniquePreserveOrder([
2579
+ ...gitOutputLines(worktreePath, [
2580
+ 'diff',
2581
+ '--name-only',
2582
+ '--diff-filter=D',
2583
+ '--',
2584
+ '.',
2585
+ ':(exclude).omx/state/agent-file-locks.json',
2586
+ ]),
2587
+ ...gitOutputLines(worktreePath, [
2588
+ 'diff',
2589
+ '--cached',
2590
+ '--name-only',
2591
+ '--diff-filter=D',
2592
+ '--',
2593
+ '.',
2594
+ ':(exclude).omx/state/agent-file-locks.json',
2595
+ ]),
2596
+ ]);
2597
+
2598
+ if (deletedFiles.length > 0) {
2599
+ const allowDelete = run('python3', [lockScript, 'allow-delete', '--branch', branch, ...deletedFiles], {
2600
+ cwd: repoRoot,
2601
+ stdio: 'pipe',
2602
+ });
2603
+ if (allowDelete.status !== 0) {
2604
+ throw new Error(
2605
+ `Delete-lock grant failed for ${branch}: ${(
2606
+ allowDelete.stderr || allowDelete.stdout || ''
2607
+ ).trim()}`,
2608
+ );
2609
+ }
2610
+ }
2611
+ }
2612
+
2613
+ function branchExists(repoRoot, branch) {
2614
+ const result = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
2615
+ allowFailure: true,
2616
+ });
2617
+ return result.status === 0;
2618
+ }
2619
+
2620
+ function resolveFinishBaseBranch(repoRoot, sourceBranch, explicitBase) {
2621
+ if (explicitBase) {
2622
+ return explicitBase;
2623
+ }
2624
+
2625
+ const branchSpecific = readGitConfig(repoRoot, `branch.${sourceBranch}.musafetyBase`);
2626
+ if (branchSpecific) {
2627
+ return branchSpecific;
2628
+ }
2629
+
2630
+ const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
2631
+ if (configured) {
2632
+ return configured;
2633
+ }
2634
+
2635
+ const current = gitRun(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFailure: true });
2636
+ const currentBranch = String(current.stdout || '').trim();
2637
+ if (current.status === 0 && currentBranch && currentBranch !== 'HEAD' && !currentBranch.startsWith('agent/')) {
2638
+ return currentBranch;
2639
+ }
2640
+
2641
+ return DEFAULT_BASE_BRANCH;
2642
+ }
2643
+
2644
+ function branchMergedIntoBase(repoRoot, branch, baseBranch) {
2645
+ if (!branchExists(repoRoot, baseBranch)) {
2646
+ return false;
2647
+ }
2648
+ const result = gitRun(repoRoot, ['merge-base', '--is-ancestor', branch, baseBranch], {
2649
+ allowFailure: true,
2650
+ });
2651
+ if (result.status === 0) {
2652
+ return true;
2653
+ }
2654
+ if (result.status === 1) {
2655
+ return false;
2656
+ }
2657
+ throw new Error(`Unable to determine merge status for ${branch} -> ${baseBranch}`);
2658
+ }
2659
+
2660
+ function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) {
2661
+ const hasChanges = worktreeHasLocalChanges(worktreePath);
2662
+ if (!hasChanges) {
2663
+ return { changed: false, committed: false };
2664
+ }
2665
+
2666
+ if (options.noAutoCommit) {
2667
+ throw new Error(
2668
+ `Branch '${branch}' has local changes in ${worktreePath}. Re-run without --no-auto-commit or commit manually first.`,
2669
+ );
2670
+ }
2671
+
2672
+ if (options.dryRun) {
2673
+ return { changed: true, committed: false, dryRun: true };
2674
+ }
2675
+
2676
+ claimLocksForAutoCommit(repoRoot, worktreePath, branch);
2677
+
2678
+ const addResult = run('git', ['-C', worktreePath, 'add', '-A'], { stdio: 'pipe' });
2679
+ if (addResult.status !== 0) {
2680
+ throw new Error(`git add failed in ${worktreePath}: ${(addResult.stderr || addResult.stdout || '').trim()}`);
2681
+ }
2682
+
2683
+ const stagedHasChanges = gitQuietChangeResult(worktreePath, [
2684
+ 'diff',
2685
+ '--cached',
2686
+ '--quiet',
2687
+ '--',
2688
+ '.',
2689
+ ':(exclude).omx/state/agent-file-locks.json',
2690
+ ]);
2691
+ if (!stagedHasChanges) {
2692
+ return { changed: true, committed: false };
2693
+ }
2694
+
2695
+ const commitMessage = options.commitMessage || `Auto-finish: ${branch}`;
2696
+ const commitResult = run('git', ['-C', worktreePath, 'commit', '-m', commitMessage], { stdio: 'pipe' });
2697
+ if (commitResult.status !== 0) {
2698
+ throw new Error(
2699
+ `Auto-commit failed on '${branch}': ${(
2700
+ commitResult.stderr || commitResult.stdout || ''
2701
+ ).trim()}`,
2702
+ );
2703
+ }
2704
+
2705
+ return { changed: true, committed: true, message: commitMessage };
2706
+ }
2707
+
1924
2708
  function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
1925
2709
  if (strategy === 'rebase') {
1926
2710
  if (ffOnly) {
@@ -2325,6 +3109,8 @@ function runInstallInternal(options) {
2325
3109
  const repoRoot = resolveRepoRoot(options.target);
2326
3110
  const operations = [];
2327
3111
 
3112
+ operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));
3113
+
2328
3114
  for (const templateFile of TEMPLATE_FILES) {
2329
3115
  operations.push(copyTemplateFile(repoRoot, templateFile, Boolean(options.force), Boolean(options.dryRun)));
2330
3116
  }
@@ -2351,6 +3137,8 @@ function runFixInternal(options) {
2351
3137
  const repoRoot = resolveRepoRoot(options.target);
2352
3138
  const operations = [];
2353
3139
 
3140
+ operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));
3141
+
2354
3142
  for (const templateFile of TEMPLATE_FILES) {
2355
3143
  operations.push(ensureTemplateFilePresent(repoRoot, templateFile, Boolean(options.dryRun)));
2356
3144
  }
@@ -2404,6 +3192,8 @@ function runScanInternal(options) {
2404
3192
  const findings = [];
2405
3193
 
2406
3194
  const requiredPaths = [
3195
+ ...OMX_SCAFFOLD_DIRECTORIES,
3196
+ ...Array.from(OMX_SCAFFOLD_FILES.keys()),
2407
3197
  ...TEMPLATE_FILES.map((entry) => toDestinationPath(entry)),
2408
3198
  LOCK_FILE_RELATIVE,
2409
3199
  ];
@@ -2755,6 +3545,11 @@ function doctor(rawArgs) {
2755
3545
  assertProtectedMainWriteAllowed(options, 'doctor');
2756
3546
  const fixPayload = runFixInternal(options);
2757
3547
  const scanResult = runScanInternal({ target: options.target, json: false });
3548
+ const currentBaseBranch = currentBranchName(scanResult.repoRoot);
3549
+ const autoFinishSummary = autoFinishReadyAgentBranches(scanResult.repoRoot, {
3550
+ baseBranch: currentBaseBranch,
3551
+ dryRun: options.dryRun,
3552
+ });
2758
3553
  const safe = scanResult.errors === 0 && scanResult.warnings === 0;
2759
3554
  const musafe = safe;
2760
3555
 
@@ -2776,6 +3571,7 @@ function doctor(rawArgs) {
2776
3571
  warnings: scanResult.warnings,
2777
3572
  findings: scanResult.findings,
2778
3573
  },
3574
+ autoFinish: autoFinishSummary,
2779
3575
  },
2780
3576
  null,
2781
3577
  2,
@@ -2787,6 +3583,16 @@ function doctor(rawArgs) {
2787
3583
 
2788
3584
  printOperations('Doctor/fix', fixPayload, options.dryRun);
2789
3585
  printScanResult(scanResult, false);
3586
+ if (autoFinishSummary.enabled) {
3587
+ console.log(
3588
+ `[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
3589
+ );
3590
+ for (const detail of autoFinishSummary.details) {
3591
+ console.log(`[${TOOL_NAME}] ${detail}`);
3592
+ }
3593
+ } else if (autoFinishSummary.details.length > 0) {
3594
+ console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
3595
+ }
2790
3596
  if (safe) {
2791
3597
  console.log(`[${TOOL_NAME}] ✅ Repo is fully safe.`);
2792
3598
  } else {
@@ -2797,6 +3603,27 @@ function doctor(rawArgs) {
2797
3603
  setExitCodeFromScan(scanResult);
2798
3604
  }
2799
3605
 
3606
+ function review(rawArgs) {
3607
+ const options = parseReviewArgs(rawArgs);
3608
+ const repoRoot = resolveRepoRoot(options.target);
3609
+ const reviewScriptPath = path.join(repoRoot, 'scripts', 'review-bot-watch.sh');
3610
+ if (!fs.existsSync(reviewScriptPath)) {
3611
+ throw new Error(
3612
+ `Missing review bot script: ${reviewScriptPath}\n` +
3613
+ `Run '${SHORT_TOOL_NAME} setup --target ${repoRoot}' then '${SHORT_TOOL_NAME} doctor --target ${repoRoot}'.`,
3614
+ );
3615
+ }
3616
+
3617
+ const result = run('bash', [reviewScriptPath, ...options.passthroughArgs], { cwd: repoRoot });
3618
+ if (isSpawnFailure(result)) {
3619
+ throw result.error;
3620
+ }
3621
+
3622
+ if (result.stdout) process.stdout.write(result.stdout);
3623
+ if (result.stderr) process.stderr.write(result.stderr);
3624
+ process.exitCode = typeof result.status === 'number' ? result.status : 1;
3625
+ }
3626
+
2800
3627
  function report(rawArgs) {
2801
3628
  const options = parseReportArgs(rawArgs);
2802
3629
  const subcommand = options.subcommand || 'help';
@@ -2955,7 +3782,22 @@ function setup(rawArgs) {
2955
3782
  }
2956
3783
 
2957
3784
  const scanResult = runScanInternal({ target: options.target, json: false });
3785
+ const currentBaseBranch = currentBranchName(scanResult.repoRoot);
3786
+ const autoFinishSummary = autoFinishReadyAgentBranches(scanResult.repoRoot, {
3787
+ baseBranch: currentBaseBranch,
3788
+ dryRun: options.dryRun,
3789
+ });
2958
3790
  printScanResult(scanResult, false);
3791
+ if (autoFinishSummary.enabled) {
3792
+ console.log(
3793
+ `[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
3794
+ );
3795
+ for (const detail of autoFinishSummary.details) {
3796
+ console.log(`[${TOOL_NAME}] ${detail}`);
3797
+ }
3798
+ } else if (autoFinishSummary.details.length > 0) {
3799
+ console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
3800
+ }
2959
3801
 
2960
3802
  if (scanResult.errors === 0 && scanResult.warnings === 0) {
2961
3803
  console.log(`[${TOOL_NAME}] ✅ Setup complete.`);
@@ -3065,6 +3907,129 @@ function cleanup(rawArgs) {
3065
3907
  process.exitCode = 0;
3066
3908
  }
3067
3909
 
3910
+ function finish(rawArgs) {
3911
+ const options = parseFinishArgs(rawArgs);
3912
+ const repoRoot = resolveRepoRoot(options.target);
3913
+ const finishScript = path.join(repoRoot, 'scripts', 'agent-branch-finish.sh');
3914
+
3915
+ if (!fs.existsSync(finishScript)) {
3916
+ throw new Error(`Missing finish script: ${finishScript}. Run '${SHORT_TOOL_NAME} setup' first.`);
3917
+ }
3918
+
3919
+ const worktreeEntries = listAgentWorktrees(repoRoot);
3920
+ const worktreeByBranch = new Map(worktreeEntries.map((entry) => [entry.branch, entry.worktreePath]));
3921
+
3922
+ let candidateBranches = [];
3923
+ if (options.branch) {
3924
+ if (!branchExists(repoRoot, options.branch)) {
3925
+ throw new Error(`Local branch not found: ${options.branch}`);
3926
+ }
3927
+ candidateBranches = [options.branch];
3928
+ } else {
3929
+ candidateBranches = uniquePreserveOrder([
3930
+ ...listLocalAgentBranchesForFinish(repoRoot),
3931
+ ...worktreeEntries.map((entry) => entry.branch),
3932
+ ]);
3933
+ }
3934
+
3935
+ const candidates = [];
3936
+ for (const branch of candidateBranches) {
3937
+ const worktreePath = worktreeByBranch.get(branch) || '';
3938
+ const baseBranch = resolveFinishBaseBranch(repoRoot, branch, options.base);
3939
+ const hasChanges = worktreePath ? worktreeHasLocalChanges(worktreePath) : false;
3940
+ const alreadyMerged = branchMergedIntoBase(repoRoot, branch, baseBranch);
3941
+ if (options.all || options.branch || hasChanges || !alreadyMerged) {
3942
+ candidates.push({
3943
+ branch,
3944
+ baseBranch,
3945
+ worktreePath,
3946
+ hasChanges,
3947
+ alreadyMerged,
3948
+ });
3949
+ }
3950
+ }
3951
+
3952
+ if (candidates.length === 0) {
3953
+ console.log(`[${TOOL_NAME}] No pending agent branches to finish.`);
3954
+ process.exitCode = 0;
3955
+ return;
3956
+ }
3957
+
3958
+ let succeeded = 0;
3959
+ let failed = 0;
3960
+ let autoCommitted = 0;
3961
+
3962
+ for (const candidate of candidates) {
3963
+ const { branch, baseBranch, worktreePath } = candidate;
3964
+ console.log(
3965
+ `[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}...`,
3966
+ );
3967
+
3968
+ try {
3969
+ let commitState = { changed: false, committed: false };
3970
+ if (worktreePath) {
3971
+ commitState = autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options);
3972
+ }
3973
+
3974
+ if (commitState.committed) {
3975
+ autoCommitted += 1;
3976
+ console.log(`[${TOOL_NAME}] Auto-committed '${branch}' before finish.`);
3977
+ } else if (commitState.changed && commitState.dryRun) {
3978
+ console.log(`[${TOOL_NAME}] [dry-run] Would auto-commit pending changes on '${branch}'.`);
3979
+ }
3980
+
3981
+ const finishArgs = [
3982
+ finishScript,
3983
+ '--branch',
3984
+ branch,
3985
+ '--base',
3986
+ baseBranch,
3987
+ '--via-pr',
3988
+ options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge',
3989
+ options.cleanup ? '--cleanup' : '--no-cleanup',
3990
+ ];
3991
+ if (options.keepRemote) {
3992
+ finishArgs.push('--keep-remote-branch');
3993
+ }
3994
+
3995
+ if (options.dryRun) {
3996
+ console.log(`[${TOOL_NAME}] [dry-run] Would run: bash ${finishArgs.join(' ')}`);
3997
+ succeeded += 1;
3998
+ continue;
3999
+ }
4000
+
4001
+ const finishResult = run('bash', finishArgs, { cwd: repoRoot, stdio: 'pipe' });
4002
+ if (finishResult.stdout) {
4003
+ process.stdout.write(finishResult.stdout);
4004
+ }
4005
+ if (finishResult.stderr) {
4006
+ process.stderr.write(finishResult.stderr);
4007
+ }
4008
+ if (finishResult.status !== 0) {
4009
+ throw new Error(`agent-branch-finish exited with status ${finishResult.status}`);
4010
+ }
4011
+
4012
+ succeeded += 1;
4013
+ } catch (error) {
4014
+ failed += 1;
4015
+ console.error(`[${TOOL_NAME}] Finish failed for '${branch}': ${error.message}`);
4016
+ if (options.failFast) {
4017
+ break;
4018
+ }
4019
+ }
4020
+ }
4021
+
4022
+ console.log(
4023
+ `[${TOOL_NAME}] Finish summary: total=${candidates.length}, success=${succeeded}, failed=${failed}, autoCommitted=${autoCommitted}`,
4024
+ );
4025
+
4026
+ if (failed > 0) {
4027
+ throw new Error('finish command failed for one or more agent branches');
4028
+ }
4029
+
4030
+ process.exitCode = 0;
4031
+ }
4032
+
3068
4033
  function sync(rawArgs) {
3069
4034
  const options = parseSyncArgs(rawArgs);
3070
4035
  const repoRoot = resolveRepoRoot(options.target);
@@ -3388,6 +4353,16 @@ function main() {
3388
4353
  return;
3389
4354
  }
3390
4355
 
4356
+ if (command === 'review') {
4357
+ review(rest);
4358
+ return;
4359
+ }
4360
+
4361
+ if (command === 'finish') {
4362
+ finish(rest);
4363
+ return;
4364
+ }
4365
+
3391
4366
  if (command === 'report') {
3392
4367
  report(rest);
3393
4368
  return;