@imdeadpool/guardex 7.0.14 → 7.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -64,7 +64,7 @@ const REQUIRED_SYSTEM_TOOLS = [
64
64
  },
65
65
  ];
66
66
  const MAINTAINER_RELEASE_REPO = path.resolve(
67
- process.env.GUARDEX_RELEASE_REPO || '/tmp/multiagent-safety',
67
+ process.env.GUARDEX_RELEASE_REPO || path.resolve(__dirname, '..'),
68
68
  );
69
69
  const NPM_BIN = process.env.GUARDEX_NPM_BIN || 'npm';
70
70
  const OPENSPEC_BIN = process.env.GUARDEX_OPENSPEC_BIN || 'openspec';
@@ -77,6 +77,12 @@ const DEFAULT_PROTECTED_BRANCHES = ['dev', 'main', 'master'];
77
77
  const DEFAULT_BASE_BRANCH = 'dev';
78
78
  const DEFAULT_SYNC_STRATEGY = 'rebase';
79
79
  const DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES = 60;
80
+ const COMPOSE_HINT_FILES = [
81
+ 'docker-compose.yml',
82
+ 'docker-compose.yaml',
83
+ 'compose.yml',
84
+ 'compose.yaml',
85
+ ];
80
86
 
81
87
  const TEMPLATE_ROOT = path.resolve(__dirname, '..', 'templates');
82
88
 
