@imdeadpool/guardex 7.0.20 → 7.0.22

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.
@@ -0,0 +1,1071 @@
1
+ const {
2
+ fs,
3
+ path,
4
+ TOOL_NAME,
5
+ SHORT_TOOL_NAME,
6
+ LOCK_FILE_RELATIVE,
7
+ REQUIRED_MANAGED_REPO_FILES,
8
+ OMX_SCAFFOLD_DIRECTORIES,
9
+ OMX_SCAFFOLD_FILES,
10
+ AGENT_WORKTREE_RELATIVE_DIRS,
11
+ defaultAgentWorktreeRelativeDir,
12
+ } = require('../context');
13
+ const { run, runPackageAsset } = require('../core/runtime');
14
+ const {
15
+ currentBranchName,
16
+ gitRefExists,
17
+ readGitConfig,
18
+ ensureRepoBranch,
19
+ hasOriginRemote,
20
+ aheadBehind,
21
+ mapWorktreePathsByBranch,
22
+ hasSignificantWorkingTreeChanges,
23
+ listLocalAgentBranches,
24
+ } = require('../git');
25
+ const {
26
+ extractAgentBranchStartMetadata,
27
+ resolveSandboxTarget,
28
+ isSpawnFailure,
29
+ startProtectedBaseSandbox,
30
+ cleanupProtectedBaseSandbox,
31
+ } = require('../sandbox');
32
+ const { ensureOmxScaffold, configureHooks } = require('../scaffold');
33
+ const { detectRecoverableAutoFinishConflict, printAutoFinishSummary } = require('../output');
34
+
35
+ /**
36
+ * @typedef {Object} SandboxMetadata
37
+ * @property {string} branch
38
+ * @property {string} worktreePath
39
+ */
40
+
41
+ /**
42
+ * @typedef {Object} OperationResult
43
+ * @property {string} status
44
+ * @property {string} [note]
45
+ * @property {string} [stdout]
46
+ * @property {string} [stderr]
47
+ * @property {string} [prUrl]
48
+ * @property {string[]} [stagedFiles]
49
+ * @property {string} [commitMessage]
50
+ * @property {OperationResult[]} [operations]
51
+ * @property {OperationResult} [cleanup]
52
+ * @property {OperationResult} [hookRefresh]
53
+ */
54
+
55
+ /**
56
+ * @typedef {Object} AutoFinishSummary
57
+ * @property {boolean} [enabled]
58
+ * @property {number} [attempted]
59
+ * @property {number} [completed]
60
+ * @property {number} [skipped]
61
+ * @property {number} [failed]
62
+ * @property {string[]} [details]
63
+ * @property {string} [baseBranch]
64
+ */
65
+
66
+ /**
67
+ * @typedef {Object} SandboxStartResult
68
+ * @property {SandboxMetadata} metadata
69
+ * @property {string} [stdout]
70
+ * @property {string} [stderr]
71
+ */
72
+
73
+ /**
74
+ * @typedef {Object} DoctorLockSyncState
75
+ * @property {OperationResult} result
76
+ * @property {string | null} sandboxLockContent
77
+ */
78
+
79
+ /**
80
+ * @typedef {Object} DoctorSandboxExecution
81
+ * @property {OperationResult} autoCommit
82
+ * @property {OperationResult} finish
83
+ * @property {OperationResult} protectedBaseRepairSync
84
+ * @property {OperationResult} lockSync
85
+ * @property {OperationResult} omxScaffoldSync
86
+ * @property {AutoFinishSummary} autoFinish
87
+ * @property {string | null} sandboxLockContent
88
+ */
89
+
90
+ function buildSandboxDoctorArgs(options, sandboxTarget) {
91
+ const args = ['doctor', '--target', sandboxTarget];
92
+ if (options.dryRun) args.push('--dry-run');
93
+ if (options.force) {
94
+ args.push('--force');
95
+ for (const managedPath of options.forceManagedPaths || []) {
96
+ args.push(managedPath);
97
+ }
98
+ }
99
+ if (options.skipAgents) args.push('--skip-agents');
100
+ if (options.skipPackageJson) args.push('--skip-package-json');
101
+ if (options.skipGitignore) args.push('--no-gitignore');
102
+ if (!options.dropStaleLocks) args.push('--keep-stale-locks');
103
+ args.push(options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge');
104
+ if (options.verboseAutoFinish) args.push('--verbose-auto-finish');
105
+ if (options.json) args.push('--json');
106
+ return args;
107
+ }
108
+
109
+ function originRemoteLooksLikeGithub(repoRoot) {
110
+ const originUrl = readGitConfig(repoRoot, 'remote.origin.url');
111
+ if (!originUrl) {
112
+ return false;
113
+ }
114
+ return /github\.com[:/]/i.test(originUrl);
115
+ }
116
+
117
+ function isCommandAvailable(commandName) {
118
+ return run('which', [commandName]).status === 0;
119
+ }
120
+
121
+ function parseGitPathList(output) {
122
+ return String(output || '')
123
+ .split('\n')
124
+ .map((line) => line.trim())
125
+ .filter((line) => line && line !== LOCK_FILE_RELATIVE);
126
+ }
127
+
128
+ function collectDoctorChangedPaths(worktreePath) {
129
+ const changed = new Set();
130
+ const commands = [
131
+ ['diff', '--name-only'],
132
+ ['diff', '--cached', '--name-only'],
133
+ ['ls-files', '--others', '--exclude-standard'],
134
+ ];
135
+ for (const gitArgs of commands) {
136
+ const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
137
+ for (const filePath of parseGitPathList(result.stdout)) {
138
+ changed.add(filePath);
139
+ }
140
+ }
141
+ return Array.from(changed);
142
+ }
143
+
144
+ function collectDoctorDeletedPaths(worktreePath) {
145
+ const deleted = new Set();
146
+ const commands = [
147
+ ['diff', '--name-only', '--diff-filter=D'],
148
+ ['diff', '--cached', '--name-only', '--diff-filter=D'],
149
+ ];
150
+ for (const gitArgs of commands) {
151
+ const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
152
+ for (const filePath of parseGitPathList(result.stdout)) {
153
+ deleted.add(filePath);
154
+ }
155
+ }
156
+ return Array.from(deleted);
157
+ }
158
+
159
+ function collectWorktreeDirtyPaths(worktreePath) {
160
+ const dirty = new Set();
161
+ const commands = [
162
+ ['diff', '--name-only'],
163
+ ['diff', '--cached', '--name-only'],
164
+ ['ls-files', '--others', '--exclude-standard'],
165
+ ];
166
+ for (const gitArgs of commands) {
167
+ const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
168
+ for (const filePath of parseGitPathList(result.stdout)) {
169
+ dirty.add(filePath);
170
+ }
171
+ }
172
+ return Array.from(dirty);
173
+ }
174
+
175
+ function collectDoctorForceAddPaths(worktreePath) {
176
+ return REQUIRED_MANAGED_REPO_FILES
177
+ .filter((relativePath) => relativePath.startsWith('scripts/') || relativePath.startsWith('.githooks/'))
178
+ .filter((relativePath) => fs.existsSync(path.join(worktreePath, relativePath)));
179
+ }
180
+
181
+ function stripDoctorSandboxLocks(rawContent, branchName) {
182
+ if (!rawContent || !branchName) {
183
+ return rawContent;
184
+ }
185
+ try {
186
+ const parsed = JSON.parse(rawContent);
187
+ const locks = parsed && typeof parsed === 'object' && parsed.locks && typeof parsed.locks === 'object'
188
+ ? parsed.locks
189
+ : null;
190
+ if (!locks) {
191
+ return rawContent;
192
+ }
193
+ let changed = false;
194
+ const filteredLocks = {};
195
+ for (const [filePath, lockInfo] of Object.entries(locks)) {
196
+ if (lockInfo && lockInfo.branch === branchName) {
197
+ changed = true;
198
+ continue;
199
+ }
200
+ filteredLocks[filePath] = lockInfo;
201
+ }
202
+ if (!changed) {
203
+ return rawContent;
204
+ }
205
+ return `${JSON.stringify({ ...parsed, locks: filteredLocks }, null, 2)}\n`;
206
+ } catch {
207
+ return rawContent;
208
+ }
209
+ }
210
+
211
+ function claimDoctorChangedLocks(metadata) {
212
+ if (!metadata.branch) {
213
+ return {
214
+ status: 'skipped',
215
+ note: 'missing sandbox branch metadata',
216
+ changedCount: 0,
217
+ deletedCount: 0,
218
+ };
219
+ }
220
+
221
+ const changedPaths = Array.from(new Set([
222
+ ...collectDoctorChangedPaths(metadata.worktreePath),
223
+ ...collectDoctorForceAddPaths(metadata.worktreePath),
224
+ ]));
225
+ const deletedPaths = collectDoctorDeletedPaths(metadata.worktreePath);
226
+ if (changedPaths.length > 0) {
227
+ runPackageAsset('lockTool', ['claim', '--branch', metadata.branch, ...changedPaths], {
228
+ cwd: metadata.worktreePath,
229
+ timeout: 30_000,
230
+ });
231
+ }
232
+ if (deletedPaths.length > 0) {
233
+ runPackageAsset('lockTool', ['allow-delete', '--branch', metadata.branch, ...deletedPaths], {
234
+ cwd: metadata.worktreePath,
235
+ timeout: 30_000,
236
+ });
237
+ }
238
+
239
+ return {
240
+ status: 'claimed',
241
+ note: 'claimed locks for doctor auto-commit',
242
+ changedCount: changedPaths.length,
243
+ deletedCount: deletedPaths.length,
244
+ };
245
+ }
246
+
247
+ function autoCommitDoctorSandboxChanges(metadata) {
248
+ if (!metadata.worktreePath || !metadata.branch) {
249
+ return {
250
+ status: 'skipped',
251
+ note: 'missing sandbox branch metadata',
252
+ };
253
+ }
254
+
255
+ claimDoctorChangedLocks(metadata);
256
+ run(
257
+ 'git',
258
+ ['-C', metadata.worktreePath, 'add', '-A', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
259
+ { timeout: 20_000 },
260
+ );
261
+ const forceAddPaths = collectDoctorForceAddPaths(metadata.worktreePath);
262
+ if (forceAddPaths.length > 0) {
263
+ run(
264
+ 'git',
265
+ ['-C', metadata.worktreePath, 'add', '-f', '--', ...forceAddPaths],
266
+ { timeout: 20_000 },
267
+ );
268
+ }
269
+ const staged = run(
270
+ 'git',
271
+ ['-C', metadata.worktreePath, 'diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
272
+ { timeout: 20_000 },
273
+ );
274
+ const stagedFiles = parseGitPathList(staged.stdout);
275
+ if (stagedFiles.length === 0) {
276
+ return {
277
+ status: 'no-changes',
278
+ note: 'no committable doctor changes found in sandbox',
279
+ };
280
+ }
281
+
282
+ const commitResult = run(
283
+ 'git',
284
+ ['-C', metadata.worktreePath, 'commit', '-m', 'Auto-finish: gx doctor repairs'],
285
+ { timeout: 30_000 },
286
+ );
287
+ if (commitResult.status !== 0) {
288
+ return {
289
+ status: 'failed',
290
+ note: 'doctor sandbox auto-commit failed',
291
+ stdout: commitResult.stdout || '',
292
+ stderr: commitResult.stderr || '',
293
+ };
294
+ }
295
+
296
+ return {
297
+ status: 'committed',
298
+ note: 'doctor sandbox repairs committed',
299
+ commitMessage: 'Auto-finish: gx doctor repairs',
300
+ stagedFiles,
301
+ };
302
+ }
303
+
304
+ function extractAgentBranchFinishPrUrl(output) {
305
+ const match = String(output || '').match(/\[agent-branch-finish\] PR:\s*(\S+)/);
306
+ return match ? match[1] : '';
307
+ }
308
+
309
+ function doctorFinishFlowIsPending(output) {
310
+ return (
311
+ /\[agent-branch-finish\] PR merge not completed yet; leaving PR open\./.test(output) ||
312
+ /\[agent-branch-finish\] Merge pending review\/check policy\. Branch cleanup skipped for now\./.test(output) ||
313
+ /\[agent-branch-finish\] PR auto-merge enabled; waiting for required checks\/reviews\./.test(output)
314
+ );
315
+ }
316
+
317
+ function finishDoctorSandboxBranch(blocked, metadata, options = {}) {
318
+ if (!hasOriginRemote(blocked.repoRoot)) {
319
+ return {
320
+ status: 'skipped',
321
+ note: 'origin remote missing; skipped auto-finish',
322
+ };
323
+ }
324
+ const explicitGhBin = Boolean(String(process.env.GUARDEX_GH_BIN || '').trim());
325
+ if (!explicitGhBin && !originRemoteLooksLikeGithub(blocked.repoRoot)) {
326
+ return {
327
+ status: 'skipped',
328
+ note: 'origin remote is not GitHub; skipped auto-finish PR flow',
329
+ };
330
+ }
331
+
332
+ const ghBin = process.env.GUARDEX_GH_BIN || 'gh';
333
+ if (!isCommandAvailable(ghBin)) {
334
+ return {
335
+ status: 'skipped',
336
+ note: `'${ghBin}' not available; skipped auto-finish PR flow`,
337
+ };
338
+ }
339
+ const ghAuthStatus = run(ghBin, ['auth', 'status'], { timeout: 20_000 });
340
+ if (ghAuthStatus.status !== 0) {
341
+ return {
342
+ status: 'skipped',
343
+ note: `'${ghBin}' auth unavailable; skipped auto-finish PR flow`,
344
+ stderr: ghAuthStatus.stderr || '',
345
+ };
346
+ }
347
+
348
+ const rawWaitTimeoutSeconds = Number.parseInt(process.env.GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS || '1800', 10);
349
+ const waitTimeoutSeconds =
350
+ Number.isFinite(rawWaitTimeoutSeconds) && rawWaitTimeoutSeconds >= 30 ? rawWaitTimeoutSeconds : 1800;
351
+ const finishTimeoutMs = Math.max(180_000, (waitTimeoutSeconds + 60) * 1000);
352
+ const waitForMergeArg = options.waitForMerge === false ? '--no-wait-for-merge' : '--wait-for-merge';
353
+
354
+ const finishResult = runPackageAsset(
355
+ 'branchFinish',
356
+ ['--branch', metadata.branch, '--base', blocked.branch, '--via-pr', waitForMergeArg, '--cleanup'],
357
+ { cwd: metadata.worktreePath, timeout: finishTimeoutMs },
358
+ );
359
+ if (isSpawnFailure(finishResult)) {
360
+ return {
361
+ status: 'failed',
362
+ note: 'doctor sandbox finish flow errored',
363
+ stdout: finishResult.stdout || '',
364
+ stderr: finishResult.stderr || '',
365
+ };
366
+ }
367
+ if (finishResult.status !== 0) {
368
+ return {
369
+ status: 'failed',
370
+ note: 'doctor sandbox finish flow failed',
371
+ stdout: finishResult.stdout || '',
372
+ stderr: finishResult.stderr || '',
373
+ };
374
+ }
375
+
376
+ const combinedOutput = `${finishResult.stdout || ''}\n${finishResult.stderr || ''}`;
377
+ if (doctorFinishFlowIsPending(combinedOutput)) {
378
+ return {
379
+ status: 'pending',
380
+ note: 'PR created and waiting for merge policy/checks',
381
+ prUrl: extractAgentBranchFinishPrUrl(combinedOutput),
382
+ stdout: finishResult.stdout || '',
383
+ stderr: finishResult.stderr || '',
384
+ };
385
+ }
386
+
387
+ return {
388
+ status: 'completed',
389
+ note: 'doctor sandbox finish flow completed',
390
+ stdout: finishResult.stdout || '',
391
+ stderr: finishResult.stderr || '',
392
+ };
393
+ }
394
+
395
+ function mergeDoctorSandboxRepairsBackToProtectedBase(options, blocked, metadata, autoCommitResult, finishResult) {
396
+ if (options.dryRun) {
397
+ return {
398
+ status: autoCommitResult.status === 'committed' ? 'would-merge' : 'skipped',
399
+ note: autoCommitResult.status === 'committed'
400
+ ? 'dry run: would fast-forward tracked doctor repairs into the protected base workspace'
401
+ : 'dry run skips tracked repair merge',
402
+ };
403
+ }
404
+
405
+ if (autoCommitResult.status !== 'committed') {
406
+ return {
407
+ status: autoCommitResult.status === 'no-changes' ? 'unchanged' : 'skipped',
408
+ note: autoCommitResult.status === 'no-changes'
409
+ ? 'no tracked doctor repairs needed in the protected base workspace'
410
+ : 'tracked doctor repair merge skipped',
411
+ };
412
+ }
413
+
414
+ if (finishResult.status !== 'skipped') {
415
+ return {
416
+ status: 'skipped',
417
+ note: finishResult.status === 'failed'
418
+ ? 'tracked doctor repairs remain in the sandbox after finish failure'
419
+ : 'tracked doctor repairs are being delivered through the sandbox finish flow',
420
+ };
421
+ }
422
+
423
+ const allowedPaths = new Set([
424
+ ...(autoCommitResult.stagedFiles || []),
425
+ ...OMX_SCAFFOLD_DIRECTORIES,
426
+ ...Array.from(OMX_SCAFFOLD_FILES.keys()),
427
+ ...REQUIRED_MANAGED_REPO_FILES,
428
+ 'bin',
429
+ 'package.json',
430
+ '.gitignore',
431
+ 'AGENTS.md',
432
+ ]);
433
+ const dirtyPaths = collectWorktreeDirtyPaths(blocked.repoRoot);
434
+ let stashRef = '';
435
+ if (dirtyPaths.length > 0) {
436
+ const unexpectedPaths = dirtyPaths.filter((filePath) => {
437
+ if (allowedPaths.has(filePath)) {
438
+ return false;
439
+ }
440
+ return !AGENT_WORKTREE_RELATIVE_DIRS.some(
441
+ (relativeDir) => filePath === relativeDir || filePath.startsWith(`${relativeDir}/`),
442
+ );
443
+ });
444
+ if (unexpectedPaths.length > 0) {
445
+ return {
446
+ status: 'failed',
447
+ note: `protected branch workspace has unrelated local changes: ${unexpectedPaths.join(', ')}`,
448
+ };
449
+ }
450
+ const stashMessage = `guardex-doctor-merge-${Date.now()}`;
451
+ const stashResult = run(
452
+ 'git',
453
+ ['-C', blocked.repoRoot, 'stash', 'push', '--all', '--message', stashMessage],
454
+ { timeout: 30_000 },
455
+ );
456
+ if (isSpawnFailure(stashResult)) {
457
+ return {
458
+ status: 'failed',
459
+ note: 'could not stash protected branch doctor drift before merge',
460
+ stdout: stashResult.stdout || '',
461
+ stderr: stashResult.stderr || '',
462
+ };
463
+ }
464
+ if (stashResult.status !== 0) {
465
+ return {
466
+ status: 'failed',
467
+ note: 'stashing protected branch doctor drift failed',
468
+ stdout: stashResult.stdout || '',
469
+ stderr: stashResult.stderr || '',
470
+ };
471
+ }
472
+
473
+ const stashLookup = run(
474
+ 'git',
475
+ ['-C', blocked.repoRoot, 'stash', 'list'],
476
+ { timeout: 20_000 },
477
+ );
478
+ stashRef = String(stashLookup.stdout || '')
479
+ .split('\n')
480
+ .find((line) => line.includes(stashMessage))
481
+ ?.split(':')[0]
482
+ ?.trim() || '';
483
+ }
484
+
485
+ const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
486
+ if (!restoreResult.ok) {
487
+ if (stashRef) {
488
+ run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
489
+ }
490
+ return {
491
+ status: 'failed',
492
+ note: `could not restore protected branch '${blocked.branch}' before applying sandbox repairs`,
493
+ stdout: restoreResult.stdout || '',
494
+ stderr: restoreResult.stderr || '',
495
+ };
496
+ }
497
+
498
+ const mergeResult = run(
499
+ 'git',
500
+ ['-C', blocked.repoRoot, 'merge', '--ff-only', metadata.branch],
501
+ { timeout: 30_000 },
502
+ );
503
+ if (isSpawnFailure(mergeResult)) {
504
+ if (stashRef) {
505
+ run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
506
+ }
507
+ return {
508
+ status: 'failed',
509
+ note: 'tracked doctor repair merge errored',
510
+ stdout: mergeResult.stdout || '',
511
+ stderr: mergeResult.stderr || '',
512
+ };
513
+ }
514
+ if (mergeResult.status !== 0) {
515
+ if (stashRef) {
516
+ run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
517
+ }
518
+ return {
519
+ status: 'failed',
520
+ note: 'tracked doctor repair merge failed',
521
+ stdout: mergeResult.stdout || '',
522
+ stderr: mergeResult.stderr || '',
523
+ };
524
+ }
525
+
526
+ let cleanupResult;
527
+ try {
528
+ cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
529
+ } catch (error) {
530
+ return {
531
+ status: 'failed',
532
+ note: `tracked doctor repair merge succeeded but sandbox cleanup failed: ${error.message}`,
533
+ stdout: mergeResult.stdout || '',
534
+ stderr: mergeResult.stderr || '',
535
+ };
536
+ }
537
+
538
+ let hookRefreshResult;
539
+ try {
540
+ hookRefreshResult = configureHooks(blocked.repoRoot, false);
541
+ } catch (error) {
542
+ return {
543
+ status: 'failed',
544
+ note: `tracked doctor repair merge succeeded but local hook refresh failed: ${error.message}`,
545
+ stdout: mergeResult.stdout || '',
546
+ stderr: mergeResult.stderr || '',
547
+ };
548
+ }
549
+
550
+ if (stashRef) {
551
+ run('git', ['-C', blocked.repoRoot, 'stash', 'drop', stashRef], { timeout: 20_000 });
552
+ }
553
+
554
+ return {
555
+ status: 'merged',
556
+ note: 'fast-forwarded tracked doctor repairs into the protected base workspace',
557
+ stdout: mergeResult.stdout || '',
558
+ stderr: mergeResult.stderr || '',
559
+ cleanup: cleanupResult,
560
+ hookRefresh: hookRefreshResult,
561
+ };
562
+ }
563
+
564
+ function createDoctorSkippedOperation(note = 'sandbox doctor did not complete successfully') {
565
+ return {
566
+ status: 'skipped',
567
+ note,
568
+ };
569
+ }
570
+
571
+ function createSkippedDoctorAutoFinishSummary(note = 'sandbox doctor did not complete successfully') {
572
+ return {
573
+ enabled: false,
574
+ attempted: 0,
575
+ completed: 0,
576
+ skipped: 0,
577
+ failed: 0,
578
+ details: [`Skipped auto-finish sweep (${note}).`],
579
+ };
580
+ }
581
+
582
+ function createDoctorSandboxExecutionState(note = 'sandbox doctor did not complete successfully') {
583
+ return {
584
+ autoCommit: createDoctorSkippedOperation(note),
585
+ finish: createDoctorSkippedOperation(note),
586
+ protectedBaseRepairSync: createDoctorSkippedOperation(note),
587
+ lockSync: createDoctorSkippedOperation(note),
588
+ omxScaffoldSync: createDoctorSkippedOperation(note),
589
+ autoFinish: createSkippedDoctorAutoFinishSummary(note),
590
+ sandboxLockContent: null,
591
+ };
592
+ }
593
+
594
+ function summarizeDoctorOmxScaffoldSync(repoRoot, dryRun) {
595
+ const omxScaffoldOps = ensureOmxScaffold(repoRoot, dryRun);
596
+ const changedOmxPaths = omxScaffoldOps.filter((operation) => operation.status !== 'unchanged');
597
+ if (changedOmxPaths.length === 0) {
598
+ return {
599
+ status: 'unchanged',
600
+ note: '.omx scaffold already in sync',
601
+ operations: omxScaffoldOps,
602
+ };
603
+ }
604
+ return {
605
+ status: dryRun ? 'would-sync' : 'synced',
606
+ note: `${dryRun ? 'would sync' : 'synced'} ${changedOmxPaths.length} .omx path(s)`,
607
+ operations: omxScaffoldOps,
608
+ };
609
+ }
610
+
611
+ function syncDoctorLockRegistryBeforeMerge(repoRoot, metadata) {
612
+ const sandboxLockPath = path.join(metadata.worktreePath, LOCK_FILE_RELATIVE);
613
+ const baseLockPath = path.join(repoRoot, LOCK_FILE_RELATIVE);
614
+ if (!fs.existsSync(baseLockPath)) {
615
+ return {
616
+ result: {
617
+ status: 'skipped',
618
+ note: `${LOCK_FILE_RELATIVE} missing in protected base workspace`,
619
+ },
620
+ sandboxLockContent: null,
621
+ };
622
+ }
623
+ if (!fs.existsSync(sandboxLockPath)) {
624
+ return {
625
+ result: {
626
+ status: 'skipped',
627
+ note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
628
+ },
629
+ sandboxLockContent: null,
630
+ };
631
+ }
632
+
633
+ const sourceContent = stripDoctorSandboxLocks(
634
+ fs.readFileSync(sandboxLockPath, 'utf8'),
635
+ metadata.branch,
636
+ );
637
+ const destinationContent = fs.readFileSync(baseLockPath, 'utf8');
638
+ if (sourceContent === destinationContent) {
639
+ return {
640
+ result: {
641
+ status: 'unchanged',
642
+ note: `${LOCK_FILE_RELATIVE} already in sync`,
643
+ },
644
+ sandboxLockContent: sourceContent,
645
+ };
646
+ }
647
+
648
+ fs.mkdirSync(path.dirname(baseLockPath), { recursive: true });
649
+ fs.writeFileSync(baseLockPath, sourceContent, 'utf8');
650
+ return {
651
+ result: {
652
+ status: 'synced',
653
+ note: `${LOCK_FILE_RELATIVE} synced from sandbox`,
654
+ },
655
+ sandboxLockContent: sourceContent,
656
+ };
657
+ }
658
+
659
+ function syncDoctorLockRegistryAfterMerge(repoRoot, sandboxLockContent) {
660
+ if (sandboxLockContent === null) {
661
+ return {
662
+ status: 'skipped',
663
+ note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
664
+ };
665
+ }
666
+
667
+ const baseLockPath = path.join(repoRoot, LOCK_FILE_RELATIVE);
668
+ if (!fs.existsSync(baseLockPath)) {
669
+ fs.mkdirSync(path.dirname(baseLockPath), { recursive: true });
670
+ fs.writeFileSync(baseLockPath, sandboxLockContent, 'utf8');
671
+ return {
672
+ status: 'synced',
673
+ note: `${LOCK_FILE_RELATIVE} recreated from sandbox`,
674
+ };
675
+ }
676
+
677
+ const destinationContent = fs.readFileSync(baseLockPath, 'utf8');
678
+ if (sandboxLockContent === destinationContent) {
679
+ return {
680
+ status: 'unchanged',
681
+ note: `${LOCK_FILE_RELATIVE} already in sync`,
682
+ };
683
+ }
684
+
685
+ fs.mkdirSync(path.dirname(baseLockPath), { recursive: true });
686
+ fs.writeFileSync(baseLockPath, sandboxLockContent, 'utf8');
687
+ return {
688
+ status: 'synced',
689
+ note: `${LOCK_FILE_RELATIVE} synced from sandbox`,
690
+ };
691
+ }
692
+
693
+ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
694
+ const baseBranch = String(options.baseBranch || '').trim();
695
+ const dryRun = Boolean(options.dryRun);
696
+ const waitForMerge = options.waitForMerge !== false;
697
+ const excludedBranches = new Set(
698
+ Array.isArray(options.excludeBranches)
699
+ ? options.excludeBranches.map((branch) => String(branch || '').trim()).filter(Boolean)
700
+ : [],
701
+ );
702
+
703
+ const summary = {
704
+ enabled: true,
705
+ baseBranch,
706
+ attempted: 0,
707
+ completed: 0,
708
+ skipped: 0,
709
+ failed: 0,
710
+ details: [],
711
+ };
712
+
713
+ if (!baseBranch || baseBranch === 'HEAD' || baseBranch.startsWith('agent/')) {
714
+ summary.enabled = false;
715
+ summary.details.push('Skipped auto-finish sweep (base branch is missing or not a non-agent local branch).');
716
+ return summary;
717
+ }
718
+
719
+ if (String(process.env.GUARDEX_DOCTOR_SANDBOX || '') === '1') {
720
+ summary.enabled = false;
721
+ summary.details.push('Skipped auto-finish sweep inside doctor sandbox pass.');
722
+ return summary;
723
+ }
724
+
725
+ if (String(process.env.GUARDEX_SKIP_AUTO_FINISH_READY_BRANCHES || '') === '1') {
726
+ summary.enabled = false;
727
+ summary.details.push('Skipped auto-finish sweep (GUARDEX_SKIP_AUTO_FINISH_READY_BRANCHES=1).');
728
+ return summary;
729
+ }
730
+
731
+ if (dryRun) {
732
+ summary.enabled = false;
733
+ summary.details.push('Skipped auto-finish sweep in dry-run mode.');
734
+ return summary;
735
+ }
736
+
737
+ if (!hasOriginRemote(repoRoot)) {
738
+ summary.enabled = false;
739
+ summary.details.push('Skipped auto-finish sweep (origin remote missing).');
740
+ return summary;
741
+ }
742
+ const explicitGhBin = Boolean(String(process.env.GUARDEX_GH_BIN || '').trim());
743
+ if (!explicitGhBin && !originRemoteLooksLikeGithub(repoRoot)) {
744
+ summary.enabled = false;
745
+ summary.details.push('Skipped auto-finish sweep (origin remote is not GitHub).');
746
+ return summary;
747
+ }
748
+
749
+ const ghBin = process.env.GUARDEX_GH_BIN || 'gh';
750
+ if (run(ghBin, ['--version']).status !== 0) {
751
+ summary.enabled = false;
752
+ summary.details.push(`Skipped auto-finish sweep (${ghBin} not available).`);
753
+ return summary;
754
+ }
755
+
756
+ const branchWorktrees = mapWorktreePathsByBranch(repoRoot);
757
+ const agentBranches = listLocalAgentBranches(repoRoot);
758
+ if (agentBranches.length === 0) {
759
+ summary.enabled = false;
760
+ summary.details.push('No local agent branches found for auto-finish sweep.');
761
+ return summary;
762
+ }
763
+
764
+ for (const branch of agentBranches) {
765
+ if (excludedBranches.has(branch)) {
766
+ summary.skipped += 1;
767
+ summary.details.push(`[skip] ${branch}: excluded from this auto-finish sweep.`);
768
+ continue;
769
+ }
770
+
771
+ if (branch === baseBranch) {
772
+ summary.skipped += 1;
773
+ summary.details.push(`[skip] ${branch}: source branch equals base branch.`);
774
+ continue;
775
+ }
776
+
777
+ let counts;
778
+ try {
779
+ counts = aheadBehind(repoRoot, branch, baseBranch);
780
+ } catch (error) {
781
+ summary.failed += 1;
782
+ summary.details.push(`[fail] ${branch}: unable to compute ahead/behind (${error.message}).`);
783
+ continue;
784
+ }
785
+
786
+ if (counts.ahead <= 0) {
787
+ summary.skipped += 1;
788
+ summary.details.push(`[skip] ${branch}: already merged into ${baseBranch}.`);
789
+ continue;
790
+ }
791
+
792
+ const branchWorktree = branchWorktrees.get(branch) || '';
793
+ if (branchWorktree && hasSignificantWorkingTreeChanges(branchWorktree)) {
794
+ summary.skipped += 1;
795
+ summary.details.push(`[skip] ${branch}: dirty worktree (${branchWorktree}).`);
796
+ continue;
797
+ }
798
+
799
+ summary.attempted += 1;
800
+ const finishArgs = [
801
+ '--branch',
802
+ branch,
803
+ '--base',
804
+ baseBranch,
805
+ '--via-pr',
806
+ waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge',
807
+ '--cleanup',
808
+ ];
809
+ const finishResult = runPackageAsset('branchFinish', finishArgs, { cwd: repoRoot });
810
+ const combinedOutput = [finishResult.stdout || '', finishResult.stderr || ''].join('\n').trim();
811
+
812
+ if (finishResult.status === 0) {
813
+ summary.completed += 1;
814
+ summary.details.push(`[done] ${branch}: auto-finish completed.`);
815
+ continue;
816
+ }
817
+
818
+ const recoverableConflict = detectRecoverableAutoFinishConflict(combinedOutput);
819
+ if (recoverableConflict) {
820
+ summary.skipped += 1;
821
+ const tail = combinedOutput ? ` ${combinedOutput.split('\n').slice(-2).join(' | ')}` : '';
822
+ summary.details.push(`[skip] ${branch}: ${recoverableConflict.rawLabel}${tail}`);
823
+ continue;
824
+ }
825
+
826
+ summary.failed += 1;
827
+ const tail = combinedOutput ? ` ${combinedOutput.split('\n').slice(-2).join(' | ')}` : '';
828
+ summary.details.push(`[fail] ${branch}: auto-finish failed.${tail}`);
829
+ }
830
+
831
+ return summary;
832
+ }
833
+
834
+ function executeDoctorSandboxLifecycle(options, blocked, metadata, integrations) {
835
+ const execution = createDoctorSandboxExecutionState();
836
+ const dryRun = Boolean(options.dryRun);
837
+ const resolvedIntegrations = integrations && typeof integrations === 'object' ? integrations : {};
838
+ const autoFinishRunner =
839
+ resolvedIntegrations.autoFinishReadyAgentBranches || autoFinishReadyAgentBranches;
840
+
841
+ execution.omxScaffoldSync = summarizeDoctorOmxScaffoldSync(blocked.repoRoot, dryRun);
842
+
843
+ if (!dryRun) {
844
+ execution.autoCommit = autoCommitDoctorSandboxChanges(metadata);
845
+ if (execution.autoCommit.status === 'committed') {
846
+ execution.finish = finishDoctorSandboxBranch(blocked, metadata, options);
847
+ } else if (execution.autoCommit.status === 'no-changes') {
848
+ execution.finish = createDoctorSkippedOperation('no doctor changes to auto-finish');
849
+ } else if (execution.autoCommit.status !== 'failed') {
850
+ execution.finish = createDoctorSkippedOperation('auto-commit did not run');
851
+ }
852
+ } else {
853
+ execution.autoCommit = createDoctorSkippedOperation('dry-run skips doctor sandbox auto-commit');
854
+ execution.finish = createDoctorSkippedOperation('dry-run skips doctor sandbox finish flow');
855
+ }
856
+
857
+ const lockSyncState = syncDoctorLockRegistryBeforeMerge(blocked.repoRoot, metadata);
858
+ execution.lockSync = lockSyncState.result;
859
+ execution.sandboxLockContent = lockSyncState.sandboxLockContent;
860
+
861
+ execution.protectedBaseRepairSync = mergeDoctorSandboxRepairsBackToProtectedBase(
862
+ options,
863
+ blocked,
864
+ metadata,
865
+ execution.autoCommit,
866
+ execution.finish,
867
+ );
868
+
869
+ execution.omxScaffoldSync = summarizeDoctorOmxScaffoldSync(blocked.repoRoot, dryRun);
870
+ execution.lockSync = syncDoctorLockRegistryAfterMerge(
871
+ blocked.repoRoot,
872
+ execution.sandboxLockContent,
873
+ );
874
+ execution.autoFinish = autoFinishRunner(blocked.repoRoot, {
875
+ baseBranch: blocked.branch,
876
+ dryRun: options.dryRun,
877
+ waitForMerge: options.waitForMerge,
878
+ excludeBranches: [metadata.branch],
879
+ });
880
+
881
+ return execution;
882
+ }
883
+
884
+ function emitDoctorSandboxJsonOutput(nestedResult, execution) {
885
+ if (nestedResult.stdout) {
886
+ if (nestedResult.status === 0) {
887
+ try {
888
+ const parsed = JSON.parse(nestedResult.stdout);
889
+ process.stdout.write(
890
+ JSON.stringify(
891
+ {
892
+ ...parsed,
893
+ protectedBaseRepairSync: execution.protectedBaseRepairSync,
894
+ sandboxOmxScaffoldSync: execution.omxScaffoldSync,
895
+ sandboxLockSync: execution.lockSync,
896
+ sandboxAutoCommit: execution.autoCommit,
897
+ sandboxFinish: execution.finish,
898
+ autoFinish: execution.autoFinish,
899
+ },
900
+ null,
901
+ 2,
902
+ ) + '\n',
903
+ );
904
+ } catch {
905
+ process.stdout.write(nestedResult.stdout);
906
+ }
907
+ } else {
908
+ process.stdout.write(nestedResult.stdout);
909
+ }
910
+ }
911
+ if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
912
+ }
913
+
914
+ function emitDoctorSandboxConsoleOutput(options, blocked, metadata, startResult, nestedResult, execution) {
915
+ console.log(
916
+ `[${TOOL_NAME}] doctor detected protected branch '${blocked.branch}'. ` +
917
+ `Running repairs in sandbox branch '${metadata.branch || 'agent/<auto>'}'.`,
918
+ );
919
+ if (startResult.stdout) process.stdout.write(startResult.stdout);
920
+ if (startResult.stderr) process.stderr.write(startResult.stderr);
921
+ if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
922
+ if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
923
+ if (nestedResult.status !== 0) {
924
+ return;
925
+ }
926
+
927
+ if (execution.autoCommit.status === 'committed') {
928
+ console.log(
929
+ `[${TOOL_NAME}] Auto-committed doctor repairs in sandbox branch '${metadata.branch}'.`,
930
+ );
931
+ } else if (execution.autoCommit.status === 'failed') {
932
+ console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit failed; branch left for manual follow-up.`);
933
+ if (execution.autoCommit.stdout) process.stdout.write(execution.autoCommit.stdout);
934
+ if (execution.autoCommit.stderr) process.stderr.write(execution.autoCommit.stderr);
935
+ } else {
936
+ console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit skipped: ${execution.autoCommit.note}.`);
937
+ }
938
+
939
+ if (execution.protectedBaseRepairSync.status === 'merged') {
940
+ console.log(`[${TOOL_NAME}] Fast-forwarded tracked doctor repairs into the protected branch workspace.`);
941
+ } else if (execution.protectedBaseRepairSync.status === 'unchanged') {
942
+ console.log(`[${TOOL_NAME}] Protected branch workspace already had the tracked doctor repairs.`);
943
+ } else if (execution.protectedBaseRepairSync.status === 'would-merge') {
944
+ console.log(`[${TOOL_NAME}] Dry run: would fast-forward tracked doctor repairs into the protected branch workspace.`);
945
+ } else if (execution.protectedBaseRepairSync.status === 'failed') {
946
+ console.log(`[${TOOL_NAME}] Protected branch tracked repair merge failed: ${execution.protectedBaseRepairSync.note}.`);
947
+ if (execution.protectedBaseRepairSync.stdout) process.stdout.write(execution.protectedBaseRepairSync.stdout);
948
+ if (execution.protectedBaseRepairSync.stderr) process.stderr.write(execution.protectedBaseRepairSync.stderr);
949
+ } else {
950
+ console.log(`[${TOOL_NAME}] Protected branch tracked repair merge skipped: ${execution.protectedBaseRepairSync.note}.`);
951
+ }
952
+
953
+ if (execution.lockSync.status === 'synced') {
954
+ console.log(
955
+ `[${TOOL_NAME}] Synced repaired lock registry back to protected branch workspace (${LOCK_FILE_RELATIVE}).`,
956
+ );
957
+ } else if (execution.lockSync.status === 'unchanged') {
958
+ console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
959
+ } else {
960
+ console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${execution.lockSync.note}.`);
961
+ }
962
+
963
+ if (execution.finish.status === 'completed') {
964
+ console.log(`[${TOOL_NAME}] Auto-finish flow completed for sandbox branch '${metadata.branch}'.`);
965
+ if (execution.finish.stdout) process.stdout.write(execution.finish.stdout);
966
+ if (execution.finish.stderr) process.stderr.write(execution.finish.stderr);
967
+ } else if (execution.finish.status === 'pending') {
968
+ console.log(
969
+ `[${TOOL_NAME}] Auto-finish pending for sandbox branch '${metadata.branch}': ${execution.finish.note}.`,
970
+ );
971
+ if (execution.finish.prUrl) {
972
+ console.log(`[${TOOL_NAME}] PR: ${execution.finish.prUrl}`);
973
+ }
974
+ if (execution.finish.stdout) process.stdout.write(execution.finish.stdout);
975
+ if (execution.finish.stderr) process.stderr.write(execution.finish.stderr);
976
+ } else if (execution.finish.status === 'failed') {
977
+ console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
978
+ if (execution.finish.stdout) process.stdout.write(execution.finish.stdout);
979
+ if (execution.finish.stderr) process.stderr.write(execution.finish.stderr);
980
+ } else {
981
+ console.log(`[${TOOL_NAME}] Auto-finish skipped: ${execution.finish.note}.`);
982
+ }
983
+
984
+ printAutoFinishSummary(execution.autoFinish, {
985
+ baseBranch: blocked.branch,
986
+ verbose: options.verboseAutoFinish,
987
+ });
988
+ if (execution.omxScaffoldSync.status === 'synced') {
989
+ console.log(`[${TOOL_NAME}] Synced .omx scaffold back to protected branch workspace.`);
990
+ } else if (execution.omxScaffoldSync.status === 'unchanged') {
991
+ console.log(`[${TOOL_NAME}] .omx scaffold already aligned in protected branch workspace.`);
992
+ } else if (execution.omxScaffoldSync.status === 'would-sync') {
993
+ console.log(`[${TOOL_NAME}] Dry run: would sync .omx scaffold back to protected branch workspace.`);
994
+ } else {
995
+ console.log(`[${TOOL_NAME}] .omx scaffold sync skipped: ${execution.omxScaffoldSync.note}.`);
996
+ }
997
+ }
998
+
999
+ function setDoctorSandboxExitCode(nestedResult, execution) {
1000
+ if (typeof nestedResult.status === 'number') {
1001
+ let exitCode = nestedResult.status;
1002
+ if (exitCode === 0 && execution.autoCommit.status === 'failed') {
1003
+ exitCode = 1;
1004
+ }
1005
+ if (
1006
+ exitCode === 0 &&
1007
+ execution.autoCommit.status === 'committed' &&
1008
+ (execution.finish.status === 'failed' || execution.finish.status === 'pending')
1009
+ ) {
1010
+ exitCode = 1;
1011
+ }
1012
+ if (exitCode === 0 && execution.protectedBaseRepairSync.status === 'failed') {
1013
+ exitCode = 1;
1014
+ }
1015
+ process.exitCode = exitCode;
1016
+ return;
1017
+ }
1018
+ process.exitCode = 1;
1019
+ }
1020
+
1021
+ function runDoctorInSandbox(options, blocked, rawIntegrations = {}) {
1022
+ const integrations = rawIntegrations && typeof rawIntegrations === 'object' ? rawIntegrations : {};
1023
+ const startSandbox = integrations.startProtectedBaseSandbox || startProtectedBaseSandbox;
1024
+ const startResult = startSandbox(blocked, {
1025
+ taskName: `${SHORT_TOOL_NAME}-doctor`,
1026
+ sandboxSuffix: 'gx-doctor',
1027
+ });
1028
+ const metadata = startResult.metadata;
1029
+
1030
+ const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
1031
+ const nestedResult = run(
1032
+ process.execPath,
1033
+ [require.main?.filename || process.argv[1], ...buildSandboxDoctorArgs(options, sandboxTarget)],
1034
+ { cwd: metadata.worktreePath },
1035
+ );
1036
+ if (isSpawnFailure(nestedResult)) {
1037
+ throw nestedResult.error;
1038
+ }
1039
+
1040
+ const execution = nestedResult.status === 0
1041
+ ? executeDoctorSandboxLifecycle(options, blocked, metadata, integrations)
1042
+ : createDoctorSandboxExecutionState();
1043
+
1044
+ if (options.json) {
1045
+ emitDoctorSandboxJsonOutput(nestedResult, execution);
1046
+ } else {
1047
+ emitDoctorSandboxConsoleOutput(options, blocked, metadata, startResult, nestedResult, execution);
1048
+ }
1049
+
1050
+ setDoctorSandboxExitCode(nestedResult, execution);
1051
+ }
1052
+
1053
+ module.exports = {
1054
+ extractAgentBranchStartMetadata,
1055
+ resolveSandboxTarget,
1056
+ buildSandboxDoctorArgs,
1057
+ isSpawnFailure,
1058
+ startProtectedBaseSandbox,
1059
+ cleanupProtectedBaseSandbox,
1060
+ claimDoctorChangedLocks,
1061
+ autoCommitDoctorSandboxChanges,
1062
+ finishDoctorSandboxBranch,
1063
+ mergeDoctorSandboxRepairsBackToProtectedBase,
1064
+ syncDoctorLockRegistryBeforeMerge,
1065
+ syncDoctorLockRegistryAfterMerge,
1066
+ executeDoctorSandboxLifecycle,
1067
+ emitDoctorSandboxJsonOutput,
1068
+ emitDoctorSandboxConsoleOutput,
1069
+ autoFinishReadyAgentBranches,
1070
+ runDoctorInSandbox,
1071
+ };