@@ -84,6 +90,7 @@ const TEMPLATE_FILES = [
84
90
  'scripts/agent-branch-start.sh',
85
91
  'scripts/agent-branch-finish.sh',
86
92
  'scripts/codex-agent.sh',
93
+ 'scripts/guardex-docker-loader.sh',
87
94
  'scripts/review-bot-watch.sh',
88
95
  'scripts/agent-worktree-prune.sh',
89
96
  'scripts/agent-file-locks.py',
@@ -105,6 +112,7 @@ const TEMPLATE_FILES = [
105
112
  const REQUIRED_WORKFLOW_FILES = [
106
113
  'scripts/agent-branch-start.sh',
107
114
  'scripts/agent-branch-finish.sh',
115
+ 'scripts/guardex-docker-loader.sh',
108
116
  'scripts/agent-worktree-prune.sh',
109
117
  'scripts/agent-file-locks.py',
110
118
  'scripts/guardex-env.sh',
@@ -133,6 +141,7 @@ const REQUIRED_PACKAGE_SCRIPTS = {
133
141
  'agent:safety:scan': 'gx status --strict',
134
142
  'agent:safety:fix': 'gx setup --repair',
135
143
  'agent:safety:doctor': 'gx doctor',
144
+ 'agent:docker:load': 'bash ./scripts/guardex-docker-loader.sh',
136
145
  'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
137
146
  'agent:finish': 'gx finish --all',
138
147
  };
@@ -141,6 +150,7 @@ const EXECUTABLE_RELATIVE_PATHS = new Set([
141
150
  'scripts/agent-branch-start.sh',
142
151
  'scripts/agent-branch-finish.sh',
143
152
  'scripts/codex-agent.sh',
153
+ 'scripts/guardex-docker-loader.sh',
144
154
  'scripts/review-bot-watch.sh',
145
155
  'scripts/agent-worktree-prune.sh',
146
156
  'scripts/agent-file-locks.py',
@@ -173,10 +183,18 @@ const AGENTS_MARKER_START = '<!-- multiagent-safety:START -->';
173
183
  const AGENTS_MARKER_END = '<!-- multiagent-safety:END -->';
174
184
  const GITIGNORE_MARKER_START = '# multiagent-safety:START';
175
185
  const GITIGNORE_MARKER_END = '# multiagent-safety:END';
186
+ const CODEX_WORKTREE_RELATIVE_DIR = path.join('.omx', 'agent-worktrees');
187
+ const CLAUDE_WORKTREE_RELATIVE_DIR = path.join('.omc', 'agent-worktrees');
188
+ const AGENT_WORKTREE_RELATIVE_DIRS = [
189
+ CODEX_WORKTREE_RELATIVE_DIR,
190
+ CLAUDE_WORKTREE_RELATIVE_DIR,
191
+ ];
176
192
  const MANAGED_GITIGNORE_PATHS = [
177
193
  '.omx/',
178
194
  '.omc/',
179
195
  'scripts/*',
196
+ 'scripts/agent-branch-start.sh',
197
+ 'scripts/agent-file-locks.py',
180
198
  '.githooks',
181
199
  'oh-my-codex/',
182
200
  '.codex/skills/gitguardex/SKILL.md',
@@ -190,7 +208,9 @@ const OMX_SCAFFOLD_DIRECTORIES = [
190
208
  '.omx/state',
191
209
  '.omx/logs',
192
210
  '.omx/plans',
193
- '.omx/agent-worktrees',
211
+ CODEX_WORKTREE_RELATIVE_DIR,
212
+ '.omc',
213
+ CLAUDE_WORKTREE_RELATIVE_DIR,
194
214
  ];
195
215
  const OMX_SCAFFOLD_FILES = new Map([
196
216
  ['.omx/notepad.md', '\n\n## WORKING MEMORY\n'],
@@ -240,6 +260,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
240
260
  ['sync', 'Sync agent branches with origin/<base>'],
241
261
  ['finish', 'Commit + PR + merge completed agent branches (--all, --branch)'],
242
262
  ['cleanup', 'Prune merged/stale agent branches and worktrees'],
263
+ ['release', 'Create or update the current GitHub release with README-generated notes'],
243
264
  ['agents', 'Start/stop repo-scoped review + cleanup bots'],
244
265
  ['prompt', 'Print AI setup checklist (--exec, --snippet)'],
245
266
  ['report', 'Security/safety reports (e.g. OpenSSF scorecard)'],
@@ -260,6 +281,19 @@ const AGENT_BOT_DESCRIPTIONS = [
260
281
  ['agents', 'Start/stop review + cleanup bots for this repo'],
261
282
  ];
262
283
 
284
+ function envFlagIsTruthy(raw) {
285
+ const lowered = String(raw || '').trim().toLowerCase();
286
+ return lowered === '1' || lowered === 'true' || lowered === 'yes' || lowered === 'on';
287
+ }
288
+
289
+ function isClaudeCodeSession(env = process.env) {
290
+ return envFlagIsTruthy(env.CLAUDECODE) || Boolean(env.CLAUDE_CODE_SESSION_ID);
291
+ }
292
+
293
+ function defaultAgentWorktreeRelativeDir(env = process.env) {
294
+ return isClaudeCodeSession(env) ? CLAUDE_WORKTREE_RELATIVE_DIR : CODEX_WORKTREE_RELATIVE_DIR;
295
+ }
296
+
263
297
  const AI_SETUP_PROMPT = `GitGuardex (gx) setup checklist for Codex/Claude in this repo.
264
298
 
265
299
  1) Install: npm i -g @imdeadpool/guardex && gh --version
@@ -509,8 +543,6 @@ const NESTED_REPO_DEFAULT_SKIP_DIRS = new Set([
509
543
  '.venv',
510
544
  '.pnpm-store',
511
545
  ]);
512
- const NESTED_REPO_WORKTREE_RELATIVE_DIR = path.join('.omx', 'agent-worktrees');
513
-
514
546
  function discoverNestedGitRepos(rootPath, opts = {}) {
515
547
  const maxDepth = Number.isFinite(opts.maxDepth) ? Math.max(1, opts.maxDepth) : NESTED_REPO_DEFAULT_MAX_DEPTH;
516
548
  const extraSkip = new Set(Array.isArray(opts.extraSkip) ? opts.extraSkip : []);
@@ -525,7 +557,7 @@ function discoverNestedGitRepos(rootPath, opts = {}) {
525
557
  return path.resolve(resolvedRoot, raw);
526
558
  })();
527
559
 
528
- const workreeSkipAbsolute = path.join(resolvedRoot, NESTED_REPO_WORKTREE_RELATIVE_DIR);
560
+ const worktreeSkipAbsolutes = AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => path.join(resolvedRoot, relativeDir));
529
561
  const found = new Set();
530
562
  found.add(resolvedRoot);
531
563
 
@@ -557,7 +589,7 @@ function discoverNestedGitRepos(rootPath, opts = {}) {
557
589
 
558
590
  if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
559
591
  if (shouldSkipDir(entry.name)) continue;
560
- if (entryPath === workreeSkipAbsolute) continue;
592
+ if (worktreeSkipAbsolutes.includes(entryPath)) continue;
561
593
  walk(entryPath, depth + 1);
562
594
  }
563
595
  }
@@ -1000,6 +1032,14 @@ function parseCommonArgs(rawArgs, defaults) {
1000
1032
  options.allowProtectedBaseWrite = true;
1001
1033
  continue;
1002
1034
  }
1035
+ if (Object.prototype.hasOwnProperty.call(options, 'waitForMerge') && arg === '--wait-for-merge') {
1036
+ options.waitForMerge = true;
1037
+ continue;
1038
+ }
1039
+ if (Object.prototype.hasOwnProperty.call(options, 'waitForMerge') && arg === '--no-wait-for-merge') {
1040
+ options.waitForMerge = false;
1041
+ continue;
1042
+ }
1003
1043
 
1004
1044
  throw new Error(`Unknown option: ${arg}`);
1005
1045
  }
@@ -1090,6 +1130,7 @@ function parseDoctorArgs(rawArgs) {
1090
1130
  dryRun: false,
1091
1131
  json: false,
1092
1132
  allowProtectedBaseWrite: false,
1133
+ waitForMerge: true,
1093
1134
  });
1094
1135
  }
1095
1136
 
@@ -1102,16 +1143,15 @@ function buildParentWorkspaceView(repoRoot) {
1102
1143
  const workspaceFileName = `${path.basename(repoRoot)}-branches.code-workspace`;
1103
1144
  const workspacePath = path.join(parentDir, workspaceFileName);
1104
1145
  const repoRelativePath = normalizeWorkspacePath(path.relative(parentDir, repoRoot) || '.');
1105
- const worktreesRelativePath = normalizeWorkspacePath(
1106
- path.join(repoRelativePath === '.' ? '' : repoRelativePath, '.omx', 'agent-worktrees'),
1107
- );
1108
1146
 
1109
1147
  return {
1110
1148
  workspacePath,
1111
1149
  payload: {
1112
1150
  folders: [
1113
1151
  { path: repoRelativePath },
1114
- { path: worktreesRelativePath },
1152
+ ...AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => ({
1153
+ path: normalizeWorkspacePath(path.join(repoRelativePath === '.' ? '' : repoRelativePath, relativeDir)),
1154
+ })),
1115
1155
  ],
1116
1156
  settings: {
1117
1157
  'scm.alwaysShowRepositories': true,
@@ -1195,6 +1235,40 @@ function assertProtectedMainWriteAllowed(options, commandName) {
1195
1235
  );
1196
1236
  }
1197
1237
 
1238
+ function runSetupBootstrapInternal(options) {
1239
+ const installPayload = runInstallInternal(options);
1240
+ installPayload.operations.push(
1241
+ ensureSetupProtectedBranches(installPayload.repoRoot, Boolean(options.dryRun)),
1242
+ );
1243
+
1244
+ let parentWorkspace = null;
1245
+ if (options.parentWorkspaceView) {
1246
+ installPayload.operations.push(
1247
+ ensureParentWorkspaceView(installPayload.repoRoot, Boolean(options.dryRun)),
1248
+ );
1249
+ if (!options.dryRun) {
1250
+ parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot);
1251
+ }
1252
+ }
1253
+
1254
+ const fixPayload = runFixInternal({
1255
+ target: installPayload.repoRoot,
1256
+ dryRun: options.dryRun,
1257
+ force: options.force,
1258
+ dropStaleLocks: true,
1259
+ skipAgents: options.skipAgents,
1260
+ skipPackageJson: options.skipPackageJson,
1261
+ skipGitignore: options.skipGitignore,
1262
+ allowProtectedBaseWrite: options.allowProtectedBaseWrite,
1263
+ });
1264
+
1265
+ return {
1266
+ installPayload,
1267
+ fixPayload,
1268
+ parentWorkspace,
1269
+ };
1270
+ }
1271
+
1198
1272
  function extractAgentBranchStartMetadata(output) {
1199
1273
  const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m);
1200
1274
  const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m);
@@ -1208,7 +1282,7 @@ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
1208
1282
  const resolvedTarget = path.resolve(targetPath);
1209
1283
  const relativeTarget = path.relative(repoRoot, resolvedTarget);
1210
1284
  if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
1211
- throw new Error(`doctor target must stay inside repo root when sandboxing: ${resolvedTarget}`);
1285
+ throw new Error(`sandbox target must stay inside repo root: ${resolvedTarget}`);
1212
1286
  }
1213
1287
  if (!relativeTarget || relativeTarget === '.') {
1214
1288
  return worktreePath;
@@ -1216,6 +1290,16 @@ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
1216
1290
  return path.join(worktreePath, relativeTarget);
1217
1291
  }
1218
1292
 
1293
+ function buildSandboxSetupArgs(options, sandboxTarget) {
1294
+ const args = ['setup', '--target', sandboxTarget, '--no-global-install', '--no-recursive'];
1295
+ if (options.force) args.push('--force');
1296
+ if (options.skipAgents) args.push('--skip-agents');
1297
+ if (options.skipPackageJson) args.push('--skip-package-json');
1298
+ if (options.skipGitignore) args.push('--no-gitignore');
1299
+ if (options.dryRun) args.push('--dry-run');
1300
+ return args;
1301
+ }
1302
+
1219
1303
  function buildSandboxDoctorArgs(options, sandboxTarget) {
1220
1304
  const args = ['doctor', '--target', sandboxTarget];
1221
1305
  if (options.dryRun) args.push('--dry-run');
@@ -1224,6 +1308,7 @@ function buildSandboxDoctorArgs(options, sandboxTarget) {
1224
1308
  if (options.skipPackageJson) args.push('--skip-package-json');
1225
1309
  if (options.skipGitignore) args.push('--no-gitignore');
1226
1310
  if (!options.dropStaleLocks) args.push('--keep-stale-locks');
1311
+ args.push(options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge');
1227
1312
  if (options.json) args.push('--json');
1228
1313
  return args;
1229
1314
  }
@@ -1259,7 +1344,7 @@ function ensureRepoBranch(repoRoot, branch) {
1259
1344
  return { ok: true, changed: true };
1260
1345
  }
1261
1346
 
1262
- function doctorSandboxBranchPrefix() {
1347
+ function protectedBaseSandboxBranchPrefix() {
1263
1348
  const now = new Date();
1264
1349
  const stamp = [
1265
1350
  now.getUTCFullYear(),
@@ -1273,15 +1358,15 @@ function doctorSandboxBranchPrefix() {
1273
1358
  return `agent/gx/${stamp}`;
1274
1359
  }
1275
1360
 
1276
- function doctorSandboxWorktreePath(repoRoot, branchName) {
1277
- return path.join(repoRoot, '.omx', 'agent-worktrees', branchName.replace(/\//g, '__'));
1361
+ function protectedBaseSandboxWorktreePath(repoRoot, branchName) {
1362
+ return path.join(repoRoot, defaultAgentWorktreeRelativeDir(), branchName.replace(/\//g, '__'));
1278
1363
  }
1279
1364
 
1280
1365
  function gitRefExists(repoRoot, ref) {
1281
1366
  return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0;
1282
1367
  }
1283
1368
 
1284
- function resolveDoctorSandboxStartRef(repoRoot, baseBranch) {
1369
+ function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) {
1285
1370
  run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 });
1286
1371
  if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) {
1287
1372
  return `origin/${baseBranch}`;
@@ -1289,18 +1374,21 @@ function resolveDoctorSandboxStartRef(repoRoot, baseBranch) {
1289
1374
  if (gitRefExists(repoRoot, `refs/heads/${baseBranch}`)) {
1290
1375
  return baseBranch;
1291
1376
  }
1292
- throw new Error(`Unable to find base ref for sandbox doctor: ${baseBranch}`);
1377
+ if (currentBranchName(repoRoot) === baseBranch) {
1378
+ return null;
1379
+ }
1380
+ throw new Error(`Unable to find base ref for sandbox bootstrap: ${baseBranch}`);
1293
1381
  }
1294
1382
 
1295
- function startDoctorSandboxFallback(blocked) {
1296
- const branchPrefix = doctorSandboxBranchPrefix();
1383
+ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
1384
+ const branchPrefix = protectedBaseSandboxBranchPrefix();
1297
1385
  let selectedBranch = '';
1298
1386
  let selectedWorktreePath = '';
1299
1387
 
1300
1388
  for (let attempt = 0; attempt < 30; attempt += 1) {
1301
- const suffix = attempt === 0 ? 'gx-doctor' : `${attempt + 1}-gx-doctor`;
1389
+ const suffix = attempt === 0 ? sandboxSuffix : `${attempt + 1}-${sandboxSuffix}`;
1302
1390
  const candidateBranch = `${branchPrefix}-${suffix}`;
1303
- const candidateWorktreePath = doctorSandboxWorktreePath(blocked.repoRoot, candidateBranch);
1391
+ const candidateWorktreePath = protectedBaseSandboxWorktreePath(blocked.repoRoot, candidateBranch);
1304
1392
  if (gitRefExists(blocked.repoRoot, `refs/heads/${candidateBranch}`)) {
1305
1393
  continue;
1306
1394
  }
@@ -1313,20 +1401,36 @@ function startDoctorSandboxFallback(blocked) {
1313
1401
  }
1314
1402
 
1315
1403
  if (!selectedBranch || !selectedWorktreePath) {
1316
- throw new Error('Unable to allocate unique sandbox branch/worktree for doctor');
1404
+ throw new Error('Unable to allocate unique sandbox branch/worktree');
1317
1405
  }
1318
1406
 
1319
1407
  fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true });
1320
- const startRef = resolveDoctorSandboxStartRef(blocked.repoRoot, blocked.branch);
1321
- const addResult = run(
1322
- 'git',
1323
- ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef],
1324
- );
1408
+ const startRef = resolveProtectedBaseSandboxStartRef(blocked.repoRoot, blocked.branch);
1409
+ const addArgs = startRef
1410
+ ? ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef]
1411
+ : ['-C', blocked.repoRoot, 'worktree', 'add', '--orphan', selectedWorktreePath];
1412
+ const addResult = run('git', addArgs);
1325
1413
  if (isSpawnFailure(addResult)) {
1326
1414
  throw addResult.error;
1327
1415
  }
1328
1416
  if (addResult.status !== 0) {
1329
- throw new Error((addResult.stderr || addResult.stdout || 'failed to create doctor sandbox').trim());
1417
+ throw new Error((addResult.stderr || addResult.stdout || 'failed to create sandbox').trim());
1418
+ }
1419
+
1420
+ if (!startRef) {
1421
+ const renameResult = run(
1422
+ 'git',
1423
+ ['-C', selectedWorktreePath, 'branch', '-m', selectedBranch],
1424
+ { timeout: 20_000 },
1425
+ );
1426
+ if (isSpawnFailure(renameResult)) {
1427
+ throw renameResult.error;
1428
+ }
1429
+ if (renameResult.status !== 0) {
1430
+ throw new Error(
1431
+ (renameResult.stderr || renameResult.stdout || 'failed to name orphan sandbox branch').trim(),
1432
+ );
1433
+ }
1330
1434
  }
1331
1435
 
1332
1436
  return {
@@ -1341,16 +1445,20 @@ function startDoctorSandboxFallback(blocked) {
1341
1445
  };
1342
1446
  }
1343
1447
 
1344
- function startDoctorSandbox(blocked) {
1448
+ function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) {
1449
+ if (sandboxSuffix === 'gx-doctor') {
1450
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
1451
+ }
1452
+
1345
1453
  const startScript = path.join(blocked.repoRoot, 'scripts', 'agent-branch-start.sh');
1346
1454
  if (!fs.existsSync(startScript)) {
1347
- return startDoctorSandboxFallback(blocked);
1455
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
1348
1456
  }
1349
1457
 
1350
1458
  const startResult = run('bash', [
1351
1459
  startScript,
1352
1460
  '--task',
1353
- `${SHORT_TOOL_NAME}-doctor`,
1461
+ taskName,
1354
1462
  '--agent',
1355
1463
  SHORT_TOOL_NAME,
1356
1464
  '--base',
@@ -1360,7 +1468,7 @@ function startDoctorSandbox(blocked) {
1360
1468
  throw startResult.error;
1361
1469
  }
1362
1470
  if (startResult.status !== 0) {
1363
- return startDoctorSandboxFallback(blocked);
1471
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
1364
1472
  }
1365
1473
 
1366
1474
  const metadata = extractAgentBranchStartMetadata(startResult.stdout);
@@ -1375,11 +1483,11 @@ function startDoctorSandbox(blocked) {
1375
1483
  if (!restoreResult.ok) {
1376
1484
  const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim();
1377
1485
  throw new Error(
1378
- `doctor sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
1486
+ `sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
1379
1487
  (detail ? `\n${detail}` : ''),
1380
1488
  );
1381
1489
  }
1382
- return startDoctorSandboxFallback(blocked);
1490
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
1383
1491
  }
1384
1492
 
1385
1493
  return {
@@ -1389,6 +1497,59 @@ function startDoctorSandbox(blocked) {
1389
1497
  };
1390
1498
  }
1391
1499
 
1500
+ function cleanupProtectedBaseSandbox(repoRoot, metadata) {
1501
+ const result = {
1502
+ worktree: 'skipped',
1503
+ branch: 'skipped',
1504
+ note: 'missing sandbox metadata',
1505
+ };
1506
+
1507
+ if (!metadata?.worktreePath || !metadata?.branch) {
1508
+ return result;
1509
+ }
1510
+
1511
+ if (fs.existsSync(metadata.worktreePath)) {
1512
+ const removeResult = run(
1513
+ 'git',
1514
+ ['-C', repoRoot, 'worktree', 'remove', '--force', metadata.worktreePath],
1515
+ { timeout: 30_000 },
1516
+ );
1517
+ if (isSpawnFailure(removeResult)) {
1518
+ throw removeResult.error;
1519
+ }
1520
+ if (removeResult.status !== 0) {
1521
+ throw new Error(
1522
+ (removeResult.stderr || removeResult.stdout || 'failed to remove sandbox worktree').trim(),
1523
+ );
1524
+ }
1525
+ result.worktree = 'removed';
1526
+ } else {
1527
+ result.worktree = 'missing';
1528
+ }
1529
+
1530
+ if (gitRefExists(repoRoot, `refs/heads/${metadata.branch}`)) {
1531
+ const branchDeleteResult = run(
1532
+ 'git',
1533
+ ['-C', repoRoot, 'branch', '-D', metadata.branch],
1534
+ { timeout: 20_000 },
1535
+ );
1536
+ if (isSpawnFailure(branchDeleteResult)) {
1537
+ throw branchDeleteResult.error;
1538
+ }
1539
+ if (branchDeleteResult.status !== 0) {
1540
+ throw new Error(
1541
+ (branchDeleteResult.stderr || branchDeleteResult.stdout || 'failed to delete sandbox branch').trim(),
1542
+ );
1543
+ }
1544
+ result.branch = 'deleted';
1545
+ } else {
1546
+ result.branch = 'missing';
1547
+ }
1548
+
1549
+ result.note = 'sandbox worktree pruned';
1550
+ return result;
1551
+ }
1552
+
1392
1553
  function parseGitPathList(output) {
1393
1554
  return String(output || '')
1394
1555
  .split('\n')
@@ -1427,6 +1588,59 @@ function collectDoctorDeletedPaths(worktreePath) {
1427
1588
  return Array.from(deleted);
1428
1589
  }
1429
1590
 
1591
+ function collectWorktreeDirtyPaths(worktreePath) {
1592
+ const dirty = new Set();
1593
+ const commands = [
1594
+ ['diff', '--name-only'],
1595
+ ['diff', '--cached', '--name-only'],
1596
+ ['ls-files', '--others', '--exclude-standard'],
1597
+ ];
1598
+ for (const gitArgs of commands) {
1599
+ const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
1600
+ for (const filePath of parseGitPathList(result.stdout)) {
1601
+ dirty.add(filePath);
1602
+ }
1603
+ }
1604
+ return Array.from(dirty);
1605
+ }
1606
+
1607
+ function collectDoctorForceAddPaths(worktreePath) {
1608
+ return TEMPLATE_FILES
1609
+ .map((entry) => toDestinationPath(entry))
1610
+ .filter((relativePath) => relativePath.startsWith('scripts/') || relativePath.startsWith('.githooks/'))
1611
+ .filter((relativePath) => fs.existsSync(path.join(worktreePath, relativePath)));
1612
+ }
1613
+
1614
+ function stripDoctorSandboxLocks(rawContent, branchName) {
1615
+ if (!rawContent || !branchName) {
1616
+ return rawContent;
1617
+ }
1618
+ try {
1619
+ const parsed = JSON.parse(rawContent);
1620
+ const locks = parsed && typeof parsed === 'object' && parsed.locks && typeof parsed.locks === 'object'
1621
+ ? parsed.locks
1622
+ : null;
1623
+ if (!locks) {
1624
+ return rawContent;
1625
+ }
1626
+ let changed = false;
1627
+ const filteredLocks = {};
1628
+ for (const [filePath, lockInfo] of Object.entries(locks)) {
1629
+ if (lockInfo && lockInfo.branch === branchName) {
1630
+ changed = true;
1631
+ continue;
1632
+ }
1633
+ filteredLocks[filePath] = lockInfo;
1634
+ }
1635
+ if (!changed) {
1636
+ return rawContent;
1637
+ }
1638
+ return `${JSON.stringify({ ...parsed, locks: filteredLocks }, null, 2)}\n`;
1639
+ } catch {
1640
+ return rawContent;
1641
+ }
1642
+ }
1643
+
1430
1644
  function claimDoctorChangedLocks(metadata) {
1431
1645
  const lockScript = path.join(metadata.worktreePath, 'scripts', 'agent-file-locks.py');
1432
1646
  if (!fs.existsSync(lockScript) || !metadata.branch) {
@@ -1438,7 +1652,10 @@ function claimDoctorChangedLocks(metadata) {
1438
1652
  };
1439
1653
  }
1440
1654
 
1441
- const changedPaths = collectDoctorChangedPaths(metadata.worktreePath);
1655
+ const changedPaths = Array.from(new Set([
1656
+ ...collectDoctorChangedPaths(metadata.worktreePath),
1657
+ ...collectDoctorForceAddPaths(metadata.worktreePath),
1658
+ ]));
1442
1659
  const deletedPaths = collectDoctorDeletedPaths(metadata.worktreePath);
1443
1660
  if (changedPaths.length > 0) {
1444
1661
  run('python3', [lockScript, 'claim', '--branch', metadata.branch, ...changedPaths], {
@@ -1470,7 +1687,19 @@ function autoCommitDoctorSandboxChanges(metadata) {
1470
1687
  }
1471
1688
 
1472
1689
  claimDoctorChangedLocks(metadata);
1473
- run('git', ['-C', metadata.worktreePath, 'add', '-A'], { timeout: 20_000 });
1690
+ run(
1691
+ 'git',
1692
+ ['-C', metadata.worktreePath, 'add', '-A', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
1693
+ { timeout: 20_000 },
1694
+ );
1695
+ const forceAddPaths = collectDoctorForceAddPaths(metadata.worktreePath);
1696
+ if (forceAddPaths.length > 0) {
1697
+ run(
1698
+ 'git',
1699
+ ['-C', metadata.worktreePath, 'add', '-f', '--', ...forceAddPaths],
1700
+ { timeout: 20_000 },
1701
+ );
1702
+ }
1474
1703
  const staged = run(
1475
1704
  'git',
1476
1705
  ['-C', metadata.worktreePath, 'diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
@@ -1535,7 +1764,7 @@ function doctorFinishFlowIsPending(output) {
1535
1764
  );
1536
1765
  }
1537
1766
 
1538
- function finishDoctorSandboxBranch(blocked, metadata) {
1767
+ function finishDoctorSandboxBranch(blocked, metadata, options = {}) {
1539
1768
  const finishScript = path.join(metadata.worktreePath, 'scripts', 'agent-branch-finish.sh');
1540
1769
  if (!fs.existsSync(finishScript)) {
1541
1770
  return {
@@ -1577,10 +1806,11 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1577
1806
  const waitTimeoutSeconds =
1578
1807
  Number.isFinite(rawWaitTimeoutSeconds) && rawWaitTimeoutSeconds >= 30 ? rawWaitTimeoutSeconds : 1800;
1579
1808
  const finishTimeoutMs = Math.max(180_000, (waitTimeoutSeconds + 60) * 1000);
1809
+ const waitForMergeArg = options.waitForMerge === false ? '--no-wait-for-merge' : '--wait-for-merge';
1580
1810
 
1581
1811
  const finishResult = run(
1582
1812
  'bash',
1583
- [finishScript, '--branch', metadata.branch, '--base', blocked.branch, '--via-pr', '--wait-for-merge'],
1813
+ [finishScript, '--branch', metadata.branch, '--base', blocked.branch, '--via-pr', waitForMergeArg],
1584
1814
  { cwd: metadata.worktreePath, timeout: finishTimeoutMs },
1585
1815
  );
1586
1816
  if (isSpawnFailure(finishResult)) {
@@ -1619,35 +1849,186 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1619
1849
  };
1620
1850
  }
1621
1851
 
1622
- function syncProtectedBaseDoctorRepairs(options, blocked) {
1623
- const fixPayload = runFixInternal({
1624
- ...options,
1625
- target: blocked.repoRoot,
1626
- allowProtectedBaseWrite: true,
1627
- });
1628
- const changedOperations = fixPayload.operations.filter(
1629
- (operation) => !['unchanged', 'skipped'].includes(operation.status),
1852
+ function mergeDoctorSandboxRepairsBackToProtectedBase(options, blocked, metadata, autoCommitResult, finishResult) {
1853
+ if (options.dryRun) {
1854
+ return {
1855
+ status: autoCommitResult.status === 'committed' ? 'would-merge' : 'skipped',
1856
+ note: autoCommitResult.status === 'committed'
1857
+ ? 'dry run: would fast-forward tracked doctor repairs into the protected base workspace'
1858
+ : 'dry run skips tracked repair merge',
1859
+ };
1860
+ }
1861
+
1862
+ if (autoCommitResult.status !== 'committed') {
1863
+ return {
1864
+ status: autoCommitResult.status === 'no-changes' ? 'unchanged' : 'skipped',
1865
+ note: autoCommitResult.status === 'no-changes'
1866
+ ? 'no tracked doctor repairs needed in the protected base workspace'
1867
+ : 'tracked doctor repair merge skipped',
1868
+ };
1869
+ }
1870
+
1871
+ if (finishResult.status !== 'skipped') {
1872
+ return {
1873
+ status: 'skipped',
1874
+ note: finishResult.status === 'failed'
1875
+ ? 'tracked doctor repairs remain in the sandbox after finish failure'
1876
+ : 'tracked doctor repairs are being delivered through the sandbox finish flow',
1877
+ };
1878
+ }
1879
+
1880
+ const allowedPaths = new Set([
1881
+ ...(autoCommitResult.stagedFiles || []),
1882
+ ...OMX_SCAFFOLD_DIRECTORIES,
1883
+ ...Array.from(OMX_SCAFFOLD_FILES.keys()),
1884
+ ...TEMPLATE_FILES.map((entry) => toDestinationPath(entry)),
1885
+ 'bin',
1886
+ 'package.json',
1887
+ '.gitignore',
1888
+ 'AGENTS.md',
1889
+ ]);
1890
+ const dirtyPaths = collectWorktreeDirtyPaths(blocked.repoRoot);
1891
+ let stashRef = '';
1892
+ if (dirtyPaths.length > 0) {
1893
+ const unexpectedPaths = dirtyPaths.filter((filePath) => {
1894
+ if (allowedPaths.has(filePath)) {
1895
+ return false;
1896
+ }
1897
+ return !AGENT_WORKTREE_RELATIVE_DIRS.some(
1898
+ (relativeDir) => filePath === relativeDir || filePath.startsWith(`${relativeDir}/`),
1899
+ );
1900
+ });
1901
+ if (unexpectedPaths.length > 0) {
1902
+ return {
1903
+ status: 'failed',
1904
+ note: `protected branch workspace has unrelated local changes: ${unexpectedPaths.join(', ')}`,
1905
+ };
1906
+ }
1907
+ const stashMessage = `guardex-doctor-merge-${Date.now()}`;
1908
+ const stashResult = run(
1909
+ 'git',
1910
+ ['-C', blocked.repoRoot, 'stash', 'push', '--all', '--message', stashMessage],
1911
+ { timeout: 30_000 },
1912
+ );
1913
+ if (isSpawnFailure(stashResult)) {
1914
+ return {
1915
+ status: 'failed',
1916
+ note: 'could not stash protected branch doctor drift before merge',
1917
+ stdout: stashResult.stdout || '',
1918
+ stderr: stashResult.stderr || '',
1919
+ };
1920
+ }
1921
+ if (stashResult.status !== 0) {
1922
+ return {
1923
+ status: 'failed',
1924
+ note: 'stashing protected branch doctor drift failed',
1925
+ stdout: stashResult.stdout || '',
1926
+ stderr: stashResult.stderr || '',
1927
+ };
1928
+ }
1929
+
1930
+ const stashLookup = run(
1931
+ 'git',
1932
+ ['-C', blocked.repoRoot, 'stash', 'list'],
1933
+ { timeout: 20_000 },
1934
+ );
1935
+ stashRef = String(stashLookup.stdout || '')
1936
+ .split('\n')
1937
+ .find((line) => line.includes(stashMessage))
1938
+ ?.split(':')[0]
1939
+ ?.trim() || '';
1940
+ }
1941
+
1942
+ const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
1943
+ if (!restoreResult.ok) {
1944
+ if (stashRef) {
1945
+ run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
1946
+ }
1947
+ return {
1948
+ status: 'failed',
1949
+ note: `could not restore protected branch '${blocked.branch}' before applying sandbox repairs`,
1950
+ stdout: restoreResult.stdout || '',
1951
+ stderr: restoreResult.stderr || '',
1952
+ };
1953
+ }
1954
+
1955
+ const mergeResult = run(
1956
+ 'git',
1957
+ ['-C', blocked.repoRoot, 'merge', '--ff-only', metadata.branch],
1958
+ { timeout: 30_000 },
1630
1959
  );
1631
- const hookChanged = fixPayload.hookResult?.status && fixPayload.hookResult.status !== 'unchanged';
1632
- const changedCount = changedOperations.length + (hookChanged ? 1 : 0);
1960
+ if (isSpawnFailure(mergeResult)) {
1961
+ if (stashRef) {
1962
+ run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
1963
+ }
1964
+ return {
1965
+ status: 'failed',
1966
+ note: 'tracked doctor repair merge errored',
1967
+ stdout: mergeResult.stdout || '',
1968
+ stderr: mergeResult.stderr || '',
1969
+ };
1970
+ }
1971
+ if (mergeResult.status !== 0) {
1972
+ if (stashRef) {
1973
+ run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
1974
+ }
1975
+ return {
1976
+ status: 'failed',
1977
+ note: 'tracked doctor repair merge failed',
1978
+ stdout: mergeResult.stdout || '',
1979
+ stderr: mergeResult.stderr || '',
1980
+ };
1981
+ }
1633
1982
 
1634
- if (changedCount === 0) {
1983
+ let cleanupResult;
1984
+ try {
1985
+ cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
1986
+ } catch (error) {
1635
1987
  return {
1636
- status: 'unchanged',
1637
- note: 'managed repair files already aligned in protected branch workspace',
1638
- fixPayload,
1988
+ status: 'failed',
1989
+ note: `tracked doctor repair merge succeeded but sandbox cleanup failed: ${error.message}`,
1990
+ stdout: mergeResult.stdout || '',
1991
+ stderr: mergeResult.stderr || '',
1992
+ };
1993
+ }
1994
+
1995
+ let hookRefreshResult;
1996
+ try {
1997
+ hookRefreshResult = configureHooks(blocked.repoRoot, false);
1998
+ } catch (error) {
1999
+ return {
2000
+ status: 'failed',
2001
+ note: `tracked doctor repair merge succeeded but local hook refresh failed: ${error.message}`,
2002
+ stdout: mergeResult.stdout || '',
2003
+ stderr: mergeResult.stderr || '',
1639
2004
  };
1640
2005
  }
1641
2006
 
2007
+ if (stashRef) {
2008
+ run('git', ['-C', blocked.repoRoot, 'stash', 'drop', stashRef], { timeout: 20_000 });
2009
+ }
2010
+
1642
2011
  return {
1643
- status: options.dryRun ? 'would-sync' : 'synced',
1644
- note: `${options.dryRun ? 'would sync' : 'synced'} ${changedCount} managed repair item(s)`,
1645
- fixPayload,
2012
+ status: 'merged',
2013
+ note: 'fast-forwarded tracked doctor repairs into the protected base workspace',
2014
+ stdout: mergeResult.stdout || '',
2015
+ stderr: mergeResult.stderr || '',
2016
+ cleanup: cleanupResult,
2017
+ hookRefresh: hookRefreshResult,
1646
2018
  };
1647
2019
  }
1648
2020
 
2021
+ function syncDoctorLocalSupportFiles(repoRoot, dryRun) {
2022
+ return TEMPLATE_FILES
2023
+ .filter((entry) => entry.startsWith('codex/') || entry.startsWith('claude/'))
2024
+ .map((entry) => ensureTemplateFilePresent(repoRoot, entry, dryRun));
2025
+ }
2026
+
1649
2027
  function runDoctorInSandbox(options, blocked) {
1650
- const startResult = startDoctorSandbox(blocked);
2028
+ const startResult = startProtectedBaseSandbox(blocked, {
2029
+ taskName: `${SHORT_TOOL_NAME}-doctor`,
2030
+ sandboxSuffix: 'gx-doctor',
2031
+ });
1651
2032
  const metadata = startResult.metadata;
1652
2033
 
1653
2034
  const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
@@ -1677,6 +2058,7 @@ function runDoctorInSandbox(options, blocked) {
1677
2058
  status: 'skipped',
1678
2059
  note: 'sandbox doctor did not complete successfully',
1679
2060
  };
2061
+ let sandboxLockContent = null;
1680
2062
  let postSandboxAutoFinishSummary = {
1681
2063
  enabled: false,
1682
2064
  attempted: 0,
@@ -1690,7 +2072,6 @@ function runDoctorInSandbox(options, blocked) {
1690
2072
  note: 'sandbox doctor did not complete successfully',
1691
2073
  };
1692
2074
  if (nestedResult.status === 0) {
1693
- protectedBaseRepairSyncResult = syncProtectedBaseDoctorRepairs(options, blocked);
1694
2075
  const omxScaffoldOps = ensureOmxScaffold(blocked.repoRoot, Boolean(options.dryRun));
1695
2076
  const changedOmxPaths = omxScaffoldOps.filter((operation) => operation.status !== 'unchanged');
1696
2077
  if (changedOmxPaths.length === 0) {
@@ -1710,7 +2091,7 @@ function runDoctorInSandbox(options, blocked) {
1710
2091
  if (!options.dryRun) {
1711
2092
  autoCommitResult = autoCommitDoctorSandboxChanges(metadata);
1712
2093
  if (autoCommitResult.status === 'committed') {
1713
- finishResult = finishDoctorSandboxBranch(blocked, metadata);
2094
+ finishResult = finishDoctorSandboxBranch(blocked, metadata, options);
1714
2095
  } else if (autoCommitResult.status === 'no-changes') {
1715
2096
  finishResult = {
1716
2097
  status: 'skipped',
@@ -1746,7 +2127,11 @@ function runDoctorInSandbox(options, blocked) {
1746
2127
  note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
1747
2128
  };
1748
2129
  } else {
1749
- const sourceContent = fs.readFileSync(sandboxLockPath, 'utf8');
2130
+ const sourceContent = stripDoctorSandboxLocks(
2131
+ fs.readFileSync(sandboxLockPath, 'utf8'),
2132
+ metadata.branch,
2133
+ );
2134
+ sandboxLockContent = sourceContent;
1750
2135
  const destinationContent = fs.readFileSync(baseLockPath, 'utf8');
1751
2136
  if (sourceContent === destinationContent) {
1752
2137
  lockSyncResult = {
@@ -1763,6 +2148,62 @@ function runDoctorInSandbox(options, blocked) {
1763
2148
  }
1764
2149
  }
1765
2150
 
2151
+ protectedBaseRepairSyncResult = mergeDoctorSandboxRepairsBackToProtectedBase(
2152
+ options,
2153
+ blocked,
2154
+ metadata,
2155
+ autoCommitResult,
2156
+ finishResult,
2157
+ );
2158
+
2159
+ syncDoctorLocalSupportFiles(blocked.repoRoot, Boolean(options.dryRun));
2160
+
2161
+ const postMergeOmxScaffoldOps = ensureOmxScaffold(blocked.repoRoot, Boolean(options.dryRun));
2162
+ const postMergeChangedOmxPaths = postMergeOmxScaffoldOps.filter((operation) => operation.status !== 'unchanged');
2163
+ if (postMergeChangedOmxPaths.length === 0) {
2164
+ omxScaffoldSyncResult = {
2165
+ status: 'unchanged',
2166
+ note: '.omx scaffold already in sync',
2167
+ operations: postMergeOmxScaffoldOps,
2168
+ };
2169
+ } else {
2170
+ omxScaffoldSyncResult = {
2171
+ status: options.dryRun ? 'would-sync' : 'synced',
2172
+ note: `${options.dryRun ? 'would sync' : 'synced'} ${postMergeChangedOmxPaths.length} .omx path(s)`,
2173
+ operations: postMergeOmxScaffoldOps,
2174
+ };
2175
+ }
2176
+
2177
+ const postMergeBaseLockPath = path.join(blocked.repoRoot, LOCK_FILE_RELATIVE);
2178
+ if (sandboxLockContent === null) {
2179
+ lockSyncResult = {
2180
+ status: 'skipped',
2181
+ note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
2182
+ };
2183
+ } else if (!fs.existsSync(postMergeBaseLockPath)) {
2184
+ fs.mkdirSync(path.dirname(postMergeBaseLockPath), { recursive: true });
2185
+ fs.writeFileSync(postMergeBaseLockPath, sandboxLockContent, 'utf8');
2186
+ lockSyncResult = {
2187
+ status: 'synced',
2188
+ note: `${LOCK_FILE_RELATIVE} recreated from sandbox`,
2189
+ };
2190
+ } else {
2191
+ const destinationContent = fs.readFileSync(postMergeBaseLockPath, 'utf8');
2192
+ if (sandboxLockContent === destinationContent) {
2193
+ lockSyncResult = {
2194
+ status: 'unchanged',
2195
+ note: `${LOCK_FILE_RELATIVE} already in sync`,
2196
+ };
2197
+ } else {
2198
+ fs.mkdirSync(path.dirname(postMergeBaseLockPath), { recursive: true });
2199
+ fs.writeFileSync(postMergeBaseLockPath, sandboxLockContent, 'utf8');
2200
+ lockSyncResult = {
2201
+ status: 'synced',
2202
+ note: `${LOCK_FILE_RELATIVE} synced from sandbox`,
2203
+ };
2204
+ }
2205
+ }
2206
+
1766
2207
  postSandboxAutoFinishSummary = autoFinishReadyAgentBranches(blocked.repoRoot, {
1767
2208
  baseBranch: blocked.branch,
1768
2209
  dryRun: options.dryRun,
@@ -1820,14 +2261,28 @@ function runDoctorInSandbox(options, blocked) {
1820
2261
  console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit skipped: ${autoCommitResult.note}.`);
1821
2262
  }
1822
2263
 
1823
- if (protectedBaseRepairSyncResult.status === 'synced') {
1824
- console.log(`[${TOOL_NAME}] Synced repaired managed files back to protected branch workspace.`);
2264
+ if (protectedBaseRepairSyncResult.status === 'merged') {
2265
+ console.log(`[${TOOL_NAME}] Fast-forwarded tracked doctor repairs into the protected branch workspace.`);
1825
2266
  } else if (protectedBaseRepairSyncResult.status === 'unchanged') {
1826
- console.log(`[${TOOL_NAME}] Protected branch workspace already had the repaired managed files.`);
1827
- } else if (protectedBaseRepairSyncResult.status === 'would-sync') {
1828
- console.log(`[${TOOL_NAME}] Dry run: would sync repaired managed files back to protected branch workspace.`);
2267
+ console.log(`[${TOOL_NAME}] Protected branch workspace already had the tracked doctor repairs.`);
2268
+ } else if (protectedBaseRepairSyncResult.status === 'would-merge') {
2269
+ console.log(`[${TOOL_NAME}] Dry run: would fast-forward tracked doctor repairs into the protected branch workspace.`);
2270
+ } else if (protectedBaseRepairSyncResult.status === 'failed') {
2271
+ console.log(`[${TOOL_NAME}] Protected branch tracked repair merge failed: ${protectedBaseRepairSyncResult.note}.`);
2272
+ if (protectedBaseRepairSyncResult.stdout) process.stdout.write(protectedBaseRepairSyncResult.stdout);
2273
+ if (protectedBaseRepairSyncResult.stderr) process.stderr.write(protectedBaseRepairSyncResult.stderr);
1829
2274
  } else {
1830
- console.log(`[${TOOL_NAME}] Protected branch workspace repair sync skipped: ${protectedBaseRepairSyncResult.note}.`);
2275
+ console.log(`[${TOOL_NAME}] Protected branch tracked repair merge skipped: ${protectedBaseRepairSyncResult.note}.`);
2276
+ }
2277
+
2278
+ if (lockSyncResult.status === 'synced') {
2279
+ console.log(
2280
+ `[${TOOL_NAME}] Synced repaired lock registry back to protected branch workspace (${LOCK_FILE_RELATIVE}).`,
2281
+ );
2282
+ } else if (lockSyncResult.status === 'unchanged') {
2283
+ console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
2284
+ } else {
2285
+ console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${lockSyncResult.note}.`);
1831
2286
  }
1832
2287
 
1833
2288
  if (finishResult.status === 'completed') {
@@ -1845,22 +2300,13 @@ function runDoctorInSandbox(options, blocked) {
1845
2300
  if (finishResult.stderr) process.stderr.write(finishResult.stderr);
1846
2301
  } else if (finishResult.status === 'failed') {
1847
2302
  console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
2303
+ console.log(`[guardex] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
1848
2304
  if (finishResult.stdout) process.stdout.write(finishResult.stdout);
1849
2305
  if (finishResult.stderr) process.stderr.write(finishResult.stderr);
1850
2306
  } else {
1851
2307
  console.log(`[${TOOL_NAME}] Auto-finish skipped: ${finishResult.note}.`);
1852
2308
  }
1853
2309
 
1854
- if (lockSyncResult.status === 'synced') {
1855
- console.log(
1856
- `[${TOOL_NAME}] Synced repaired lock registry back to protected branch workspace (${LOCK_FILE_RELATIVE}).`,
1857
- );
1858
- } else if (lockSyncResult.status === 'unchanged') {
1859
- console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
1860
- } else {
1861
- console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${lockSyncResult.note}.`);
1862
- }
1863
-
1864
2310
  if (postSandboxAutoFinishSummary.enabled) {
1865
2311
  console.log(
1866
2312
  `[${TOOL_NAME}] Auto-finish sweep (base=${blocked.branch}): attempted=${postSandboxAutoFinishSummary.attempted}, completed=${postSandboxAutoFinishSummary.completed}, skipped=${postSandboxAutoFinishSummary.skipped}, failed=${postSandboxAutoFinishSummary.failed}`,
@@ -1895,12 +2341,89 @@ function runDoctorInSandbox(options, blocked) {
1895
2341
  ) {
1896
2342
  exitCode = 1;
1897
2343
  }
2344
+ if (exitCode === 0 && protectedBaseRepairSyncResult.status === 'failed') {
2345
+ exitCode = 1;
2346
+ }
1898
2347
  process.exitCode = exitCode;
1899
2348
  return;
1900
2349
  }
1901
2350
  process.exitCode = 1;
1902
2351
  }
1903
2352
 
2353
+ function runSetupInSandbox(options, blocked, repoLabel = '') {
2354
+ const startResult = startProtectedBaseSandbox(blocked, {
2355
+ taskName: `${SHORT_TOOL_NAME}-setup`,
2356
+ sandboxSuffix: 'gx-setup',
2357
+ });
2358
+ const metadata = startResult.metadata;
2359
+
2360
+ if (startResult.stdout) process.stdout.write(startResult.stdout);
2361
+ if (startResult.stderr) process.stderr.write(startResult.stderr);
2362
+ console.log(
2363
+ `[${TOOL_NAME}] setup blocked on protected branch '${blocked.branch}' in an initialized repo; ` +
2364
+ 'refreshing through a sandbox worktree and syncing managed bootstrap files back locally.',
2365
+ );
2366
+
2367
+ const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
2368
+ const nestedResult = run(
2369
+ process.execPath,
2370
+ [__filename, ...buildSandboxSetupArgs(options, sandboxTarget)],
2371
+ { cwd: metadata.worktreePath },
2372
+ );
2373
+ if (isSpawnFailure(nestedResult)) {
2374
+ throw nestedResult.error;
2375
+ }
2376
+ if (nestedResult.status !== 0) {
2377
+ if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
2378
+ if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
2379
+ throw new Error(
2380
+ `sandboxed setup failed for protected branch '${blocked.branch}'. ` +
2381
+ `Inspect sandbox at ${metadata.worktreePath}`,
2382
+ );
2383
+ }
2384
+
2385
+ const syncOptions = {
2386
+ ...options,
2387
+ target: blocked.repoRoot,
2388
+ recursive: false,
2389
+ allowProtectedBaseWrite: true,
2390
+ };
2391
+ const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(syncOptions);
2392
+ printOperations(`Setup/install${repoLabel}`, installPayload, syncOptions.dryRun);
2393
+ printOperations(`Setup/fix${repoLabel}`, fixPayload, syncOptions.dryRun);
2394
+ if (!syncOptions.dryRun && parentWorkspace) {
2395
+ console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
2396
+ }
2397
+
2398
+ const scanResult = runScanInternal({ target: blocked.repoRoot, json: false });
2399
+ const currentBaseBranch = currentBranchName(scanResult.repoRoot);
2400
+ const autoFinishSummary = autoFinishReadyAgentBranches(scanResult.repoRoot, {
2401
+ baseBranch: currentBaseBranch,
2402
+ dryRun: syncOptions.dryRun,
2403
+ });
2404
+ printScanResult(scanResult, false);
2405
+ if (autoFinishSummary.enabled) {
2406
+ console.log(
2407
+ `[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
2408
+ );
2409
+ for (const detail of autoFinishSummary.details) {
2410
+ console.log(`[${TOOL_NAME}] ${detail}`);
2411
+ }
2412
+ } else if (autoFinishSummary.details.length > 0) {
2413
+ console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
2414
+ }
2415
+
2416
+ const cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
2417
+ console.log(
2418
+ `[${TOOL_NAME}] Protected-base setup sandbox cleanup: ${cleanupResult.note} ` +
2419
+ `(worktree=${cleanupResult.worktree}, branch=${cleanupResult.branch}).`,
2420
+ );
2421
+
2422
+ return {
2423
+ scanResult,
2424
+ };
2425
+ }
2426
+
1904
2427
  function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) {
1905
2428
  const remaining = [];
1906
2429
  let target = defaultTarget;
@@ -2082,6 +2605,19 @@ function inferGithubRepoFromOrigin(repoRoot) {
2082
2605
  return `github.com/${slug}`;
2083
2606
  }
2084
2607
 
2608
+ function inferGithubRepoSlug(rawValue) {
2609
+ const raw = String(rawValue || '').trim();
2610
+ if (!raw) return '';
2611
+ const match = raw.match(/github\.com[:/](.+?)(?:\.git)?$/i);
2612
+ if (!match) return '';
2613
+ const slug = String(match[1] || '')
2614
+ .replace(/^\/+/, '')
2615
+ .replace(/^github\.com\//i, '')
2616
+ .trim();
2617
+ if (!slug || !slug.includes('/')) return '';
2618
+ return slug;
2619
+ }
2620
+
2085
2621
  function resolveScorecardRepo(repoRoot, explicitRepo) {
2086
2622
  if (explicitRepo) {
2087
2623
  return explicitRepo.trim();
@@ -2565,6 +3101,66 @@ function currentBranchName(repoRoot) {
2565
3101
  return branch;
2566
3102
  }
2567
3103
 
3104
+ function repoHasHeadCommit(repoRoot) {
3105
+ return gitRun(repoRoot, ['rev-parse', '--verify', 'HEAD'], { allowFailure: true }).status === 0;
3106
+ }
3107
+
3108
+ function readBranchDisplayName(repoRoot) {
3109
+ const symbolic = gitRun(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { allowFailure: true });
3110
+ if (symbolic.status === 0) {
3111
+ const branch = String(symbolic.stdout || '').trim();
3112
+ if (!branch) {
3113
+ return '(unknown)';
3114
+ }
3115
+ return repoHasHeadCommit(repoRoot) ? branch : `${branch} (unborn; no commits yet)`;
3116
+ }
3117
+
3118
+ const detached = gitRun(repoRoot, ['rev-parse', '--short', 'HEAD'], { allowFailure: true });
3119
+ if (detached.status === 0) {
3120
+ return `(detached at ${String(detached.stdout || '').trim()})`;
3121
+ }
3122
+ return '(unknown)';
3123
+ }
3124
+
3125
+ function repoHasOriginRemote(repoRoot) {
3126
+ return gitRun(repoRoot, ['remote', 'get-url', 'origin'], { allowFailure: true }).status === 0;
3127
+ }
3128
+
3129
+ function detectComposeHintFiles(repoRoot) {
3130
+ return COMPOSE_HINT_FILES.filter((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
3131
+ }
3132
+
3133
+ function printSetupRepoHints(repoRoot, baseBranch, repoLabel = '') {
3134
+ const branchDisplay = readBranchDisplayName(repoRoot);
3135
+ const hasHeadCommit = repoHasHeadCommit(repoRoot);
3136
+ const hasOrigin = repoHasOriginRemote(repoRoot);
3137
+ const composeFiles = detectComposeHintFiles(repoRoot);
3138
+ if (hasHeadCommit && hasOrigin && composeFiles.length === 0) {
3139
+ return;
3140
+ }
3141
+
3142
+ const label = repoLabel ? ` ${repoLabel}` : '';
3143
+ if (!hasHeadCommit) {
3144
+ console.log(`[${TOOL_NAME}] Fresh repo onboarding${label}: current branch is ${branchDisplay}.`);
3145
+ console.log(`[${TOOL_NAME}] Bootstrap commit${label}: git add . && git commit -m "bootstrap gitguardex"`);
3146
+ console.log(
3147
+ `[${TOOL_NAME}] First agent flow${label}: ` +
3148
+ `bash scripts/agent-branch-start.sh "<task>" "codex" -> ` +
3149
+ `python3 scripts/agent-file-locks.py claim --branch "$(git branch --show-current)" <file...> -> ` +
3150
+ `bash scripts/agent-branch-finish.sh --branch "$(git branch --show-current)" --base ${baseBranch} --via-pr --wait-for-merge`,
3151
+ );
3152
+ }
3153
+ if (!hasOrigin) {
3154
+ console.log(`[${TOOL_NAME}] No origin remote${label}: finish and auto-merge flows stay local until you add one.`);
3155
+ }
3156
+ if (composeFiles.length > 0) {
3157
+ console.log(
3158
+ `[${TOOL_NAME}] Docker Compose helper${label}: detected ${composeFiles.join(', ')}. ` +
3159
+ `Set GUARDEX_DOCKER_SERVICE and run 'bash scripts/guardex-docker-loader.sh -- <command...>'.`,
3160
+ );
3161
+ }
3162
+ }
3163
+
2568
3164
  function workingTreeIsDirty(repoRoot) {
2569
3165
  const result = gitRun(repoRoot, ['status', '--porcelain'], { allowFailure: true });
2570
3166
  if (result.status !== 0) {
@@ -3376,6 +3972,17 @@ function parseVersionString(version) {
3376
3972
  ];
3377
3973
  }
3378
3974
 
3975
+ function compareParsedVersions(left, right) {
3976
+ if (!left || !right) return 0;
3977
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
3978
+ const leftValue = left[index] || 0;
3979
+ const rightValue = right[index] || 0;
3980
+ if (leftValue > rightValue) return 1;
3981
+ if (leftValue < rightValue) return -1;
3982
+ }
3983
+ return 0;
3984
+ }
3985
+
3379
3986
  function isNewerVersion(latest, current) {
3380
3987
  const latestParts = parseVersionString(latest);
3381
3988
  const currentParts = parseVersionString(current);
@@ -3384,11 +3991,7 @@ function isNewerVersion(latest, current) {
3384
3991
  return String(latest || '').trim() !== String(current || '').trim();
3385
3992
  }
3386
3993
 
3387
- for (let index = 0; index < latestParts.length; index += 1) {
3388
- if (latestParts[index] > currentParts[index]) return true;
3389
- if (latestParts[index] < currentParts[index]) return false;
3390
- }
3391
- return false;
3994
+ return compareParsedVersions(latestParts, currentParts) > 0;
3392
3995
  }
3393
3996
 
3394
3997
  function parseNpmVersionOutput(stdout) {
@@ -4078,8 +4681,7 @@ function runFixInternal(options) {
4078
4681
  function runScanInternal(options) {
4079
4682
  const repoRoot = resolveRepoRoot(options.target);
4080
4683
  const guardexToggle = resolveGuardexRepoToggle(repoRoot);
4081
- const currentBranchResult = gitRun(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFailure: true });
4082
- const branch = currentBranchResult.status === 0 ? currentBranchResult.stdout.trim() : '(unknown)';
4684
+ const branch = readBranchDisplayName(repoRoot);
4083
4685
  if (!guardexToggle.enabled) {
4084
4686
  return {
4085
4687
  repoRoot,
@@ -4543,6 +5145,8 @@ function doctor(rawArgs) {
4543
5145
  ...(options.skipPackageJson ? ['--skip-package-json'] : []),
4544
5146
  ...(options.skipGitignore ? ['--no-gitignore'] : []),
4545
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',
4546
5150
  ...(options.json ? ['--json'] : []),
4547
5151
  ...(options.allowProtectedBaseWrite ? ['--allow-protected-base-write'] : []),
4548
5152
  ],
@@ -4662,7 +5266,7 @@ function doctor(rawArgs) {
4662
5266
  return;
4663
5267
  }
4664
5268
 
4665
- printOperations('Doctor/fix', fixPayload, singleRepoOptions.dryRun);
5269
+ printOperations('Doctor/fix', fixPayload, options.dryRun);
4666
5270
  printScanResult(scanResult, false);
4667
5271
  if (scanResult.guardexEnabled === false) {
4668
5272
  console.log(`[${TOOL_NAME}] Repo-local Guardex enforcement is intentionally disabled.`);
@@ -5171,31 +5775,24 @@ function setup(rawArgs) {
5171
5775
  console.log(`[${TOOL_NAME}] ── Setup target: ${repoPath} ──`);
5172
5776
  }
5173
5777
 
5174
- assertProtectedMainWriteAllowed(perRepoOptions, 'setup');
5175
- const installPayload = runInstallInternal(perRepoOptions);
5176
- installPayload.operations.push(ensureSetupProtectedBranches(installPayload.repoRoot, Boolean(perRepoOptions.dryRun)));
5177
- if (perRepoOptions.parentWorkspaceView) {
5178
- installPayload.operations.push(ensureParentWorkspaceView(installPayload.repoRoot, Boolean(perRepoOptions.dryRun)));
5778
+ const blocked = protectedBaseWriteBlock(perRepoOptions);
5779
+ if (blocked) {
5780
+ const sandboxResult = runSetupInSandbox(perRepoOptions, blocked, repoLabel);
5781
+ aggregateErrors += sandboxResult.scanResult.errors;
5782
+ aggregateWarnings += sandboxResult.scanResult.warnings;
5783
+ lastScanResult = sandboxResult.scanResult;
5784
+ continue;
5179
5785
  }
5180
- printOperations(`Setup/install${repoLabel}`, installPayload, perRepoOptions.dryRun);
5181
5786
 
5182
- const fixPayload = runFixInternal({
5183
- target: repoPath,
5184
- dryRun: perRepoOptions.dryRun,
5185
- force: perRepoOptions.force,
5186
- dropStaleLocks: true,
5187
- skipAgents: perRepoOptions.skipAgents,
5188
- skipPackageJson: perRepoOptions.skipPackageJson,
5189
- skipGitignore: perRepoOptions.skipGitignore,
5190
- });
5787
+ const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(perRepoOptions);
5788
+ printOperations(`Setup/install${repoLabel}`, installPayload, perRepoOptions.dryRun);
5191
5789
  printOperations(`Setup/fix${repoLabel}`, fixPayload, perRepoOptions.dryRun);
5192
5790
 
5193
5791
  if (perRepoOptions.dryRun) {
5194
5792
  continue;
5195
5793
  }
5196
5794
 
5197
- if (perRepoOptions.parentWorkspaceView) {
5198
- const parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot);
5795
+ if (parentWorkspace) {
5199
5796
  console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
5200
5797
  }
5201
5798
 
@@ -5216,6 +5813,7 @@ function setup(rawArgs) {
5216
5813
  } else if (autoFinishSummary.details.length > 0) {
5217
5814
  console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
5218
5815
  }
5816
+ printSetupRepoHints(scanResult.repoRoot, currentBaseBranch, repoLabel);
5219
5817
 
5220
5818
  aggregateErrors += scanResult.errors;
5221
5819
  aggregateWarnings += scanResult.warnings;
@@ -5275,6 +5873,156 @@ function ensureCleanWorkingTree(repoRoot) {
5275
5873
  }
5276
5874
  }
5277
5875
 
5876
+ function readReleaseRepoPackageJson(repoRoot) {
5877
+ const manifestPath = path.join(repoRoot, 'package.json');
5878
+ if (!fs.existsSync(manifestPath)) {
5879
+ throw new Error(`Release blocked: package.json missing in ${repoRoot}`);
5880
+ }
5881
+
5882
+ try {
5883
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
5884
+ } catch (error) {
5885
+ throw new Error(`Release blocked: unable to parse package.json in ${repoRoot}: ${error.message}`);
5886
+ }
5887
+ }
5888
+
5889
+ function resolveReleaseGithubRepo(repoRoot) {
5890
+ const releasePackageJson = readReleaseRepoPackageJson(repoRoot);
5891
+ const fromManifest = inferGithubRepoSlug(
5892
+ releasePackageJson.repository &&
5893
+ (releasePackageJson.repository.url || releasePackageJson.repository),
5894
+ );
5895
+ if (fromManifest) {
5896
+ return fromManifest;
5897
+ }
5898
+
5899
+ const fromOrigin = inferGithubRepoSlug(readGitConfig(repoRoot, 'remote.origin.url'));
5900
+ if (fromOrigin) {
5901
+ return fromOrigin;
5902
+ }
5903
+
5904
+ throw new Error(
5905
+ 'Release blocked: unable to resolve GitHub repo from package.json repository URL or origin remote.',
5906
+ );
5907
+ }
5908
+
5909
+ function readRepoReadme(repoRoot) {
5910
+ const readmePath = path.join(repoRoot, 'README.md');
5911
+ if (!fs.existsSync(readmePath)) {
5912
+ throw new Error(`Release blocked: README.md missing in ${repoRoot}`);
5913
+ }
5914
+ return fs.readFileSync(readmePath, 'utf8');
5915
+ }
5916
+
5917
+ function parseReadmeReleaseEntries(readmeContent) {
5918
+ const releaseNotesIndex = String(readmeContent || '').indexOf('## Release notes');
5919
+ if (releaseNotesIndex < 0) {
5920
+ throw new Error('Release blocked: README.md is missing the "## Release notes" section');
5921
+ }
5922
+
5923
+ const releaseNotesContent = String(readmeContent || '').slice(releaseNotesIndex);
5924
+ const entries = [];
5925
+ const lines = releaseNotesContent.split(/\r?\n/);
5926
+ let currentTag = '';
5927
+ let currentLines = [];
5928
+
5929
+ function flushEntry() {
5930
+ if (!currentTag) {
5931
+ return;
5932
+ }
5933
+ const body = currentLines.join('\n').trim();
5934
+ if (body) {
5935
+ entries.push({ tag: currentTag, body, version: parseVersionString(currentTag) });
5936
+ }
5937
+ currentTag = '';
5938
+ currentLines = [];
5939
+ }
5940
+
5941
+ for (const line of lines) {
5942
+ const headingMatch = line.match(/^###\s+(v\d+\.\d+\.\d+)\s*$/);
5943
+ if (headingMatch) {
5944
+ flushEntry();
5945
+ currentTag = headingMatch[1];
5946
+ continue;
5947
+ }
5948
+
5949
+ if (!currentTag) {
5950
+ continue;
5951
+ }
5952
+
5953
+ if (/^<\/details>\s*$/.test(line) || /^##\s+/.test(line)) {
5954
+ flushEntry();
5955
+ continue;
5956
+ }
5957
+
5958
+ currentLines.push(line);
5959
+ }
5960
+
5961
+ flushEntry();
5962
+
5963
+ if (entries.length === 0) {
5964
+ throw new Error('Release blocked: README.md did not yield any versioned release-note sections');
5965
+ }
5966
+
5967
+ return entries;
5968
+ }
5969
+
5970
+ function resolvePreviousPublishedReleaseTag(repoSlug, currentTag) {
5971
+ const result = run(GH_BIN, ['release', 'list', '--repo', repoSlug, '--limit', '20'], {
5972
+ timeout: 20_000,
5973
+ });
5974
+ if (result.error) {
5975
+ throw new Error(`Release blocked: unable to run '${GH_BIN} release list': ${result.error.message}`);
5976
+ }
5977
+ if (result.status !== 0) {
5978
+ const details = (result.stderr || result.stdout || '').trim();
5979
+ throw new Error(`Release blocked: unable to list GitHub releases.${details ? `\n${details}` : ''}`);
5980
+ }
5981
+
5982
+ const tags = String(result.stdout || '')
5983
+ .split('\n')
5984
+ .map((line) => line.split('\t')[0].trim())
5985
+ .filter(Boolean);
5986
+
5987
+ return tags.find((tag) => tag !== currentTag) || '';
5988
+ }
5989
+
5990
+ function selectReleaseEntriesForWindow(entries, currentTag, previousTag) {
5991
+ const currentVersion = parseVersionString(currentTag);
5992
+ if (!currentVersion) {
5993
+ throw new Error(`Release blocked: invalid current version tag '${currentTag}'`);
5994
+ }
5995
+ const previousVersion = previousTag ? parseVersionString(previousTag) : null;
5996
+
5997
+ const selected = entries.filter((entry) => {
5998
+ if (!entry.version) return false;
5999
+ if (compareParsedVersions(entry.version, currentVersion) > 0) return false;
6000
+ if (!previousVersion) return entry.tag === currentTag;
6001
+ return compareParsedVersions(entry.version, previousVersion) > 0;
6002
+ });
6003
+
6004
+ if (!selected.some((entry) => entry.tag === currentTag)) {
6005
+ throw new Error(`Release blocked: README.md is missing release notes for ${currentTag}`);
6006
+ }
6007
+
6008
+ return selected;
6009
+ }
6010
+
6011
+ function renderGeneratedReleaseNotes(entries, currentTag, previousTag) {
6012
+ const intro = previousTag ? `Changes since ${previousTag}.` : `Changes in ${currentTag}.`;
6013
+ const sections = entries
6014
+ .map((entry) => `### ${entry.tag}\n${entry.body}`)
6015
+ .join('\n\n');
6016
+ return `GitGuardex ${currentTag}\n\n${intro}\n\n${sections}`;
6017
+ }
6018
+
6019
+ function buildReleaseNotesFromReadme(repoRoot, currentTag, previousTag) {
6020
+ const readme = readRepoReadme(repoRoot);
6021
+ const entries = parseReadmeReleaseEntries(readme);
6022
+ const selected = selectReleaseEntriesForWindow(entries, currentTag, previousTag);
6023
+ return renderGeneratedReleaseNotes(selected, currentTag, previousTag);
6024
+ }
6025
+
5278
6026
  function release(rawArgs) {
5279
6027
  if (rawArgs.length > 0) {
5280
6028
  throw new Error(`Unknown option: ${rawArgs[0]}`);
@@ -5290,13 +6038,74 @@ function release(rawArgs) {
5290
6038
  ensureMainBranch(repoRoot);
5291
6039
  ensureCleanWorkingTree(repoRoot);
5292
6040
 
5293
- console.log(`[${TOOL_NAME}] Releasing ${packageJson.name}@${packageJson.version} from ${repoRoot}`);
5294
- const publishResult = run(NPM_BIN, ['publish'], { cwd: repoRoot, stdio: 'inherit' });
5295
- if (publishResult.status !== 0) {
5296
- throw new Error('npm publish failed');
6041
+ if (!isCommandAvailable(GH_BIN)) {
6042
+ throw new Error(`Release blocked: '${GH_BIN}' is not available`);
6043
+ }
6044
+
6045
+ const ghAuthStatus = run(GH_BIN, ['auth', 'status'], { timeout: 20_000 });
6046
+ if (ghAuthStatus.error) {
6047
+ throw new Error(`Release blocked: unable to run '${GH_BIN} auth status': ${ghAuthStatus.error.message}`);
6048
+ }
6049
+ if (ghAuthStatus.status !== 0) {
6050
+ const details = (ghAuthStatus.stderr || ghAuthStatus.stdout || '').trim();
6051
+ throw new Error(`Release blocked: '${GH_BIN}' auth is unavailable.${details ? `\n${details}` : ''}`);
6052
+ }
6053
+
6054
+ const releasePackageJson = readReleaseRepoPackageJson(repoRoot);
6055
+ const repoSlug = resolveReleaseGithubRepo(repoRoot);
6056
+ const currentTag = `v${releasePackageJson.version}`;
6057
+ const previousTag = resolvePreviousPublishedReleaseTag(repoSlug, currentTag);
6058
+ const notes = buildReleaseNotesFromReadme(repoRoot, currentTag, previousTag);
6059
+ const headCommit = gitRun(repoRoot, ['rev-parse', 'HEAD']).stdout.trim();
6060
+
6061
+ const existingRelease = run(GH_BIN, ['release', 'view', currentTag, '--repo', repoSlug], {
6062
+ timeout: 20_000,
6063
+ });
6064
+ if (existingRelease.error) {
6065
+ throw new Error(`Release blocked: unable to run '${GH_BIN} release view': ${existingRelease.error.message}`);
6066
+ }
6067
+
6068
+ const releaseArgs =
6069
+ existingRelease.status === 0
6070
+ ? ['release', 'edit', currentTag, '--repo', repoSlug, '--title', currentTag, '--notes', notes]
6071
+ : [
6072
+ 'release',
6073
+ 'create',
6074
+ currentTag,
6075
+ '--repo',
6076
+ repoSlug,
6077
+ '--target',
6078
+ headCommit,
6079
+ '--title',
6080
+ currentTag,
6081
+ '--notes',
6082
+ notes,
6083
+ ];
6084
+
6085
+ console.log(
6086
+ `[${TOOL_NAME}] ${existingRelease.status === 0 ? 'Updating' : 'Creating'} GitHub release ${currentTag} on ${repoSlug}`,
6087
+ );
6088
+ if (previousTag) {
6089
+ console.log(`[${TOOL_NAME}] Aggregating README release notes newer than ${previousTag}.`);
6090
+ } else {
6091
+ console.log(`[${TOOL_NAME}] No earlier published GitHub release found; using only ${currentTag}.`);
5297
6092
  }
5298
6093
 
5299
- console.log(`[${TOOL_NAME}] Publish complete.`);
6094
+ const releaseResult = run(GH_BIN, releaseArgs, { cwd: repoRoot, timeout: 60_000 });
6095
+ if (releaseResult.error) {
6096
+ throw new Error(`Release blocked: unable to run '${GH_BIN} release': ${releaseResult.error.message}`);
6097
+ }
6098
+ if (releaseResult.status !== 0) {
6099
+ const details = (releaseResult.stderr || releaseResult.stdout || '').trim();
6100
+ throw new Error(`GitHub release command failed.${details ? `\n${details}` : ''}`);
6101
+ }
6102
+
6103
+ const releaseUrl = String(releaseResult.stdout || '').trim();
6104
+ if (releaseUrl) {
6105
+ console.log(releaseUrl);
6106
+ }
6107
+
6108
+ console.log(`[${TOOL_NAME}] ✅ GitHub release ${currentTag} is synced to the README history.`);
5300
6109
  process.exitCode = 0;
5301
6110
  }
5302
6111
 
@@ -6100,6 +6909,7 @@ function main() {
6100
6909
  }
6101
6910
 
6102
6911
  if (command === '--version' || command === '-v' || command === 'version') {
6912
+ maybeSelfUpdateBeforeStatus();
6103
6913
  console.log(packageJson.version);
6104
6914
  return;
6105
6915
  }