@imdeadpool/guardex 7.0.21 → 7.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/main.js CHANGED
@@ -3,7 +3,9 @@
3
3
  const hooksModule = require('../hooks');
4
4
  const sandboxModule = require('../sandbox');
5
5
  const toolchainModule = require('../toolchain');
6
- const finishModule = require('../finish');
6
+ const finishCommands = require('../finish');
7
+ const doctorModule = require('../doctor');
8
+ const sessionSeverityReport = require('../report/session-severity');
7
9
  const {
8
10
  fs,
9
11
  path,
@@ -60,6 +62,9 @@ const {
60
62
  DEPRECATED_COMMAND_ALIASES,
61
63
  envFlagIsTruthy,
62
64
  defaultAgentWorktreeRelativeDir,
65
+ listAiSetupPartNames,
66
+ parseAiSetupPartNames,
67
+ renderAiSetupPrompt,
63
68
  AI_SETUP_PROMPT,
64
69
  AI_SETUP_COMMANDS,
65
70
  SCORECARD_RISK_BY_CHECK,
@@ -69,6 +74,37 @@ const {
69
74
  resolveRepoRoot,
70
75
  isGitRepo,
71
76
  discoverNestedGitRepos,
77
+ uniquePreserveOrder,
78
+ listLocalUserBranches,
79
+ listLocalAgentBranches,
80
+ mapWorktreePathsByBranch,
81
+ gitRefExists,
82
+ hasSignificantWorkingTreeChanges,
83
+ readConfiguredProtectedBranches,
84
+ readProtectedBranches,
85
+ ensureSetupProtectedBranches,
86
+ writeProtectedBranches,
87
+ readGitConfig,
88
+ resolveBaseBranch,
89
+ resolveSyncStrategy,
90
+ currentBranchName,
91
+ repoHasHeadCommit,
92
+ readBranchDisplayName,
93
+ hasOriginRemote: repoHasOriginRemote,
94
+ detectComposeHintFiles,
95
+ printSetupRepoHints,
96
+ ensureRepoBranch,
97
+ ensureOriginBaseRef,
98
+ workingTreeIsDirty,
99
+ aheadBehind,
100
+ lockRegistryStatus,
101
+ listAgentWorktrees,
102
+ listLocalAgentBranchesForFinish,
103
+ worktreeHasLocalChanges,
104
+ branchExists,
105
+ resolveFinishBaseBranch,
106
+ branchMergedIntoBase,
107
+ syncOperation,
72
108
  } = require('../git');
73
109
  const {
74
110
  run,
@@ -78,6 +114,12 @@ const {
78
114
  runReviewBotCommand,
79
115
  invokePackageAsset,
80
116
  } = require('../core/runtime');
117
+ const {
118
+ parseVersionString,
119
+ compareParsedVersions,
120
+ isNewerVersion,
121
+ } = require('../core/versions');
122
+ const { readSingleLineFromStdin } = require('../core/stdin');
81
123
  const {
82
124
  normalizeManagedForcePath,
83
125
  parseCommonArgs,
@@ -119,94 +161,28 @@ const {
119
161
  renderShellDispatchShim,
120
162
  renderPythonDispatchShim,
121
163
  managedForceConflictMessage,
164
+ renderManagedFile,
165
+ ensureGeneratedScriptShim,
166
+ ensureHookShim,
167
+ copyTemplateFile,
168
+ ensureTemplateFilePresent,
169
+ materializePackageRepoTemplateFiles,
170
+ ensureOmxScaffold,
171
+ ensureLockRegistry,
172
+ lockStateOrError,
173
+ writeLockState,
174
+ removeLegacyPackageScripts,
175
+ installUserLevelAsset,
176
+ removeLegacyManagedRepoFile,
177
+ ensureAgentsSnippet,
178
+ ensureManagedGitignore,
179
+ buildRepoVscodeSettings,
180
+ ensureRepoVscodeSettings,
181
+ configureHooks,
122
182
  printOperations,
123
183
  printStandaloneOperations,
124
184
  } = require('../scaffold');
125
185
 
126
- let sandboxApi;
127
- let toolchainApi;
128
- let finishApi;
129
-
130
- function getSandboxApi() {
131
- if (!sandboxApi) {
132
- sandboxApi = sandboxModule.createSandboxApi({
133
- protectedBaseWriteBlock,
134
- runInstallInternal,
135
- ensureSetupProtectedBranches,
136
- ensureParentWorkspaceView,
137
- buildParentWorkspaceView,
138
- runFixInternal,
139
- });
140
- }
141
- return sandboxApi;
142
- }
143
-
144
- function getToolchainApi() {
145
- if (!toolchainApi) {
146
- toolchainApi = toolchainModule.createToolchainApi({
147
- TOOL_NAME,
148
- NPM_BIN,
149
- NPX_BIN,
150
- packageJson,
151
- OPENSPEC_PACKAGE,
152
- OPENSPEC_BIN,
153
- GLOBAL_TOOLCHAIN_PACKAGES,
154
- parseAutoApproval,
155
- isInteractiveTerminal,
156
- promptYesNoStrict,
157
- run,
158
- checkForGuardexUpdate,
159
- printUpdateAvailableBanner,
160
- readInstalledGuardexVersion,
161
- restartIntoUpdatedGuardex,
162
- checkForOpenSpecPackageUpdate,
163
- printOpenSpecUpdateAvailableBanner,
164
- resolveGlobalInstallApproval,
165
- detectGlobalToolchainPackages,
166
- detectOptionalLocalCompanionTools,
167
- formatGlobalToolchainServiceName,
168
- askGlobalInstallForMissing,
169
- });
170
- }
171
- return toolchainApi;
172
- }
173
-
174
- function getFinishApi() {
175
- if (!finishApi) {
176
- finishApi = finishModule.createFinishApi({
177
- TOOL_NAME,
178
- LOCK_FILE_RELATIVE,
179
- path,
180
- fs,
181
- run,
182
- runPackageAsset,
183
- resolveRepoRoot,
184
- parseCleanupArgs,
185
- parseMergeArgs,
186
- parseFinishArgs,
187
- parseSyncArgs,
188
- listAgentWorktrees,
189
- listLocalAgentBranchesForFinish,
190
- uniquePreserveOrder,
191
- branchExists,
192
- resolveFinishBaseBranch,
193
- worktreeHasLocalChanges,
194
- branchMergedIntoBase,
195
- autoCommitWorktreeForFinish,
196
- resolveBaseBranch,
197
- resolveSyncStrategy,
198
- ensureOriginBaseRef,
199
- gitRun,
200
- currentBranchName,
201
- workingTreeIsDirty,
202
- aheadBehind,
203
- lockRegistryStatus,
204
- syncOperation,
205
- });
206
- }
207
- return finishApi;
208
- }
209
-
210
186
  /**
211
187
  * @typedef {Object} AutoFinishSummary
212
188
  * @property {boolean} [enabled]
@@ -261,116 +237,27 @@ function getFinishApi() {
261
237
  * @property {AutoFinishSummary} autoFinish
262
238
  * @property {string | null} sandboxLockContent
263
239
  */
264
- function renderManagedFile(repoRoot, relativePath, content, options = {}) {
265
- const destinationPath = path.join(repoRoot, relativePath);
266
- const destinationExists = fs.existsSync(destinationPath);
267
- const force = Boolean(options.force);
268
- const dryRun = Boolean(options.dryRun);
269
-
270
- if (destinationExists) {
271
- const existingContent = fs.readFileSync(destinationPath, 'utf8');
272
- if (existingContent === content) {
273
- ensureExecutable(destinationPath, relativePath, dryRun);
274
- return { status: 'unchanged', file: relativePath };
275
- }
276
- if (!force && !isCriticalGuardrailPath(relativePath)) {
277
- throw new Error(managedForceConflictMessage(relativePath));
278
- }
279
- }
280
-
281
- ensureParentDir(repoRoot, destinationPath, dryRun);
282
- if (!dryRun) {
283
- fs.writeFileSync(destinationPath, content, 'utf8');
284
- ensureExecutable(destinationPath, relativePath, dryRun);
285
- }
286
-
287
- if (destinationExists && !force && isCriticalGuardrailPath(relativePath)) {
288
- return { status: dryRun ? 'would-repair-critical' : 'repaired-critical', file: relativePath };
289
- }
290
-
291
- return { status: destinationExists ? 'overwritten' : 'created', file: relativePath };
292
- }
293
-
294
- function ensureGeneratedScriptShim(repoRoot, spec, options = {}) {
295
- const content = spec.kind === 'python'
296
- ? renderPythonDispatchShim(spec.command)
297
- : renderShellDispatchShim(spec.command);
298
- return renderManagedFile(repoRoot, spec.relativePath, content, options);
299
- }
300
-
301
- function ensureHookShim(repoRoot, hookName, options = {}) {
302
- return renderManagedFile(
303
- repoRoot,
304
- path.posix.join('.githooks', hookName),
305
- renderShellDispatchShim(['hook', 'run', hookName]),
306
- options,
307
- );
308
- }
309
-
310
- function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
311
- const sourcePath = path.join(TEMPLATE_ROOT, relativeTemplatePath);
312
- const destinationRelativePath = toDestinationPath(relativeTemplatePath);
313
- const destinationPath = path.join(repoRoot, destinationRelativePath);
314
-
315
- const sourceContent = fs.readFileSync(sourcePath, 'utf8');
316
- const destinationExists = fs.existsSync(destinationPath);
317
240
 
318
- if (destinationExists) {
319
- const existingContent = fs.readFileSync(destinationPath, 'utf8');
320
- if (existingContent === sourceContent) {
321
- ensureExecutable(destinationPath, destinationRelativePath, dryRun);
322
- return { status: 'unchanged', file: destinationRelativePath };
323
- }
324
- if (!force && !isCriticalGuardrailPath(destinationRelativePath)) {
325
- throw new Error(managedForceConflictMessage(destinationRelativePath));
326
- }
327
- }
328
-
329
- ensureParentDir(repoRoot, destinationPath, dryRun);
330
- if (!dryRun) {
331
- fs.writeFileSync(destinationPath, sourceContent, 'utf8');
332
- ensureExecutable(destinationPath, destinationRelativePath, dryRun);
241
+ function appendForceArgs(args, options) {
242
+ if (!options.force) {
243
+ return;
333
244
  }
334
-
335
- if (destinationExists && !force && isCriticalGuardrailPath(destinationRelativePath)) {
336
- return { status: dryRun ? 'would-repair-critical' : 'repaired-critical', file: destinationRelativePath };
245
+ args.push('--force');
246
+ for (const managedPath of options.forceManagedPaths || []) {
247
+ args.push(managedPath);
337
248
  }
338
-
339
- return { status: destinationExists ? 'overwritten' : 'created', file: destinationRelativePath };
340
249
  }
341
250
 
342
- function ensureTemplateFilePresent(repoRoot, relativeTemplatePath, dryRun) {
343
- const sourcePath = path.join(TEMPLATE_ROOT, relativeTemplatePath);
344
- const destinationRelativePath = toDestinationPath(relativeTemplatePath);
345
- const destinationPath = path.join(repoRoot, destinationRelativePath);
346
- const sourceContent = fs.readFileSync(sourcePath, 'utf8');
347
-
348
- if (fs.existsSync(destinationPath)) {
349
- const existingContent = fs.readFileSync(destinationPath, 'utf8');
350
- if (existingContent === sourceContent) {
351
- ensureExecutable(destinationPath, destinationRelativePath, dryRun);
352
- return { status: 'unchanged', file: destinationRelativePath };
353
- }
354
-
355
- if (isCriticalGuardrailPath(destinationRelativePath)) {
356
- if (!dryRun) {
357
- fs.writeFileSync(destinationPath, sourceContent, 'utf8');
358
- ensureExecutable(destinationPath, destinationRelativePath, dryRun);
359
- }
360
- return { status: dryRun ? 'would-repair-critical' : 'repaired-critical', file: destinationRelativePath };
361
- }
362
-
363
- // In fix mode, avoid silently replacing local customizations.
364
- return { status: 'skipped-conflict', file: destinationRelativePath };
251
+ function shouldForceManagedPath(options, relativePath) {
252
+ if (!options.force) {
253
+ return false;
365
254
  }
366
-
367
- ensureParentDir(repoRoot, destinationPath, dryRun);
368
- if (!dryRun) {
369
- fs.writeFileSync(destinationPath, sourceContent, 'utf8');
370
- ensureExecutable(destinationPath, destinationRelativePath, dryRun);
255
+ const targetedPaths = Array.isArray(options.forceManagedPaths) ? options.forceManagedPaths : [];
256
+ if (targetedPaths.length === 0) {
257
+ return true;
371
258
  }
372
-
373
- return { status: 'created', file: destinationRelativePath };
259
+ const normalized = normalizeManagedForcePath(relativePath);
260
+ return normalized !== null && targetedPaths.includes(normalized);
374
261
  }
375
262
 
376
263
  function ensureTargetedLegacyWorkflowShims(repoRoot, options) {
@@ -389,2760 +276,599 @@ function ensureTargetedLegacyWorkflowShims(repoRoot, options) {
389
276
  return operations;
390
277
  }
391
278
 
392
- function lockFilePath(repoRoot) {
393
- return path.join(repoRoot, LOCK_FILE_RELATIVE);
279
+ function normalizeWorkspacePath(relativePath) {
280
+ return String(relativePath || '.').replace(/\\/g, '/');
394
281
  }
395
282
 
396
- function ensureOmxScaffold(repoRoot, dryRun) {
397
- const operations = [];
398
-
399
- for (const relativeDir of REPO_SCAFFOLD_DIRECTORIES) {
400
- const absoluteDir = path.join(repoRoot, relativeDir);
401
- if (fs.existsSync(absoluteDir)) {
402
- if (!fs.statSync(absoluteDir).isDirectory()) {
403
- throw new Error(`Expected directory at ${relativeDir} but found a file.`);
404
- }
405
- operations.push({ status: 'unchanged', file: relativeDir });
406
- continue;
407
- }
408
-
409
- if (!dryRun) {
410
- fs.mkdirSync(absoluteDir, { recursive: true });
411
- }
412
- operations.push({ status: 'created', file: relativeDir });
413
- }
283
+ function isCommandAvailable(commandName) {
284
+ return run('which', [commandName]).status === 0;
285
+ }
414
286
 
415
- for (const relativeDir of OMX_SCAFFOLD_DIRECTORIES) {
416
- const absoluteDir = path.join(repoRoot, relativeDir);
417
- if (fs.existsSync(absoluteDir)) {
418
- if (!fs.statSync(absoluteDir).isDirectory()) {
419
- throw new Error(`Expected directory at ${relativeDir} but found a file.`);
420
- }
421
- operations.push({ status: 'unchanged', file: relativeDir });
422
- continue;
423
- }
287
+ function buildParentWorkspaceView(repoRoot) {
288
+ const parentDir = path.dirname(repoRoot);
289
+ const workspaceFileName = `${path.basename(repoRoot)}-branches.code-workspace`;
290
+ const workspacePath = path.join(parentDir, workspaceFileName);
291
+ const repoRelativePath = normalizeWorkspacePath(path.relative(parentDir, repoRoot) || '.');
424
292
 
425
- if (!dryRun) {
426
- fs.mkdirSync(absoluteDir, { recursive: true });
427
- }
428
- operations.push({ status: 'created', file: relativeDir });
429
- }
293
+ return {
294
+ workspacePath,
295
+ payload: {
296
+ folders: [
297
+ { path: repoRelativePath },
298
+ ...AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => ({
299
+ path: normalizeWorkspacePath(path.join(repoRelativePath === '.' ? '' : repoRelativePath, relativeDir)),
300
+ })),
301
+ ],
302
+ settings: {
303
+ 'scm.alwaysShowRepositories': true,
304
+ },
305
+ },
306
+ };
307
+ }
430
308
 
431
- for (const [relativeFile, defaultContent] of OMX_SCAFFOLD_FILES.entries()) {
432
- const absoluteFile = path.join(repoRoot, relativeFile);
433
- if (fs.existsSync(absoluteFile)) {
434
- if (!fs.statSync(absoluteFile).isFile()) {
435
- throw new Error(`Expected file at ${relativeFile} but found a directory.`);
436
- }
437
- operations.push({ status: 'unchanged', file: relativeFile });
438
- continue;
439
- }
309
+ function ensureParentWorkspaceView(repoRoot, dryRun) {
310
+ const { workspacePath, payload } = buildParentWorkspaceView(repoRoot);
311
+ const operationFile = path.relative(repoRoot, workspacePath) || path.basename(workspacePath);
312
+ const nextContent = `${JSON.stringify(payload, null, 2)}\n`;
313
+ const note = 'parent VS Code workspace view';
440
314
 
315
+ if (!fs.existsSync(workspacePath)) {
441
316
  if (!dryRun) {
442
- fs.mkdirSync(path.dirname(absoluteFile), { recursive: true });
443
- fs.writeFileSync(absoluteFile, defaultContent, 'utf8');
317
+ fs.writeFileSync(workspacePath, nextContent, 'utf8');
444
318
  }
445
- operations.push({ status: 'created', file: relativeFile });
319
+ return { status: dryRun ? 'would-create' : 'created', file: operationFile, note };
446
320
  }
447
321
 
448
- return operations;
449
- }
450
-
451
- function ensureLockRegistry(repoRoot, dryRun) {
452
- const absolutePath = lockFilePath(repoRoot);
453
- if (fs.existsSync(absolutePath)) {
454
- return { status: 'unchanged', file: LOCK_FILE_RELATIVE };
322
+ const currentContent = fs.readFileSync(workspacePath, 'utf8');
323
+ if (currentContent === nextContent) {
324
+ return { status: 'unchanged', file: operationFile, note };
455
325
  }
456
326
 
457
327
  if (!dryRun) {
458
- fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
459
- fs.writeFileSync(absolutePath, JSON.stringify({ locks: {} }, null, 2) + '\n', 'utf8');
460
- }
461
-
462
- return { status: 'created', file: LOCK_FILE_RELATIVE };
463
- }
464
-
465
- function lockStateOrError(repoRoot) {
466
- const lockPath = lockFilePath(repoRoot);
467
- if (!fs.existsSync(lockPath)) {
468
- return { ok: false, error: `${LOCK_FILE_RELATIVE} is missing` };
469
- }
470
-
471
- try {
472
- const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
473
- if (!parsed || typeof parsed !== 'object' || typeof parsed.locks !== 'object' || parsed.locks === null) {
474
- return { ok: false, error: `${LOCK_FILE_RELATIVE} has invalid schema (expected { locks: {} })` };
475
- }
476
-
477
- // Normalize older schema entries.
478
- for (const [filePath, entry] of Object.entries(parsed.locks)) {
479
- if (!entry || typeof entry !== 'object') {
480
- parsed.locks[filePath] = { branch: '', claimed_at: '', allow_delete: false };
481
- continue;
482
- }
483
- if (!Object.prototype.hasOwnProperty.call(entry, 'allow_delete')) {
484
- entry.allow_delete = false;
485
- }
486
- }
487
-
488
- return { ok: true, raw: parsed, locks: parsed.locks };
489
- } catch (error) {
490
- return { ok: false, error: `${LOCK_FILE_RELATIVE} is invalid JSON: ${error.message}` };
328
+ fs.writeFileSync(workspacePath, nextContent, 'utf8');
491
329
  }
330
+ return { status: dryRun ? 'would-update' : 'updated', file: operationFile, note };
492
331
  }
493
332
 
494
- function writeLockState(repoRoot, payload, dryRun) {
495
- if (dryRun) return;
496
- const lockPath = lockFilePath(repoRoot);
497
- fs.mkdirSync(path.dirname(lockPath), { recursive: true });
498
- fs.writeFileSync(lockPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
333
+ function hasGuardexBootstrapFiles(repoRoot) {
334
+ const required = [
335
+ 'AGENTS.md',
336
+ '.githooks/pre-commit',
337
+ '.githooks/pre-push',
338
+ LOCK_FILE_RELATIVE,
339
+ ];
340
+ return required.every((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
499
341
  }
500
342
 
501
- function removeLegacyPackageScripts(repoRoot, dryRun) {
502
- const packagePath = path.join(repoRoot, 'package.json');
503
- if (!fs.existsSync(packagePath)) {
504
- return { status: 'skipped', file: 'package.json', note: 'package.json not found' };
343
+ function protectedBaseWriteBlock(options, { requireBootstrap = true } = {}) {
344
+ if (options.dryRun || options.allowProtectedBaseWrite) {
345
+ return null;
505
346
  }
506
347
 
507
- let pkg;
508
- try {
509
- pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
510
- } catch (error) {
511
- throw new Error(`Unable to parse package.json in target repo: ${error.message}`);
348
+ const repoRoot = resolveRepoRoot(options.target);
349
+ if (requireBootstrap && !hasGuardexBootstrapFiles(repoRoot)) {
350
+ return null;
512
351
  }
513
352
 
514
- const existingScripts = pkg.scripts && typeof pkg.scripts === 'object'
515
- ? pkg.scripts
516
- : {};
517
- pkg.scripts = existingScripts;
518
- let changed = false;
519
- for (const [key, value] of Object.entries(LEGACY_MANAGED_PACKAGE_SCRIPTS)) {
520
- if (existingScripts[key] === value) {
521
- delete existingScripts[key];
522
- changed = true;
523
- }
353
+ const branch = currentBranchName(repoRoot);
354
+ if (branch !== 'main') {
355
+ return null;
524
356
  }
525
357
 
526
- if (!changed) {
527
- return { status: 'unchanged', file: 'package.json', note: 'no Guardex-managed agent:* scripts found' };
358
+ const protectedBranches = readProtectedBranches(repoRoot);
359
+ if (!protectedBranches.includes(branch)) {
360
+ return null;
528
361
  }
529
362
 
530
- if (!dryRun) {
531
- fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
532
- }
363
+ return {
364
+ repoRoot,
365
+ branch,
366
+ };
367
+ }
533
368
 
534
- return { status: dryRun ? 'would-update' : 'updated', file: 'package.json', note: 'removed Guardex-managed agent:* scripts' };
369
+ function assertProtectedMainWriteAllowed(options, commandName) {
370
+ return sandboxModule.assertProtectedMainWriteAllowed(options, commandName);
535
371
  }
536
372
 
537
- function installUserLevelAsset(asset, options = {}) {
538
- const dryRun = Boolean(options.dryRun);
539
- const force = Boolean(options.force);
540
- const destinationPath = path.join(GUARDEX_HOME_DIR, asset.destination);
541
- const sourceContent = fs.readFileSync(asset.source, 'utf8');
542
- const destinationExists = fs.existsSync(destinationPath);
543
-
544
- if (destinationExists) {
545
- const existingContent = fs.readFileSync(destinationPath, 'utf8');
546
- if (existingContent === sourceContent) {
547
- return { status: 'unchanged', file: asset.destination };
548
- }
549
- if (!force) {
550
- return { status: 'skipped-conflict', file: asset.destination };
373
+ function runSetupBootstrapInternal(options) {
374
+ const installPayload = runInstallInternal(options);
375
+ installPayload.operations.push(
376
+ ensureSetupProtectedBranches(installPayload.repoRoot, Boolean(options.dryRun)),
377
+ );
378
+
379
+ let parentWorkspace = null;
380
+ if (options.parentWorkspaceView) {
381
+ installPayload.operations.push(
382
+ ensureParentWorkspaceView(installPayload.repoRoot, Boolean(options.dryRun)),
383
+ );
384
+ if (!options.dryRun) {
385
+ parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot);
551
386
  }
552
387
  }
553
388
 
554
- if (!dryRun) {
555
- fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
556
- fs.writeFileSync(destinationPath, sourceContent, 'utf8');
557
- }
558
- return { status: destinationExists ? (dryRun ? 'would-update' : 'updated') : 'created', file: asset.destination };
389
+ const fixPayload = runFixInternal({
390
+ target: installPayload.repoRoot,
391
+ dryRun: options.dryRun,
392
+ force: options.force,
393
+ forceManagedPaths: options.forceManagedPaths,
394
+ dropStaleLocks: true,
395
+ skipAgents: options.skipAgents,
396
+ skipPackageJson: options.skipPackageJson,
397
+ skipGitignore: options.skipGitignore,
398
+ allowProtectedBaseWrite: options.allowProtectedBaseWrite,
399
+ });
400
+
401
+ return {
402
+ installPayload,
403
+ fixPayload,
404
+ parentWorkspace,
405
+ };
559
406
  }
560
407
 
561
- function removeLegacyManagedRepoFile(repoRoot, relativePath, options = {}) {
562
- const dryRun = Boolean(options.dryRun);
563
- const force = Boolean(options.force);
564
- const absolutePath = path.join(repoRoot, relativePath);
565
- if (!fs.existsSync(absolutePath)) {
566
- return { status: 'unchanged', file: relativePath, note: 'not present' };
567
- }
568
- if (!fs.statSync(absolutePath).isFile()) {
569
- return { status: 'skipped-conflict', file: relativePath, note: 'not a regular file' };
570
- }
408
+ function extractAgentBranchStartMetadata(output) {
409
+ const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m);
410
+ const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m);
411
+ return {
412
+ branch: branchMatch ? branchMatch[1].trim() : '',
413
+ worktreePath: worktreeMatch ? worktreeMatch[1].trim() : '',
414
+ };
415
+ }
571
416
 
572
- const skillAsset = USER_LEVEL_SKILL_ASSETS.find((asset) => asset.destination === relativePath);
573
- if (skillAsset) {
574
- const userLevelPath = path.join(GUARDEX_HOME_DIR, skillAsset.destination);
575
- if (!fs.existsSync(userLevelPath)) {
576
- return { status: 'skipped', file: relativePath, note: 'user-level replacement not installed' };
577
- }
417
+ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
418
+ const resolvedTarget = path.resolve(targetPath);
419
+ const relativeTarget = path.relative(repoRoot, resolvedTarget);
420
+ if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
421
+ throw new Error(`sandbox target must stay inside repo root: ${resolvedTarget}`);
578
422
  }
579
-
580
- const templateRelative = skillAsset
581
- ? skillAsset.source.slice(TEMPLATE_ROOT.length + 1)
582
- : relativePath.replace(/^\./, '');
583
- const sourcePath = path.join(TEMPLATE_ROOT, templateRelative);
584
- if (!fs.existsSync(sourcePath)) {
585
- return { status: 'skipped', file: relativePath, note: 'template source missing' };
423
+ if (!relativeTarget || relativeTarget === '.') {
424
+ return worktreePath;
586
425
  }
426
+ return path.join(worktreePath, relativeTarget);
427
+ }
587
428
 
588
- const sourceContent = fs.readFileSync(sourcePath, 'utf8');
589
- const existingContent = fs.readFileSync(absolutePath, 'utf8');
590
- if (existingContent !== sourceContent && !force) {
591
- return { status: 'skipped-conflict', file: relativePath, note: 'local edits differ from managed template' };
592
- }
429
+ function buildSandboxSetupArgs(options, sandboxTarget) {
430
+ const args = ['setup', '--target', sandboxTarget, '--no-global-install', '--no-recursive'];
431
+ appendForceArgs(args, options);
432
+ if (options.skipAgents) args.push('--skip-agents');
433
+ if (options.skipPackageJson) args.push('--skip-package-json');
434
+ if (options.skipGitignore) args.push('--no-gitignore');
435
+ if (options.dryRun) args.push('--dry-run');
436
+ return args;
437
+ }
593
438
 
594
- if (!dryRun) {
595
- fs.rmSync(absolutePath, { force: true });
596
- }
597
- return { status: dryRun ? 'would-remove' : 'removed', file: relativePath };
439
+ function isSpawnFailure(result) {
440
+ return Boolean(result?.error) && typeof result?.status !== 'number';
598
441
  }
599
442
 
600
- function ensureAgentsSnippet(repoRoot, dryRun, options = {}) {
601
- const agentsPath = path.join(repoRoot, 'AGENTS.md');
602
- const snippet = fs.readFileSync(path.join(TEMPLATE_ROOT, 'AGENTS.multiagent-safety.md'), 'utf8').trimEnd();
603
- const managedRegex = new RegExp(
604
- `${AGENTS_MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${AGENTS_MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
605
- 'm',
606
- );
443
+ function protectedBaseSandboxBranchPrefix() {
444
+ const now = new Date();
445
+ const stamp = [
446
+ now.getUTCFullYear(),
447
+ String(now.getUTCMonth() + 1).padStart(2, '0'),
448
+ String(now.getUTCDate()).padStart(2, '0'),
449
+ ].join('') + '-' + [
450
+ String(now.getUTCHours()).padStart(2, '0'),
451
+ String(now.getUTCMinutes()).padStart(2, '0'),
452
+ String(now.getUTCSeconds()).padStart(2, '0'),
453
+ ].join('');
454
+ return `agent/gx/${stamp}`;
455
+ }
607
456
 
608
- if (!fs.existsSync(agentsPath)) {
609
- if (!dryRun) {
610
- fs.writeFileSync(agentsPath, `# AGENTS\n\n${snippet}\n`, 'utf8');
611
- }
612
- return { status: 'created', file: 'AGENTS.md' };
613
- }
457
+ function protectedBaseSandboxWorktreePath(repoRoot, branchName) {
458
+ return path.join(repoRoot, defaultAgentWorktreeRelativeDir(), branchName.replace(/\//g, '__'));
459
+ }
614
460
 
615
- const existing = fs.readFileSync(agentsPath, 'utf8');
616
- if (managedRegex.test(existing)) {
617
- const next = existing.replace(managedRegex, snippet);
618
- if (next === existing) {
619
- return { status: 'unchanged', file: 'AGENTS.md' };
620
- }
621
- if (!dryRun) {
622
- fs.writeFileSync(agentsPath, next, 'utf8');
623
- }
624
- return { status: 'updated', file: 'AGENTS.md', note: 'refreshed gitguardex-managed block' };
461
+ function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) {
462
+ run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 });
463
+ if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) {
464
+ return `origin/${baseBranch}`;
625
465
  }
626
-
627
- if (existing.includes(AGENTS_MARKER_START)) {
628
- return { status: 'unchanged', file: 'AGENTS.md', note: 'existing marker found without managed end marker' };
466
+ if (gitRefExists(repoRoot, `refs/heads/${baseBranch}`)) {
467
+ return baseBranch;
629
468
  }
630
-
631
- const separator = existing.endsWith('\n') ? '\n' : '\n\n';
632
- if (!dryRun) {
633
- fs.writeFileSync(agentsPath, `${existing}${separator}${snippet}\n`, 'utf8');
469
+ if (currentBranchName(repoRoot) === baseBranch) {
470
+ return null;
634
471
  }
635
-
636
- return { status: 'updated', file: 'AGENTS.md' };
472
+ throw new Error(`Unable to find base ref for sandbox bootstrap: ${baseBranch}`);
637
473
  }
638
474
 
639
- function ensureManagedGitignore(repoRoot, dryRun) {
640
- const gitignorePath = path.join(repoRoot, '.gitignore');
641
- const managedBlock = [
642
- GITIGNORE_MARKER_START,
643
- ...MANAGED_GITIGNORE_PATHS,
644
- GITIGNORE_MARKER_END,
645
- ].join('\n');
646
- const managedRegex = new RegExp(
647
- `${GITIGNORE_MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${GITIGNORE_MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
648
- 'm',
649
- );
650
-
651
- if (!fs.existsSync(gitignorePath)) {
652
- if (!dryRun) {
653
- fs.writeFileSync(gitignorePath, `${managedBlock}\n`, 'utf8');
654
- }
655
- return { status: 'created', file: '.gitignore', note: 'added gitguardex-managed entries' };
656
- }
657
-
658
- const existing = fs.readFileSync(gitignorePath, 'utf8');
659
- if (managedRegex.test(existing)) {
660
- const next = existing.replace(managedRegex, managedBlock);
661
- if (next === existing) {
662
- return { status: 'unchanged', file: '.gitignore' };
663
- }
664
- if (!dryRun) {
665
- fs.writeFileSync(gitignorePath, next, 'utf8');
666
- }
667
- return { status: 'updated', file: '.gitignore', note: 'refreshed gitguardex-managed entries' };
668
- }
669
-
670
- const separator = existing.endsWith('\n') ? '\n' : '\n\n';
671
- if (!dryRun) {
672
- fs.writeFileSync(gitignorePath, `${existing}${separator}${managedBlock}\n`, 'utf8');
673
- }
674
- return { status: 'updated', file: '.gitignore', note: 'appended gitguardex-managed entries' };
675
- }
676
-
677
- function stripJsonComments(source) {
678
- let result = '';
679
- let inString = false;
680
- let escapeNext = false;
681
- let inLineComment = false;
682
- let inBlockComment = false;
683
-
684
- for (let index = 0; index < source.length; index += 1) {
685
- const current = source[index];
686
- const next = source[index + 1];
687
-
688
- if (inLineComment) {
689
- if (current === '\n' || current === '\r') {
690
- inLineComment = false;
691
- result += current;
692
- }
693
- continue;
694
- }
695
-
696
- if (inBlockComment) {
697
- if (current === '*' && next === '/') {
698
- inBlockComment = false;
699
- index += 1;
700
- continue;
701
- }
702
- if (current === '\n' || current === '\r') {
703
- result += current;
704
- }
705
- continue;
706
- }
707
-
708
- if (inString) {
709
- result += current;
710
- if (escapeNext) {
711
- escapeNext = false;
712
- } else if (current === '\\') {
713
- escapeNext = true;
714
- } else if (current === '"') {
715
- inString = false;
716
- }
717
- continue;
718
- }
719
-
720
- if (current === '"') {
721
- inString = true;
722
- result += current;
723
- continue;
724
- }
725
-
726
- if (current === '/' && next === '/') {
727
- inLineComment = true;
728
- index += 1;
729
- continue;
730
- }
731
-
732
- if (current === '/' && next === '*') {
733
- inBlockComment = true;
734
- index += 1;
735
- continue;
736
- }
737
-
738
- result += current;
739
- }
740
-
741
- return result;
742
- }
743
-
744
- function stripJsonTrailingCommas(source) {
745
- let result = '';
746
- let inString = false;
747
- let escapeNext = false;
748
-
749
- for (let index = 0; index < source.length; index += 1) {
750
- const current = source[index];
751
-
752
- if (inString) {
753
- result += current;
754
- if (escapeNext) {
755
- escapeNext = false;
756
- } else if (current === '\\') {
757
- escapeNext = true;
758
- } else if (current === '"') {
759
- inString = false;
760
- }
761
- continue;
762
- }
763
-
764
- if (current === '"') {
765
- inString = true;
766
- result += current;
767
- continue;
768
- }
769
-
770
- if (current === ',') {
771
- let lookahead = index + 1;
772
- while (lookahead < source.length && /\s/.test(source[lookahead])) {
773
- lookahead += 1;
774
- }
775
- if (source[lookahead] === '}' || source[lookahead] === ']') {
776
- continue;
777
- }
778
- }
779
-
780
- result += current;
781
- }
782
-
783
- return result;
784
- }
785
-
786
- function parseJsonObjectLikeFile(source, relativePath) {
787
- let parsed;
788
- try {
789
- parsed = JSON.parse(stripJsonTrailingCommas(stripJsonComments(source)));
790
- } catch (error) {
791
- throw new Error(`Unable to parse ${relativePath} as JSON or JSONC: ${error.message}`);
792
- }
793
-
794
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
795
- throw new Error(`${relativePath} must contain a top-level object.`);
796
- }
797
-
798
- return parsed;
799
- }
800
-
801
- function uniqueStringList(values) {
802
- const seen = new Set();
803
- const result = [];
804
-
805
- for (const value of values) {
806
- if (typeof value !== 'string' || seen.has(value)) {
807
- continue;
808
- }
809
- seen.add(value);
810
- result.push(value);
811
- }
812
-
813
- return result;
814
- }
815
-
816
- function buildRepoVscodeSettings(existingSettings = {}) {
817
- const nextSettings = { ...existingSettings };
818
- const existingIgnoredFolders = Array.isArray(existingSettings[REPO_SCAN_IGNORED_FOLDERS_SETTING])
819
- ? existingSettings[REPO_SCAN_IGNORED_FOLDERS_SETTING]
820
- : [];
821
-
822
- nextSettings[REPO_SCAN_IGNORED_FOLDERS_SETTING] = uniqueStringList([
823
- ...existingIgnoredFolders,
824
- ...MANAGED_REPO_SCAN_IGNORED_FOLDERS,
825
- ]);
826
-
827
- return nextSettings;
828
- }
829
-
830
- function ensureRepoVscodeSettings(repoRoot, dryRun) {
831
- const settingsPath = path.join(repoRoot, SHARED_VSCODE_SETTINGS_RELATIVE);
832
- const destinationExists = fs.existsSync(settingsPath);
833
- const existingContent = destinationExists ? fs.readFileSync(settingsPath, 'utf8') : '';
834
- const existingSettings = destinationExists
835
- ? parseJsonObjectLikeFile(existingContent, SHARED_VSCODE_SETTINGS_RELATIVE)
836
- : {};
837
- const nextContent = `${JSON.stringify(buildRepoVscodeSettings(existingSettings), null, 2)}\n`;
838
-
839
- if (destinationExists && existingContent === nextContent) {
840
- return { status: 'unchanged', file: SHARED_VSCODE_SETTINGS_RELATIVE };
841
- }
842
-
843
- ensureParentDir(repoRoot, settingsPath, dryRun);
844
- if (!dryRun) {
845
- fs.writeFileSync(settingsPath, nextContent, 'utf8');
846
- }
847
-
848
- return {
849
- status: destinationExists ? 'updated' : 'created',
850
- file: SHARED_VSCODE_SETTINGS_RELATIVE,
851
- note: 'shared VS Code repo scan ignores for Guardex worktrees',
852
- };
853
- }
854
-
855
- function configureHooks(repoRoot, dryRun) {
856
- if (dryRun) {
857
- return { status: 'would-set', key: 'core.hooksPath', value: '.githooks' };
858
- }
859
-
860
- const result = run('git', ['-C', repoRoot, 'config', 'core.hooksPath', '.githooks']);
861
- if (result.status !== 0) {
862
- throw new Error(`Failed to set git hooksPath: ${(result.stderr || '').trim()}`);
863
- }
864
-
865
- return { status: 'set', key: 'core.hooksPath', value: '.githooks' };
866
- }
867
-
868
- function appendForceArgs(args, options) {
869
- if (!options.force) {
870
- return;
871
- }
872
- args.push('--force');
873
- for (const managedPath of options.forceManagedPaths || []) {
874
- args.push(managedPath);
875
- }
876
- }
877
-
878
- function shouldForceManagedPath(options, relativePath) {
879
- if (!options.force) {
880
- return false;
881
- }
882
- const targetedPaths = Array.isArray(options.forceManagedPaths) ? options.forceManagedPaths : [];
883
- if (targetedPaths.length === 0) {
884
- return true;
885
- }
886
- const normalized = normalizeManagedForcePath(relativePath);
887
- return normalized !== null && targetedPaths.includes(normalized);
888
- }
889
-
890
- function normalizeWorkspacePath(relativePath) {
891
- return String(relativePath || '.').replace(/\\/g, '/');
892
- }
893
-
894
- function buildParentWorkspaceView(repoRoot) {
895
- const parentDir = path.dirname(repoRoot);
896
- const workspaceFileName = `${path.basename(repoRoot)}-branches.code-workspace`;
897
- const workspacePath = path.join(parentDir, workspaceFileName);
898
- const repoRelativePath = normalizeWorkspacePath(path.relative(parentDir, repoRoot) || '.');
899
-
900
- return {
901
- workspacePath,
902
- payload: {
903
- folders: [
904
- { path: repoRelativePath },
905
- ...AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => ({
906
- path: normalizeWorkspacePath(path.join(repoRelativePath === '.' ? '' : repoRelativePath, relativeDir)),
907
- })),
908
- ],
909
- settings: {
910
- 'scm.alwaysShowRepositories': true,
911
- },
912
- },
913
- };
914
- }
915
-
916
- function ensureParentWorkspaceView(repoRoot, dryRun) {
917
- const { workspacePath, payload } = buildParentWorkspaceView(repoRoot);
918
- const operationFile = path.relative(repoRoot, workspacePath) || path.basename(workspacePath);
919
- const nextContent = `${JSON.stringify(payload, null, 2)}\n`;
920
- const note = 'parent VS Code workspace view';
921
-
922
- if (!fs.existsSync(workspacePath)) {
923
- if (!dryRun) {
924
- fs.writeFileSync(workspacePath, nextContent, 'utf8');
925
- }
926
- return { status: dryRun ? 'would-create' : 'created', file: operationFile, note };
927
- }
928
-
929
- const currentContent = fs.readFileSync(workspacePath, 'utf8');
930
- if (currentContent === nextContent) {
931
- return { status: 'unchanged', file: operationFile, note };
932
- }
933
-
934
- if (!dryRun) {
935
- fs.writeFileSync(workspacePath, nextContent, 'utf8');
936
- }
937
- return { status: dryRun ? 'would-update' : 'updated', file: operationFile, note };
938
- }
939
-
940
- function hasGuardexBootstrapFiles(repoRoot) {
941
- const required = [
942
- 'AGENTS.md',
943
- '.githooks/pre-commit',
944
- '.githooks/pre-push',
945
- LOCK_FILE_RELATIVE,
946
- ];
947
- return required.every((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
948
- }
949
-
950
- function protectedBaseWriteBlock(options, { requireBootstrap = true } = {}) {
951
- if (options.dryRun || options.allowProtectedBaseWrite) {
952
- return null;
953
- }
954
-
955
- const repoRoot = resolveRepoRoot(options.target);
956
- if (requireBootstrap && !hasGuardexBootstrapFiles(repoRoot)) {
957
- return null;
958
- }
959
-
960
- const branch = currentBranchName(repoRoot);
961
- if (branch !== 'main') {
962
- return null;
963
- }
964
-
965
- const protectedBranches = readProtectedBranches(repoRoot);
966
- if (!protectedBranches.includes(branch)) {
967
- return null;
968
- }
969
-
970
- return {
971
- repoRoot,
972
- branch,
973
- };
974
- }
975
-
976
- function assertProtectedMainWriteAllowed(options, commandName) {
977
- return getSandboxApi().assertProtectedMainWriteAllowed(options, commandName);
978
- }
979
-
980
- function runSetupBootstrapInternal(options) {
981
- return getSandboxApi().runSetupBootstrapInternal(options);
982
- }
983
-
984
- function extractAgentBranchStartMetadata(output) {
985
- const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m);
986
- const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m);
987
- return {
988
- branch: branchMatch ? branchMatch[1].trim() : '',
989
- worktreePath: worktreeMatch ? worktreeMatch[1].trim() : '',
990
- };
991
- }
992
-
993
- function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
994
- const resolvedTarget = path.resolve(targetPath);
995
- const relativeTarget = path.relative(repoRoot, resolvedTarget);
996
- if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
997
- throw new Error(`sandbox target must stay inside repo root: ${resolvedTarget}`);
998
- }
999
- if (!relativeTarget || relativeTarget === '.') {
1000
- return worktreePath;
1001
- }
1002
- return path.join(worktreePath, relativeTarget);
1003
- }
1004
-
1005
- function buildSandboxSetupArgs(options, sandboxTarget) {
1006
- const args = ['setup', '--target', sandboxTarget, '--no-global-install', '--no-recursive'];
1007
- appendForceArgs(args, options);
1008
- if (options.skipAgents) args.push('--skip-agents');
1009
- if (options.skipPackageJson) args.push('--skip-package-json');
1010
- if (options.skipGitignore) args.push('--no-gitignore');
1011
- if (options.dryRun) args.push('--dry-run');
1012
- return args;
1013
- }
1014
-
1015
- function buildSandboxDoctorArgs(options, sandboxTarget) {
1016
- const args = ['doctor', '--target', sandboxTarget];
1017
- if (options.dryRun) args.push('--dry-run');
1018
- appendForceArgs(args, options);
1019
- if (options.skipAgents) args.push('--skip-agents');
1020
- if (options.skipPackageJson) args.push('--skip-package-json');
1021
- if (options.skipGitignore) args.push('--no-gitignore');
1022
- if (!options.dropStaleLocks) args.push('--keep-stale-locks');
1023
- args.push(options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge');
1024
- if (options.verboseAutoFinish) args.push('--verbose-auto-finish');
1025
- if (options.json) args.push('--json');
1026
- return args;
1027
- }
1028
-
1029
- function isSpawnFailure(result) {
1030
- return Boolean(result?.error) && typeof result?.status !== 'number';
1031
- }
1032
-
1033
- function ensureRepoBranch(repoRoot, branch) {
1034
- const current = currentBranchName(repoRoot);
1035
- if (current === branch) {
1036
- return { ok: true, changed: false };
1037
- }
1038
-
1039
- const checkoutResult = run('git', ['-C', repoRoot, 'checkout', branch], { timeout: 20_000 });
1040
- if (isSpawnFailure(checkoutResult)) {
1041
- return {
1042
- ok: false,
1043
- changed: false,
1044
- stdout: checkoutResult.stdout || '',
1045
- stderr: checkoutResult.stderr || '',
1046
- };
1047
- }
1048
- if (checkoutResult.status !== 0) {
1049
- return {
1050
- ok: false,
1051
- changed: false,
1052
- stdout: checkoutResult.stdout || '',
1053
- stderr: checkoutResult.stderr || '',
1054
- };
1055
- }
1056
-
1057
- return { ok: true, changed: true };
1058
- }
1059
-
1060
- function protectedBaseSandboxBranchPrefix() {
1061
- const now = new Date();
1062
- const stamp = [
1063
- now.getUTCFullYear(),
1064
- String(now.getUTCMonth() + 1).padStart(2, '0'),
1065
- String(now.getUTCDate()).padStart(2, '0'),
1066
- ].join('') + '-' + [
1067
- String(now.getUTCHours()).padStart(2, '0'),
1068
- String(now.getUTCMinutes()).padStart(2, '0'),
1069
- String(now.getUTCSeconds()).padStart(2, '0'),
1070
- ].join('');
1071
- return `agent/gx/${stamp}`;
1072
- }
1073
-
1074
- function protectedBaseSandboxWorktreePath(repoRoot, branchName) {
1075
- return path.join(repoRoot, defaultAgentWorktreeRelativeDir(), branchName.replace(/\//g, '__'));
1076
- }
1077
-
1078
- function gitRefExists(repoRoot, ref) {
1079
- return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0;
1080
- }
1081
-
1082
- function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) {
1083
- run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 });
1084
- if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) {
1085
- return `origin/${baseBranch}`;
1086
- }
1087
- if (gitRefExists(repoRoot, `refs/heads/${baseBranch}`)) {
1088
- return baseBranch;
1089
- }
1090
- if (currentBranchName(repoRoot) === baseBranch) {
1091
- return null;
1092
- }
1093
- throw new Error(`Unable to find base ref for sandbox bootstrap: ${baseBranch}`);
1094
- }
1095
-
1096
- function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
1097
- const branchPrefix = protectedBaseSandboxBranchPrefix();
1098
- let selectedBranch = '';
1099
- let selectedWorktreePath = '';
1100
-
1101
- for (let attempt = 0; attempt < 30; attempt += 1) {
1102
- const suffix = attempt === 0 ? sandboxSuffix : `${attempt + 1}-${sandboxSuffix}`;
1103
- const candidateBranch = `${branchPrefix}-${suffix}`;
1104
- const candidateWorktreePath = protectedBaseSandboxWorktreePath(blocked.repoRoot, candidateBranch);
1105
- if (gitRefExists(blocked.repoRoot, `refs/heads/${candidateBranch}`)) {
1106
- continue;
1107
- }
1108
- if (fs.existsSync(candidateWorktreePath)) {
1109
- continue;
1110
- }
1111
- selectedBranch = candidateBranch;
1112
- selectedWorktreePath = candidateWorktreePath;
1113
- break;
1114
- }
1115
-
1116
- if (!selectedBranch || !selectedWorktreePath) {
1117
- throw new Error('Unable to allocate unique sandbox branch/worktree');
1118
- }
1119
-
1120
- fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true });
1121
- const startRef = resolveProtectedBaseSandboxStartRef(blocked.repoRoot, blocked.branch);
1122
- const addArgs = startRef
1123
- ? ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef]
1124
- : ['-C', blocked.repoRoot, 'worktree', 'add', '--orphan', selectedWorktreePath];
1125
- const addResult = run('git', addArgs);
1126
- if (isSpawnFailure(addResult)) {
1127
- throw addResult.error;
1128
- }
1129
- if (addResult.status !== 0) {
1130
- throw new Error((addResult.stderr || addResult.stdout || 'failed to create sandbox').trim());
1131
- }
1132
-
1133
- if (!startRef) {
1134
- const renameResult = run(
1135
- 'git',
1136
- ['-C', selectedWorktreePath, 'branch', '-m', selectedBranch],
1137
- { timeout: 20_000 },
1138
- );
1139
- if (isSpawnFailure(renameResult)) {
1140
- throw renameResult.error;
1141
- }
1142
- if (renameResult.status !== 0) {
1143
- throw new Error(
1144
- (renameResult.stderr || renameResult.stdout || 'failed to name orphan sandbox branch').trim(),
1145
- );
1146
- }
1147
- }
1148
-
1149
- return {
1150
- metadata: {
1151
- branch: selectedBranch,
1152
- worktreePath: selectedWorktreePath,
1153
- },
1154
- stdout:
1155
- `[agent-branch-start] Created branch: ${selectedBranch}\n` +
1156
- `[agent-branch-start] Worktree: ${selectedWorktreePath}\n`,
1157
- stderr: addResult.stderr || '',
1158
- };
1159
- }
1160
-
1161
- function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) {
1162
- if (sandboxSuffix === 'gx-doctor') {
1163
- return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
1164
- }
1165
-
1166
- const startResult = runPackageAsset('branchStart', [
1167
- '--task',
1168
- taskName,
1169
- '--agent',
1170
- SHORT_TOOL_NAME,
1171
- '--base',
1172
- blocked.branch,
1173
- ], { cwd: blocked.repoRoot });
1174
- if (isSpawnFailure(startResult)) {
1175
- throw startResult.error;
1176
- }
1177
- if (startResult.status !== 0) {
1178
- return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
1179
- }
1180
-
1181
- const metadata = extractAgentBranchStartMetadata(startResult.stdout);
1182
- const currentBranch = currentBranchName(blocked.repoRoot);
1183
- const worktreePath = metadata.worktreePath ? path.resolve(metadata.worktreePath) : '';
1184
- const repoRootPath = path.resolve(blocked.repoRoot);
1185
- const hasSafeWorktree = Boolean(worktreePath) && worktreePath !== repoRootPath;
1186
- const branchChanged = Boolean(currentBranch) && currentBranch !== blocked.branch;
1187
-
1188
- if (!hasSafeWorktree || branchChanged) {
1189
- const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
1190
- if (!restoreResult.ok) {
1191
- const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim();
1192
- throw new Error(
1193
- `sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
1194
- (detail ? `\n${detail}` : ''),
1195
- );
1196
- }
1197
- return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
1198
- }
1199
-
1200
- return {
1201
- metadata,
1202
- stdout: startResult.stdout || '',
1203
- stderr: startResult.stderr || '',
1204
- };
1205
- }
1206
-
1207
- function cleanupProtectedBaseSandbox(repoRoot, metadata) {
1208
- const result = {
1209
- worktree: 'skipped',
1210
- branch: 'skipped',
1211
- note: 'missing sandbox metadata',
1212
- };
1213
-
1214
- if (!metadata?.worktreePath || !metadata?.branch) {
1215
- return result;
1216
- }
1217
-
1218
- if (fs.existsSync(metadata.worktreePath)) {
1219
- const removeResult = run(
1220
- 'git',
1221
- ['-C', repoRoot, 'worktree', 'remove', '--force', metadata.worktreePath],
1222
- { timeout: 30_000 },
1223
- );
1224
- if (isSpawnFailure(removeResult)) {
1225
- throw removeResult.error;
1226
- }
1227
- if (removeResult.status !== 0) {
1228
- throw new Error(
1229
- (removeResult.stderr || removeResult.stdout || 'failed to remove sandbox worktree').trim(),
1230
- );
1231
- }
1232
- result.worktree = 'removed';
1233
- } else {
1234
- result.worktree = 'missing';
1235
- }
1236
-
1237
- if (gitRefExists(repoRoot, `refs/heads/${metadata.branch}`)) {
1238
- const branchDeleteResult = run(
1239
- 'git',
1240
- ['-C', repoRoot, 'branch', '-D', metadata.branch],
1241
- { timeout: 20_000 },
1242
- );
1243
- if (isSpawnFailure(branchDeleteResult)) {
1244
- throw branchDeleteResult.error;
1245
- }
1246
- if (branchDeleteResult.status !== 0) {
1247
- throw new Error(
1248
- (branchDeleteResult.stderr || branchDeleteResult.stdout || 'failed to delete sandbox branch').trim(),
1249
- );
1250
- }
1251
- result.branch = 'deleted';
1252
- } else {
1253
- result.branch = 'missing';
1254
- }
1255
-
1256
- result.note = 'sandbox worktree pruned';
1257
- return result;
1258
- }
1259
-
1260
- function parseGitPathList(output) {
1261
- return String(output || '')
1262
- .split('\n')
1263
- .map((line) => line.trim())
1264
- .filter((line) => line && line !== LOCK_FILE_RELATIVE);
1265
- }
1266
-
1267
- function collectDoctorChangedPaths(worktreePath) {
1268
- const changed = new Set();
1269
- const commands = [
1270
- ['diff', '--name-only'],
1271
- ['diff', '--cached', '--name-only'],
1272
- ['ls-files', '--others', '--exclude-standard'],
1273
- ];
1274
- for (const gitArgs of commands) {
1275
- const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
1276
- for (const filePath of parseGitPathList(result.stdout)) {
1277
- changed.add(filePath);
1278
- }
1279
- }
1280
- return Array.from(changed);
1281
- }
1282
-
1283
- function collectDoctorDeletedPaths(worktreePath) {
1284
- const deleted = new Set();
1285
- const commands = [
1286
- ['diff', '--name-only', '--diff-filter=D'],
1287
- ['diff', '--cached', '--name-only', '--diff-filter=D'],
1288
- ];
1289
- for (const gitArgs of commands) {
1290
- const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
1291
- for (const filePath of parseGitPathList(result.stdout)) {
1292
- deleted.add(filePath);
1293
- }
1294
- }
1295
- return Array.from(deleted);
1296
- }
1297
-
1298
- function collectWorktreeDirtyPaths(worktreePath) {
1299
- const dirty = new Set();
1300
- const commands = [
1301
- ['diff', '--name-only'],
1302
- ['diff', '--cached', '--name-only'],
1303
- ['ls-files', '--others', '--exclude-standard'],
1304
- ];
1305
- for (const gitArgs of commands) {
1306
- const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
1307
- for (const filePath of parseGitPathList(result.stdout)) {
1308
- dirty.add(filePath);
1309
- }
1310
- }
1311
- return Array.from(dirty);
1312
- }
1313
-
1314
- function collectDoctorForceAddPaths(worktreePath) {
1315
- return REQUIRED_MANAGED_REPO_FILES
1316
- .filter((relativePath) => relativePath.startsWith('scripts/') || relativePath.startsWith('.githooks/'))
1317
- .filter((relativePath) => fs.existsSync(path.join(worktreePath, relativePath)));
1318
- }
1319
-
1320
- function stripDoctorSandboxLocks(rawContent, branchName) {
1321
- if (!rawContent || !branchName) {
1322
- return rawContent;
1323
- }
1324
- try {
1325
- const parsed = JSON.parse(rawContent);
1326
- const locks = parsed && typeof parsed === 'object' && parsed.locks && typeof parsed.locks === 'object'
1327
- ? parsed.locks
1328
- : null;
1329
- if (!locks) {
1330
- return rawContent;
1331
- }
1332
- let changed = false;
1333
- const filteredLocks = {};
1334
- for (const [filePath, lockInfo] of Object.entries(locks)) {
1335
- if (lockInfo && lockInfo.branch === branchName) {
1336
- changed = true;
1337
- continue;
1338
- }
1339
- filteredLocks[filePath] = lockInfo;
1340
- }
1341
- if (!changed) {
1342
- return rawContent;
1343
- }
1344
- return `${JSON.stringify({ ...parsed, locks: filteredLocks }, null, 2)}\n`;
1345
- } catch {
1346
- return rawContent;
1347
- }
1348
- }
1349
-
1350
- function claimDoctorChangedLocks(metadata) {
1351
- if (!metadata.branch) {
1352
- return {
1353
- status: 'skipped',
1354
- note: 'missing sandbox branch metadata',
1355
- changedCount: 0,
1356
- deletedCount: 0,
1357
- };
1358
- }
1359
-
1360
- const changedPaths = Array.from(new Set([
1361
- ...collectDoctorChangedPaths(metadata.worktreePath),
1362
- ...collectDoctorForceAddPaths(metadata.worktreePath),
1363
- ]));
1364
- const deletedPaths = collectDoctorDeletedPaths(metadata.worktreePath);
1365
- if (changedPaths.length > 0) {
1366
- runPackageAsset('lockTool', ['claim', '--branch', metadata.branch, ...changedPaths], {
1367
- cwd: metadata.worktreePath,
1368
- timeout: 30_000,
1369
- });
1370
- }
1371
- if (deletedPaths.length > 0) {
1372
- runPackageAsset('lockTool', ['allow-delete', '--branch', metadata.branch, ...deletedPaths], {
1373
- cwd: metadata.worktreePath,
1374
- timeout: 30_000,
1375
- });
1376
- }
1377
-
1378
- return {
1379
- status: 'claimed',
1380
- note: 'claimed locks for doctor auto-commit',
1381
- changedCount: changedPaths.length,
1382
- deletedCount: deletedPaths.length,
1383
- };
1384
- }
1385
-
1386
- function autoCommitDoctorSandboxChanges(metadata) {
1387
- if (!metadata.worktreePath || !metadata.branch) {
1388
- return {
1389
- status: 'skipped',
1390
- note: 'missing sandbox branch metadata',
1391
- };
1392
- }
1393
-
1394
- claimDoctorChangedLocks(metadata);
1395
- run(
1396
- 'git',
1397
- ['-C', metadata.worktreePath, 'add', '-A', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
1398
- { timeout: 20_000 },
1399
- );
1400
- const forceAddPaths = collectDoctorForceAddPaths(metadata.worktreePath);
1401
- if (forceAddPaths.length > 0) {
1402
- run(
1403
- 'git',
1404
- ['-C', metadata.worktreePath, 'add', '-f', '--', ...forceAddPaths],
1405
- { timeout: 20_000 },
1406
- );
1407
- }
1408
- const staged = run(
1409
- 'git',
1410
- ['-C', metadata.worktreePath, 'diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
1411
- { timeout: 20_000 },
1412
- );
1413
- const stagedFiles = parseGitPathList(staged.stdout);
1414
- if (stagedFiles.length === 0) {
1415
- return {
1416
- status: 'no-changes',
1417
- note: 'no committable doctor changes found in sandbox',
1418
- };
1419
- }
1420
-
1421
- const commitResult = run(
1422
- 'git',
1423
- ['-C', metadata.worktreePath, 'commit', '-m', 'Auto-finish: gx doctor repairs'],
1424
- { timeout: 30_000 },
1425
- );
1426
- if (commitResult.status !== 0) {
1427
- return {
1428
- status: 'failed',
1429
- note: 'doctor sandbox auto-commit failed',
1430
- stdout: commitResult.stdout || '',
1431
- stderr: commitResult.stderr || '',
1432
- };
1433
- }
1434
-
1435
- return {
1436
- status: 'committed',
1437
- note: 'doctor sandbox repairs committed',
1438
- commitMessage: 'Auto-finish: gx doctor repairs',
1439
- stagedFiles,
1440
- };
1441
- }
1442
-
1443
- function hasOriginRemote(repoRoot) {
1444
- return run('git', ['-C', repoRoot, 'remote', 'get-url', 'origin']).status === 0;
1445
- }
1446
-
1447
- function originRemoteLooksLikeGithub(repoRoot) {
1448
- const originUrl = readGitConfig(repoRoot, 'remote.origin.url');
1449
- if (!originUrl) {
1450
- return false;
1451
- }
1452
- return /github\.com[:/]/i.test(originUrl);
1453
- }
1454
-
1455
- function isCommandAvailable(commandName) {
1456
- return run('which', [commandName]).status === 0;
1457
- }
1458
-
1459
- function extractAgentBranchFinishPrUrl(output) {
1460
- const match = String(output || '').match(/\[agent-branch-finish\] PR:\s*(\S+)/);
1461
- return match ? match[1] : '';
1462
- }
1463
-
1464
- function doctorFinishFlowIsPending(output) {
1465
- return (
1466
- /\[agent-branch-finish\] PR merge not completed yet; leaving PR open\./.test(output) ||
1467
- /\[agent-branch-finish\] Merge pending review\/check policy\. Branch cleanup skipped for now\./.test(output) ||
1468
- /\[agent-branch-finish\] PR auto-merge enabled; waiting for required checks\/reviews\./.test(output)
1469
- );
1470
- }
1471
-
1472
- function finishDoctorSandboxBranch(blocked, metadata, options = {}) {
1473
- if (!hasOriginRemote(blocked.repoRoot)) {
1474
- return {
1475
- status: 'skipped',
1476
- note: 'origin remote missing; skipped auto-finish',
1477
- };
1478
- }
1479
- const explicitGhBin = Boolean(String(process.env.GUARDEX_GH_BIN || '').trim());
1480
- if (!explicitGhBin && !originRemoteLooksLikeGithub(blocked.repoRoot)) {
1481
- return {
1482
- status: 'skipped',
1483
- note: 'origin remote is not GitHub; skipped auto-finish PR flow',
1484
- };
1485
- }
1486
-
1487
- const ghBin = process.env.GUARDEX_GH_BIN || 'gh';
1488
- if (!isCommandAvailable(ghBin)) {
1489
- return {
1490
- status: 'skipped',
1491
- note: `'${ghBin}' not available; skipped auto-finish PR flow`,
1492
- };
1493
- }
1494
- const ghAuthStatus = run(ghBin, ['auth', 'status'], { timeout: 20_000 });
1495
- if (ghAuthStatus.status !== 0) {
1496
- return {
1497
- status: 'skipped',
1498
- note: `'${ghBin}' auth unavailable; skipped auto-finish PR flow`,
1499
- stderr: ghAuthStatus.stderr || '',
1500
- };
1501
- }
1502
-
1503
- const rawWaitTimeoutSeconds = Number.parseInt(process.env.GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS || '1800', 10);
1504
- const waitTimeoutSeconds =
1505
- Number.isFinite(rawWaitTimeoutSeconds) && rawWaitTimeoutSeconds >= 30 ? rawWaitTimeoutSeconds : 1800;
1506
- const finishTimeoutMs = Math.max(180_000, (waitTimeoutSeconds + 60) * 1000);
1507
- const waitForMergeArg = options.waitForMerge === false ? '--no-wait-for-merge' : '--wait-for-merge';
1508
-
1509
- const finishResult = runPackageAsset(
1510
- 'branchFinish',
1511
- ['--branch', metadata.branch, '--base', blocked.branch, '--via-pr', waitForMergeArg, '--cleanup'],
1512
- { cwd: metadata.worktreePath, timeout: finishTimeoutMs },
1513
- );
1514
- if (isSpawnFailure(finishResult)) {
1515
- return {
1516
- status: 'failed',
1517
- note: 'doctor sandbox finish flow errored',
1518
- stdout: finishResult.stdout || '',
1519
- stderr: finishResult.stderr || '',
1520
- };
1521
- }
1522
- if (finishResult.status !== 0) {
1523
- return {
1524
- status: 'failed',
1525
- note: 'doctor sandbox finish flow failed',
1526
- stdout: finishResult.stdout || '',
1527
- stderr: finishResult.stderr || '',
1528
- };
1529
- }
1530
-
1531
- const combinedOutput = `${finishResult.stdout || ''}\n${finishResult.stderr || ''}`;
1532
- if (doctorFinishFlowIsPending(combinedOutput)) {
1533
- return {
1534
- status: 'pending',
1535
- note: 'PR created and waiting for merge policy/checks',
1536
- prUrl: extractAgentBranchFinishPrUrl(combinedOutput),
1537
- stdout: finishResult.stdout || '',
1538
- stderr: finishResult.stderr || '',
1539
- };
1540
- }
1541
-
1542
- return {
1543
- status: 'completed',
1544
- note: 'doctor sandbox finish flow completed',
1545
- stdout: finishResult.stdout || '',
1546
- stderr: finishResult.stderr || '',
1547
- };
1548
- }
1549
-
1550
- function mergeDoctorSandboxRepairsBackToProtectedBase(options, blocked, metadata, autoCommitResult, finishResult) {
1551
- if (options.dryRun) {
1552
- return {
1553
- status: autoCommitResult.status === 'committed' ? 'would-merge' : 'skipped',
1554
- note: autoCommitResult.status === 'committed'
1555
- ? 'dry run: would fast-forward tracked doctor repairs into the protected base workspace'
1556
- : 'dry run skips tracked repair merge',
1557
- };
1558
- }
1559
-
1560
- if (autoCommitResult.status !== 'committed') {
1561
- return {
1562
- status: autoCommitResult.status === 'no-changes' ? 'unchanged' : 'skipped',
1563
- note: autoCommitResult.status === 'no-changes'
1564
- ? 'no tracked doctor repairs needed in the protected base workspace'
1565
- : 'tracked doctor repair merge skipped',
1566
- };
1567
- }
1568
-
1569
- if (finishResult.status !== 'skipped') {
1570
- return {
1571
- status: 'skipped',
1572
- note: finishResult.status === 'failed'
1573
- ? 'tracked doctor repairs remain in the sandbox after finish failure'
1574
- : 'tracked doctor repairs are being delivered through the sandbox finish flow',
1575
- };
1576
- }
1577
-
1578
- const allowedPaths = new Set([
1579
- ...(autoCommitResult.stagedFiles || []),
1580
- ...OMX_SCAFFOLD_DIRECTORIES,
1581
- ...Array.from(OMX_SCAFFOLD_FILES.keys()),
1582
- ...REQUIRED_MANAGED_REPO_FILES,
1583
- 'bin',
1584
- 'package.json',
1585
- '.gitignore',
1586
- 'AGENTS.md',
1587
- ]);
1588
- const dirtyPaths = collectWorktreeDirtyPaths(blocked.repoRoot);
1589
- let stashRef = '';
1590
- if (dirtyPaths.length > 0) {
1591
- const unexpectedPaths = dirtyPaths.filter((filePath) => {
1592
- if (allowedPaths.has(filePath)) {
1593
- return false;
1594
- }
1595
- return !AGENT_WORKTREE_RELATIVE_DIRS.some(
1596
- (relativeDir) => filePath === relativeDir || filePath.startsWith(`${relativeDir}/`),
1597
- );
1598
- });
1599
- if (unexpectedPaths.length > 0) {
1600
- return {
1601
- status: 'failed',
1602
- note: `protected branch workspace has unrelated local changes: ${unexpectedPaths.join(', ')}`,
1603
- };
1604
- }
1605
- const stashMessage = `guardex-doctor-merge-${Date.now()}`;
1606
- const stashResult = run(
1607
- 'git',
1608
- ['-C', blocked.repoRoot, 'stash', 'push', '--all', '--message', stashMessage],
1609
- { timeout: 30_000 },
1610
- );
1611
- if (isSpawnFailure(stashResult)) {
1612
- return {
1613
- status: 'failed',
1614
- note: 'could not stash protected branch doctor drift before merge',
1615
- stdout: stashResult.stdout || '',
1616
- stderr: stashResult.stderr || '',
1617
- };
1618
- }
1619
- if (stashResult.status !== 0) {
1620
- return {
1621
- status: 'failed',
1622
- note: 'stashing protected branch doctor drift failed',
1623
- stdout: stashResult.stdout || '',
1624
- stderr: stashResult.stderr || '',
1625
- };
1626
- }
1627
-
1628
- const stashLookup = run(
1629
- 'git',
1630
- ['-C', blocked.repoRoot, 'stash', 'list'],
1631
- { timeout: 20_000 },
1632
- );
1633
- stashRef = String(stashLookup.stdout || '')
1634
- .split('\n')
1635
- .find((line) => line.includes(stashMessage))
1636
- ?.split(':')[0]
1637
- ?.trim() || '';
1638
- }
1639
-
1640
- const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
1641
- if (!restoreResult.ok) {
1642
- if (stashRef) {
1643
- run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
1644
- }
1645
- return {
1646
- status: 'failed',
1647
- note: `could not restore protected branch '${blocked.branch}' before applying sandbox repairs`,
1648
- stdout: restoreResult.stdout || '',
1649
- stderr: restoreResult.stderr || '',
1650
- };
1651
- }
1652
-
1653
- const mergeResult = run(
1654
- 'git',
1655
- ['-C', blocked.repoRoot, 'merge', '--ff-only', metadata.branch],
1656
- { timeout: 30_000 },
1657
- );
1658
- if (isSpawnFailure(mergeResult)) {
1659
- if (stashRef) {
1660
- run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
1661
- }
1662
- return {
1663
- status: 'failed',
1664
- note: 'tracked doctor repair merge errored',
1665
- stdout: mergeResult.stdout || '',
1666
- stderr: mergeResult.stderr || '',
1667
- };
1668
- }
1669
- if (mergeResult.status !== 0) {
1670
- if (stashRef) {
1671
- run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
1672
- }
1673
- return {
1674
- status: 'failed',
1675
- note: 'tracked doctor repair merge failed',
1676
- stdout: mergeResult.stdout || '',
1677
- stderr: mergeResult.stderr || '',
1678
- };
1679
- }
1680
-
1681
- let cleanupResult;
1682
- try {
1683
- cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
1684
- } catch (error) {
1685
- return {
1686
- status: 'failed',
1687
- note: `tracked doctor repair merge succeeded but sandbox cleanup failed: ${error.message}`,
1688
- stdout: mergeResult.stdout || '',
1689
- stderr: mergeResult.stderr || '',
1690
- };
1691
- }
1692
-
1693
- let hookRefreshResult;
1694
- try {
1695
- hookRefreshResult = configureHooks(blocked.repoRoot, false);
1696
- } catch (error) {
1697
- return {
1698
- status: 'failed',
1699
- note: `tracked doctor repair merge succeeded but local hook refresh failed: ${error.message}`,
1700
- stdout: mergeResult.stdout || '',
1701
- stderr: mergeResult.stderr || '',
1702
- };
1703
- }
1704
-
1705
- if (stashRef) {
1706
- run('git', ['-C', blocked.repoRoot, 'stash', 'drop', stashRef], { timeout: 20_000 });
1707
- }
1708
-
1709
- return {
1710
- status: 'merged',
1711
- note: 'fast-forwarded tracked doctor repairs into the protected base workspace',
1712
- stdout: mergeResult.stdout || '',
1713
- stderr: mergeResult.stderr || '',
1714
- cleanup: cleanupResult,
1715
- hookRefresh: hookRefreshResult,
1716
- };
1717
- }
1718
-
1719
- /**
1720
- * @param {string} [note]
1721
- * @returns {OperationResult}
1722
- */
1723
- function createDoctorSkippedOperation(note = 'sandbox doctor did not complete successfully') {
1724
- return {
1725
- status: 'skipped',
1726
- note,
1727
- };
1728
- }
1729
-
1730
- /**
1731
- * @param {string} [note]
1732
- * @returns {AutoFinishSummary}
1733
- */
1734
- function createSkippedDoctorAutoFinishSummary(note = 'sandbox doctor did not complete successfully') {
1735
- return {
1736
- enabled: false,
1737
- attempted: 0,
1738
- completed: 0,
1739
- skipped: 0,
1740
- failed: 0,
1741
- details: [`Skipped auto-finish sweep (${note}).`],
1742
- };
1743
- }
1744
-
1745
- /**
1746
- * Default the lifecycle to skipped states until the nested doctor run succeeds.
1747
- *
1748
- * @param {string} [note]
1749
- * @returns {DoctorSandboxExecution}
1750
- */
1751
- function createDoctorSandboxExecutionState(note = 'sandbox doctor did not complete successfully') {
1752
- return {
1753
- autoCommit: createDoctorSkippedOperation(note),
1754
- finish: createDoctorSkippedOperation(note),
1755
- protectedBaseRepairSync: createDoctorSkippedOperation(note),
1756
- lockSync: createDoctorSkippedOperation(note),
1757
- omxScaffoldSync: createDoctorSkippedOperation(note),
1758
- autoFinish: createSkippedDoctorAutoFinishSummary(note),
1759
- sandboxLockContent: null,
1760
- };
1761
- }
1762
-
1763
- /**
1764
- * @param {string} repoRoot
1765
- * @param {boolean} dryRun
1766
- * @returns {OperationResult}
1767
- */
1768
- function summarizeDoctorOmxScaffoldSync(repoRoot, dryRun) {
1769
- const omxScaffoldOps = ensureOmxScaffold(repoRoot, dryRun);
1770
- const changedOmxPaths = omxScaffoldOps.filter((operation) => operation.status !== 'unchanged');
1771
- if (changedOmxPaths.length === 0) {
1772
- return {
1773
- status: 'unchanged',
1774
- note: '.omx scaffold already in sync',
1775
- operations: omxScaffoldOps,
1776
- };
1777
- }
1778
- return {
1779
- status: dryRun ? 'would-sync' : 'synced',
1780
- note: `${dryRun ? 'would sync' : 'synced'} ${changedOmxPaths.length} .omx path(s)`,
1781
- operations: omxScaffoldOps,
1782
- };
1783
- }
1784
-
1785
- /**
1786
- * @param {string} repoRoot
1787
- * @param {SandboxMetadata} metadata
1788
- * @returns {DoctorLockSyncState}
1789
- */
1790
- function syncDoctorLockRegistryBeforeMerge(repoRoot, metadata) {
1791
- const sandboxLockPath = path.join(metadata.worktreePath, LOCK_FILE_RELATIVE);
1792
- const baseLockPath = path.join(repoRoot, LOCK_FILE_RELATIVE);
1793
- if (!fs.existsSync(baseLockPath)) {
1794
- return {
1795
- result: {
1796
- status: 'skipped',
1797
- note: `${LOCK_FILE_RELATIVE} missing in protected base workspace`,
1798
- },
1799
- sandboxLockContent: null,
1800
- };
1801
- }
1802
- if (!fs.existsSync(sandboxLockPath)) {
1803
- return {
1804
- result: {
1805
- status: 'skipped',
1806
- note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
1807
- },
1808
- sandboxLockContent: null,
1809
- };
1810
- }
1811
-
1812
- const sourceContent = stripDoctorSandboxLocks(
1813
- fs.readFileSync(sandboxLockPath, 'utf8'),
1814
- metadata.branch,
1815
- );
1816
- const destinationContent = fs.readFileSync(baseLockPath, 'utf8');
1817
- if (sourceContent === destinationContent) {
1818
- return {
1819
- result: {
1820
- status: 'unchanged',
1821
- note: `${LOCK_FILE_RELATIVE} already in sync`,
1822
- },
1823
- sandboxLockContent: sourceContent,
1824
- };
1825
- }
1826
-
1827
- fs.mkdirSync(path.dirname(baseLockPath), { recursive: true });
1828
- fs.writeFileSync(baseLockPath, sourceContent, 'utf8');
1829
- return {
1830
- result: {
1831
- status: 'synced',
1832
- note: `${LOCK_FILE_RELATIVE} synced from sandbox`,
1833
- },
1834
- sandboxLockContent: sourceContent,
1835
- };
1836
- }
1837
-
1838
- /**
1839
- * @param {string} repoRoot
1840
- * @param {string | null} sandboxLockContent
1841
- * @returns {OperationResult}
1842
- */
1843
- function syncDoctorLockRegistryAfterMerge(repoRoot, sandboxLockContent) {
1844
- if (sandboxLockContent === null) {
1845
- return {
1846
- status: 'skipped',
1847
- note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
1848
- };
1849
- }
1850
-
1851
- const baseLockPath = path.join(repoRoot, LOCK_FILE_RELATIVE);
1852
- if (!fs.existsSync(baseLockPath)) {
1853
- fs.mkdirSync(path.dirname(baseLockPath), { recursive: true });
1854
- fs.writeFileSync(baseLockPath, sandboxLockContent, 'utf8');
1855
- return {
1856
- status: 'synced',
1857
- note: `${LOCK_FILE_RELATIVE} recreated from sandbox`,
1858
- };
1859
- }
1860
-
1861
- const destinationContent = fs.readFileSync(baseLockPath, 'utf8');
1862
- if (sandboxLockContent === destinationContent) {
1863
- return {
1864
- status: 'unchanged',
1865
- note: `${LOCK_FILE_RELATIVE} already in sync`,
1866
- };
1867
- }
1868
-
1869
- fs.mkdirSync(path.dirname(baseLockPath), { recursive: true });
1870
- fs.writeFileSync(baseLockPath, sandboxLockContent, 'utf8');
1871
- return {
1872
- status: 'synced',
1873
- note: `${LOCK_FILE_RELATIVE} synced from sandbox`,
1874
- };
1875
- }
1876
-
1877
- /**
1878
- * @param {object} options
1879
- * @param {{ repoRoot: string, branch: string }} blocked
1880
- * @param {SandboxMetadata} metadata
1881
- * @returns {DoctorSandboxExecution}
1882
- */
1883
- function executeDoctorSandboxLifecycle(options, blocked, metadata) {
1884
- const execution = createDoctorSandboxExecutionState();
1885
- const dryRun = Boolean(options.dryRun);
1886
-
1887
- execution.omxScaffoldSync = summarizeDoctorOmxScaffoldSync(blocked.repoRoot, dryRun);
1888
-
1889
- if (!dryRun) {
1890
- execution.autoCommit = autoCommitDoctorSandboxChanges(metadata);
1891
- if (execution.autoCommit.status === 'committed') {
1892
- execution.finish = finishDoctorSandboxBranch(blocked, metadata, options);
1893
- } else if (execution.autoCommit.status === 'no-changes') {
1894
- execution.finish = createDoctorSkippedOperation('no doctor changes to auto-finish');
1895
- } else if (execution.autoCommit.status !== 'failed') {
1896
- execution.finish = createDoctorSkippedOperation('auto-commit did not run');
1897
- }
1898
- } else {
1899
- execution.autoCommit = createDoctorSkippedOperation('dry-run skips doctor sandbox auto-commit');
1900
- execution.finish = createDoctorSkippedOperation('dry-run skips doctor sandbox finish flow');
1901
- }
1902
-
1903
- const lockSyncState = syncDoctorLockRegistryBeforeMerge(blocked.repoRoot, metadata);
1904
- execution.lockSync = lockSyncState.result;
1905
- execution.sandboxLockContent = lockSyncState.sandboxLockContent;
1906
-
1907
- execution.protectedBaseRepairSync = mergeDoctorSandboxRepairsBackToProtectedBase(
1908
- options,
1909
- blocked,
1910
- metadata,
1911
- execution.autoCommit,
1912
- execution.finish,
1913
- );
1914
-
1915
- execution.omxScaffoldSync = summarizeDoctorOmxScaffoldSync(blocked.repoRoot, dryRun);
1916
- execution.lockSync = syncDoctorLockRegistryAfterMerge(
1917
- blocked.repoRoot,
1918
- execution.sandboxLockContent,
1919
- );
1920
- execution.autoFinish = autoFinishReadyAgentBranches(blocked.repoRoot, {
1921
- baseBranch: blocked.branch,
1922
- dryRun: options.dryRun,
1923
- waitForMerge: options.waitForMerge,
1924
- excludeBranches: [metadata.branch],
1925
- });
1926
-
1927
- return execution;
1928
- }
1929
-
1930
- function emitDoctorSandboxJsonOutput(nestedResult, execution) {
1931
- if (nestedResult.stdout) {
1932
- if (nestedResult.status === 0) {
1933
- try {
1934
- const parsed = JSON.parse(nestedResult.stdout);
1935
- process.stdout.write(
1936
- JSON.stringify(
1937
- {
1938
- ...parsed,
1939
- protectedBaseRepairSync: execution.protectedBaseRepairSync,
1940
- sandboxOmxScaffoldSync: execution.omxScaffoldSync,
1941
- sandboxLockSync: execution.lockSync,
1942
- sandboxAutoCommit: execution.autoCommit,
1943
- sandboxFinish: execution.finish,
1944
- autoFinish: execution.autoFinish,
1945
- },
1946
- null,
1947
- 2,
1948
- ) + '\n',
1949
- );
1950
- } catch {
1951
- process.stdout.write(nestedResult.stdout);
1952
- }
1953
- } else {
1954
- process.stdout.write(nestedResult.stdout);
1955
- }
1956
- }
1957
- if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
1958
- }
1959
-
1960
- /**
1961
- * @param {object} options
1962
- * @param {{ branch: string }} blocked
1963
- * @param {SandboxMetadata} metadata
1964
- * @param {SandboxStartResult} startResult
1965
- * @param {any} nestedResult
1966
- * @param {DoctorSandboxExecution} execution
1967
- */
1968
- function emitDoctorSandboxConsoleOutput(options, blocked, metadata, startResult, nestedResult, execution) {
1969
- console.log(
1970
- `[${TOOL_NAME}] doctor detected protected branch '${blocked.branch}'. ` +
1971
- `Running repairs in sandbox branch '${metadata.branch || 'agent/<auto>'}'.`,
1972
- );
1973
- if (startResult.stdout) process.stdout.write(startResult.stdout);
1974
- if (startResult.stderr) process.stderr.write(startResult.stderr);
1975
- if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
1976
- if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
1977
- if (nestedResult.status !== 0) {
1978
- return;
1979
- }
1980
-
1981
- if (execution.autoCommit.status === 'committed') {
1982
- console.log(
1983
- `[${TOOL_NAME}] Auto-committed doctor repairs in sandbox branch '${metadata.branch}'.`,
1984
- );
1985
- } else if (execution.autoCommit.status === 'failed') {
1986
- console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit failed; branch left for manual follow-up.`);
1987
- if (execution.autoCommit.stdout) process.stdout.write(execution.autoCommit.stdout);
1988
- if (execution.autoCommit.stderr) process.stderr.write(execution.autoCommit.stderr);
1989
- } else {
1990
- console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit skipped: ${execution.autoCommit.note}.`);
1991
- }
1992
-
1993
- if (execution.protectedBaseRepairSync.status === 'merged') {
1994
- console.log(`[${TOOL_NAME}] Fast-forwarded tracked doctor repairs into the protected branch workspace.`);
1995
- } else if (execution.protectedBaseRepairSync.status === 'unchanged') {
1996
- console.log(`[${TOOL_NAME}] Protected branch workspace already had the tracked doctor repairs.`);
1997
- } else if (execution.protectedBaseRepairSync.status === 'would-merge') {
1998
- console.log(`[${TOOL_NAME}] Dry run: would fast-forward tracked doctor repairs into the protected branch workspace.`);
1999
- } else if (execution.protectedBaseRepairSync.status === 'failed') {
2000
- console.log(`[${TOOL_NAME}] Protected branch tracked repair merge failed: ${execution.protectedBaseRepairSync.note}.`);
2001
- if (execution.protectedBaseRepairSync.stdout) process.stdout.write(execution.protectedBaseRepairSync.stdout);
2002
- if (execution.protectedBaseRepairSync.stderr) process.stderr.write(execution.protectedBaseRepairSync.stderr);
2003
- } else {
2004
- console.log(`[${TOOL_NAME}] Protected branch tracked repair merge skipped: ${execution.protectedBaseRepairSync.note}.`);
2005
- }
2006
-
2007
- if (execution.lockSync.status === 'synced') {
2008
- console.log(
2009
- `[${TOOL_NAME}] Synced repaired lock registry back to protected branch workspace (${LOCK_FILE_RELATIVE}).`,
2010
- );
2011
- } else if (execution.lockSync.status === 'unchanged') {
2012
- console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
2013
- } else {
2014
- console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${execution.lockSync.note}.`);
2015
- }
2016
-
2017
- if (execution.finish.status === 'completed') {
2018
- console.log(`[${TOOL_NAME}] Auto-finish flow completed for sandbox branch '${metadata.branch}'.`);
2019
- if (execution.finish.stdout) process.stdout.write(execution.finish.stdout);
2020
- if (execution.finish.stderr) process.stderr.write(execution.finish.stderr);
2021
- } else if (execution.finish.status === 'pending') {
2022
- console.log(
2023
- `[${TOOL_NAME}] Auto-finish pending for sandbox branch '${metadata.branch}': ${execution.finish.note}.`,
2024
- );
2025
- if (execution.finish.prUrl) {
2026
- console.log(`[${TOOL_NAME}] PR: ${execution.finish.prUrl}`);
2027
- }
2028
- if (execution.finish.stdout) process.stdout.write(execution.finish.stdout);
2029
- if (execution.finish.stderr) process.stderr.write(execution.finish.stderr);
2030
- } else if (execution.finish.status === 'failed') {
2031
- console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
2032
- if (execution.finish.stdout) process.stdout.write(execution.finish.stdout);
2033
- if (execution.finish.stderr) process.stderr.write(execution.finish.stderr);
2034
- } else {
2035
- console.log(`[${TOOL_NAME}] Auto-finish skipped: ${execution.finish.note}.`);
2036
- }
2037
-
2038
- printAutoFinishSummary(execution.autoFinish, {
2039
- baseBranch: blocked.branch,
2040
- verbose: options.verboseAutoFinish,
2041
- });
2042
- if (execution.omxScaffoldSync.status === 'synced') {
2043
- console.log(`[${TOOL_NAME}] Synced .omx scaffold back to protected branch workspace.`);
2044
- } else if (execution.omxScaffoldSync.status === 'unchanged') {
2045
- console.log(`[${TOOL_NAME}] .omx scaffold already aligned in protected branch workspace.`);
2046
- } else if (execution.omxScaffoldSync.status === 'would-sync') {
2047
- console.log(`[${TOOL_NAME}] Dry run: would sync .omx scaffold back to protected branch workspace.`);
2048
- } else {
2049
- console.log(`[${TOOL_NAME}] .omx scaffold sync skipped: ${execution.omxScaffoldSync.note}.`);
2050
- }
2051
- }
2052
-
2053
- function setDoctorSandboxExitCode(nestedResult, execution) {
2054
- if (typeof nestedResult.status === 'number') {
2055
- let exitCode = nestedResult.status;
2056
- if (exitCode === 0 && execution.autoCommit.status === 'failed') {
2057
- exitCode = 1;
2058
- }
2059
- if (
2060
- exitCode === 0 &&
2061
- execution.autoCommit.status === 'committed' &&
2062
- (execution.finish.status === 'failed' || execution.finish.status === 'pending')
2063
- ) {
2064
- exitCode = 1;
2065
- }
2066
- if (exitCode === 0 && execution.protectedBaseRepairSync.status === 'failed') {
2067
- exitCode = 1;
2068
- }
2069
- process.exitCode = exitCode;
2070
- return;
2071
- }
2072
- process.exitCode = 1;
2073
- }
2074
-
2075
- function runDoctorInSandbox(options, blocked) {
2076
- /** @type {SandboxStartResult} */
2077
- const startResult = startProtectedBaseSandbox(blocked, {
2078
- taskName: `${SHORT_TOOL_NAME}-doctor`,
2079
- sandboxSuffix: 'gx-doctor',
2080
- });
2081
- const metadata = startResult.metadata;
2082
-
2083
- const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
2084
- const nestedResult = run(
2085
- process.execPath,
2086
- [__filename, ...buildSandboxDoctorArgs(options, sandboxTarget)],
2087
- { cwd: metadata.worktreePath },
2088
- );
2089
- if (isSpawnFailure(nestedResult)) {
2090
- throw nestedResult.error;
2091
- }
2092
-
2093
- const execution = nestedResult.status === 0
2094
- ? executeDoctorSandboxLifecycle(options, blocked, metadata)
2095
- : createDoctorSandboxExecutionState();
2096
-
2097
- if (options.json) {
2098
- emitDoctorSandboxJsonOutput(nestedResult, execution);
2099
- } else {
2100
- emitDoctorSandboxConsoleOutput(options, blocked, metadata, startResult, nestedResult, execution);
2101
- }
2102
-
2103
- setDoctorSandboxExitCode(nestedResult, execution);
2104
- }
2105
-
2106
- function runSetupInSandbox(options, blocked, repoLabel = '') {
2107
- const startResult = startProtectedBaseSandbox(blocked, {
2108
- taskName: `${SHORT_TOOL_NAME}-setup`,
2109
- sandboxSuffix: 'gx-setup',
2110
- });
2111
- const metadata = startResult.metadata;
2112
-
2113
- if (startResult.stdout) process.stdout.write(startResult.stdout);
2114
- if (startResult.stderr) process.stderr.write(startResult.stderr);
2115
- console.log(
2116
- `[${TOOL_NAME}] setup blocked on protected branch '${blocked.branch}' in an initialized repo; ` +
2117
- 'refreshing through a sandbox worktree and syncing managed bootstrap files back locally.',
2118
- );
2119
-
2120
- const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
2121
- const nestedResult = run(
2122
- process.execPath,
2123
- [__filename, ...buildSandboxSetupArgs(options, sandboxTarget)],
2124
- { cwd: metadata.worktreePath },
2125
- );
2126
- if (isSpawnFailure(nestedResult)) {
2127
- throw nestedResult.error;
2128
- }
2129
- if (nestedResult.status !== 0) {
2130
- if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
2131
- if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
2132
- throw new Error(
2133
- `sandboxed setup failed for protected branch '${blocked.branch}'. ` +
2134
- `Inspect sandbox at ${metadata.worktreePath}`,
2135
- );
2136
- }
2137
-
2138
- const syncOptions = {
2139
- ...options,
2140
- target: blocked.repoRoot,
2141
- recursive: false,
2142
- allowProtectedBaseWrite: true,
2143
- };
2144
- const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(syncOptions);
2145
- printOperations(`Setup/install${repoLabel}`, installPayload, syncOptions.dryRun);
2146
- printOperations(`Setup/fix${repoLabel}`, fixPayload, syncOptions.dryRun);
2147
- if (!syncOptions.dryRun && parentWorkspace) {
2148
- console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
2149
- }
2150
-
2151
- const scanResult = runScanInternal({ target: blocked.repoRoot, json: false });
2152
- const currentBaseBranch = currentBranchName(scanResult.repoRoot);
2153
- const autoFinishSummary = autoFinishReadyAgentBranches(scanResult.repoRoot, {
2154
- baseBranch: currentBaseBranch,
2155
- dryRun: syncOptions.dryRun,
2156
- });
2157
- printScanResult(scanResult, false);
2158
- if (autoFinishSummary.enabled) {
2159
- console.log(
2160
- `[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
2161
- );
2162
- for (const detail of autoFinishSummary.details) {
2163
- console.log(`[${TOOL_NAME}] ${detail}`);
2164
- }
2165
- } else if (autoFinishSummary.details.length > 0) {
2166
- console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
2167
- }
2168
-
2169
- const cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
2170
- console.log(
2171
- `[${TOOL_NAME}] Protected-base setup sandbox cleanup: ${cleanupResult.note} ` +
2172
- `(worktree=${cleanupResult.worktree}, branch=${cleanupResult.branch}).`,
2173
- );
2174
-
2175
- return {
2176
- scanResult,
2177
- };
2178
- }
2179
-
2180
-
2181
- function todayDateStamp() {
2182
- return new Date().toISOString().slice(0, 10);
2183
- }
2184
-
2185
- function inferGithubRepoFromOrigin(repoRoot) {
2186
- const rawOrigin = readGitConfig(repoRoot, 'remote.origin.url');
2187
- if (!rawOrigin) return '';
2188
-
2189
- const httpsMatch = rawOrigin.match(/github\.com[:/](.+?)(?:\.git)?$/i);
2190
- if (!httpsMatch) return '';
2191
- const slug = (httpsMatch[1] || '').replace(/^\/+/, '').trim();
2192
- if (!slug || !slug.includes('/')) return '';
2193
- return `github.com/${slug}`;
2194
- }
2195
-
2196
- function inferGithubRepoSlug(rawValue) {
2197
- const raw = String(rawValue || '').trim();
2198
- if (!raw) return '';
2199
- const match = raw.match(/github\.com[:/](.+?)(?:\.git)?$/i);
2200
- if (!match) return '';
2201
- const slug = String(match[1] || '')
2202
- .replace(/^\/+/, '')
2203
- .replace(/^github\.com\//i, '')
2204
- .trim();
2205
- if (!slug || !slug.includes('/')) return '';
2206
- return slug;
2207
- }
2208
-
2209
- function resolveScorecardRepo(repoRoot, explicitRepo) {
2210
- if (explicitRepo) {
2211
- return explicitRepo.trim();
2212
- }
2213
- const inferred = inferGithubRepoFromOrigin(repoRoot);
2214
- if (inferred) return inferred;
2215
- throw new Error(
2216
- 'Unable to infer GitHub repo from origin remote. Pass --repo github.com/<owner>/<repo>.',
2217
- );
2218
- }
2219
-
2220
- function runScorecardJson(repo) {
2221
- const result = run(SCORECARD_BIN, ['--repo', repo, '--format', 'json'], { allowFailure: true });
2222
- if (result.status !== 0) {
2223
- const details = (result.stderr || result.stdout || '').trim();
2224
- throw new Error(
2225
- `Failed to run scorecard CLI ('${SCORECARD_BIN} --repo ${repo} --format json').${details ? `\n${details}` : ''}`,
2226
- );
2227
- }
2228
-
2229
- try {
2230
- return JSON.parse(result.stdout || '{}');
2231
- } catch (error) {
2232
- throw new Error(`Unable to parse scorecard JSON output: ${error.message}`);
2233
- }
2234
- }
2235
-
2236
- function readScorecardJsonFile(filePath) {
2237
- const absolute = path.resolve(filePath);
2238
- if (!fs.existsSync(absolute)) {
2239
- throw new Error(`scorecard JSON file not found: ${absolute}`);
2240
- }
2241
- try {
2242
- return JSON.parse(fs.readFileSync(absolute, 'utf8'));
2243
- } catch (error) {
2244
- throw new Error(`Unable to parse scorecard JSON file: ${error.message}`);
2245
- }
2246
- }
2247
-
2248
- function normalizeScorecardChecks(payload) {
2249
- const rawChecks = Array.isArray(payload?.checks) ? payload.checks : [];
2250
- return rawChecks.map((check) => {
2251
- const name = String(check?.name || 'Unknown');
2252
- const rawScore = Number(check?.score);
2253
- const score = Number.isFinite(rawScore) ? rawScore : 0;
2254
- return {
2255
- name,
2256
- score,
2257
- risk: SCORECARD_RISK_BY_CHECK[name] || 'Unknown',
2258
- };
2259
- });
2260
- }
2261
-
2262
- function renderScorecardBaselineMarkdown({ repo, score, checks, capturedAt, scorecardVersion, reportDate }) {
2263
- const rows = checks
2264
- .map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`)
2265
- .join('\n');
2266
-
2267
- return [
2268
- '# OpenSSF Scorecard Baseline Report',
2269
- '',
2270
- `- **Repository:** \`${repo}\``,
2271
- '- **Source:** generated by `gx report scorecard`',
2272
- `- **Captured at:** ${capturedAt}`,
2273
- `- **Scorecard version:** \`${scorecardVersion}\``,
2274
- `- **Overall score:** **${score} / 10**`,
2275
- '',
2276
- '## Check breakdown',
2277
- '',
2278
- '| Check | Score | Risk |',
2279
- '|---|---:|---|',
2280
- rows || '| (none) | 0 | Unknown |',
2281
- '',
2282
- `## Report date`,
2283
- '',
2284
- `- ${reportDate}`,
2285
- '',
2286
- ].join('\n');
2287
- }
2288
-
2289
- function renderScorecardRemediationPlanMarkdown({ baselineRelativePath, checks }) {
2290
- const failing = checks.filter((item) => item.score < 10);
2291
- const failingRows = failing
2292
- .sort((a, b) => a.score - b.score || a.name.localeCompare(b.name))
2293
- .map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`)
2294
- .join('\n');
2295
-
2296
- return [
2297
- '# OpenSSF Scorecard Remediation Plan',
2298
- '',
2299
- `Based on baseline report: \`${baselineRelativePath}\`.`,
2300
- '',
2301
- '## Failing checks',
2302
- '',
2303
- '| Check | Score | Risk |',
2304
- '|---|---:|---|',
2305
- (failingRows || '| None | 10 | N/A |'),
2306
- '',
2307
- '## Priority order',
2308
- '',
2309
- '1. Fix **High** risk checks first (especially score 0 items).',
2310
- '2. Then close **Medium** risk checks with score < 10.',
2311
- '3. Finally address **Low** risk ecosystem/process checks.',
2312
- '',
2313
- '## Verification loop',
2314
- '',
2315
- '1. Run scorecard again.',
2316
- '2. Re-generate baseline + remediation files.',
2317
- '3. Compare score deltas and track improved checks.',
2318
- '',
2319
- ].join('\n');
2320
- }
2321
-
2322
- function parseBranchList(rawValue) {
2323
- return String(rawValue || '')
2324
- .split(/[\s,]+/)
2325
- .map((item) => item.trim())
2326
- .filter(Boolean);
2327
- }
2328
-
2329
- function uniquePreserveOrder(items) {
2330
- const seen = new Set();
2331
- const result = [];
2332
- for (const item of items) {
2333
- if (seen.has(item)) continue;
2334
- seen.add(item);
2335
- result.push(item);
2336
- }
2337
- return result;
2338
- }
2339
-
2340
- function readConfiguredProtectedBranches(repoRoot) {
2341
- const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
2342
- if (result.status !== 0) {
2343
- return null;
2344
- }
2345
- const parsed = uniquePreserveOrder(parseBranchList(result.stdout.trim()));
2346
- if (parsed.length === 0) {
2347
- return null;
2348
- }
2349
- return parsed;
2350
- }
2351
-
2352
- function listLocalUserBranches(repoRoot) {
2353
- const result = gitRun(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { allowFailure: true });
2354
- const branchNames = result.status === 0
2355
- ? uniquePreserveOrder(
2356
- String(result.stdout || '')
2357
- .split('\n')
2358
- .map((item) => item.trim())
2359
- .filter(Boolean),
2360
- )
2361
- : [];
2362
-
2363
- const additionalUserBranches = branchNames.filter(
2364
- (branchName) =>
2365
- !branchName.startsWith('agent/') &&
2366
- !DEFAULT_PROTECTED_BRANCHES.includes(branchName),
2367
- );
2368
- if (additionalUserBranches.length > 0) {
2369
- return additionalUserBranches;
2370
- }
2371
-
2372
- const current = gitRun(repoRoot, ['branch', '--show-current'], { allowFailure: true });
2373
- if (current.status !== 0) {
2374
- return [];
2375
- }
2376
-
2377
- const branchName = String(current.stdout || '').trim();
2378
- if (
2379
- !branchName ||
2380
- branchName.startsWith('agent/') ||
2381
- DEFAULT_PROTECTED_BRANCHES.includes(branchName)
2382
- ) {
2383
- return [];
2384
- }
2385
-
2386
- return [branchName];
2387
- }
2388
-
2389
- function listLocalAgentBranches(repoRoot) {
2390
- const result = gitRun(
2391
- repoRoot,
2392
- ['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent/'],
2393
- { allowFailure: true },
2394
- );
2395
- if (result.status !== 0) {
2396
- return [];
2397
- }
2398
- return uniquePreserveOrder(
2399
- String(result.stdout || '')
2400
- .split('\n')
2401
- .map((item) => item.trim())
2402
- .filter(Boolean),
2403
- );
2404
- }
2405
-
2406
- function mapWorktreePathsByBranch(repoRoot) {
2407
- const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
2408
- const map = new Map();
2409
- if (result.status !== 0) {
2410
- return map;
2411
- }
2412
-
2413
- const lines = String(result.stdout || '').split('\n');
2414
- let currentWorktree = '';
2415
- for (const line of lines) {
2416
- if (line.startsWith('worktree ')) {
2417
- currentWorktree = line.slice('worktree '.length).trim();
2418
- continue;
2419
- }
2420
- if (line.startsWith('branch refs/heads/')) {
2421
- const branchName = line.slice('branch refs/heads/'.length).trim();
2422
- if (currentWorktree && branchName) {
2423
- map.set(branchName, currentWorktree);
2424
- }
2425
- }
2426
- }
2427
- return map;
2428
- }
2429
-
2430
- function hasSignificantWorkingTreeChanges(worktreePath) {
2431
- const result = run('git', [
2432
- '-C',
2433
- worktreePath,
2434
- 'status',
2435
- '--porcelain',
2436
- '--untracked-files=normal',
2437
- '--',
2438
- ]);
2439
- if (result.status !== 0) {
2440
- return true;
2441
- }
2442
-
2443
- const lines = String(result.stdout || '')
2444
- .split('\n')
2445
- .map((line) => line.trimEnd())
2446
- .filter((line) => line.length > 0);
2447
-
2448
- for (const line of lines) {
2449
- const pathPart = (line.length > 3 ? line.slice(3) : '').trim();
2450
- if (!pathPart) continue;
2451
- if (pathPart === LOCK_FILE_RELATIVE) continue;
2452
- if (pathPart.startsWith(`${LOCK_FILE_RELATIVE} -> `)) continue;
2453
- if (pathPart.endsWith(` -> ${LOCK_FILE_RELATIVE}`)) continue;
2454
- return true;
2455
- }
2456
- return false;
2457
- }
2458
-
2459
- function autoFinishReadyAgentBranches(repoRoot, options = {}) {
2460
- const baseBranch = String(options.baseBranch || '').trim();
2461
- const dryRun = Boolean(options.dryRun);
2462
- const waitForMerge = options.waitForMerge !== false;
2463
- const excludedBranches = new Set(
2464
- Array.isArray(options.excludeBranches)
2465
- ? options.excludeBranches.map((branch) => String(branch || '').trim()).filter(Boolean)
2466
- : [],
2467
- );
2468
-
2469
- const summary = {
2470
- enabled: true,
2471
- baseBranch,
2472
- attempted: 0,
2473
- completed: 0,
2474
- skipped: 0,
2475
- failed: 0,
2476
- details: [],
2477
- };
2478
-
2479
- if (!baseBranch || baseBranch === 'HEAD' || baseBranch.startsWith('agent/')) {
2480
- summary.enabled = false;
2481
- summary.details.push('Skipped auto-finish sweep (base branch is missing or not a non-agent local branch).');
2482
- return summary;
2483
- }
2484
-
2485
- if (String(process.env.GUARDEX_DOCTOR_SANDBOX || '') === '1') {
2486
- summary.enabled = false;
2487
- summary.details.push('Skipped auto-finish sweep inside doctor sandbox pass.');
2488
- return summary;
2489
- }
2490
-
2491
- if (String(process.env.GUARDEX_SKIP_AUTO_FINISH_READY_BRANCHES || '') === '1') {
2492
- summary.enabled = false;
2493
- summary.details.push('Skipped auto-finish sweep (GUARDEX_SKIP_AUTO_FINISH_READY_BRANCHES=1).');
2494
- return summary;
2495
- }
2496
-
2497
- if (dryRun) {
2498
- summary.enabled = false;
2499
- summary.details.push('Skipped auto-finish sweep in dry-run mode.');
2500
- return summary;
2501
- }
2502
-
2503
- const hasOrigin = gitRun(repoRoot, ['remote', 'get-url', 'origin'], { allowFailure: true }).status === 0;
2504
- if (!hasOrigin) {
2505
- summary.enabled = false;
2506
- summary.details.push('Skipped auto-finish sweep (origin remote missing).');
2507
- return summary;
2508
- }
2509
- const explicitGhBin = Boolean(String(process.env.GUARDEX_GH_BIN || '').trim());
2510
- if (!explicitGhBin && !originRemoteLooksLikeGithub(repoRoot)) {
2511
- summary.enabled = false;
2512
- summary.details.push('Skipped auto-finish sweep (origin remote is not GitHub).');
2513
- return summary;
2514
- }
2515
-
2516
- const ghBin = process.env.GUARDEX_GH_BIN || 'gh';
2517
- if (run(ghBin, ['--version']).status !== 0) {
2518
- summary.enabled = false;
2519
- summary.details.push(`Skipped auto-finish sweep (${ghBin} not available).`);
2520
- return summary;
2521
- }
2522
-
2523
- const branchWorktrees = mapWorktreePathsByBranch(repoRoot);
2524
- const agentBranches = listLocalAgentBranches(repoRoot);
2525
- if (agentBranches.length === 0) {
2526
- summary.enabled = false;
2527
- summary.details.push('No local agent branches found for auto-finish sweep.');
2528
- return summary;
2529
- }
2530
-
2531
- for (const branch of agentBranches) {
2532
- if (excludedBranches.has(branch)) {
2533
- summary.skipped += 1;
2534
- summary.details.push(`[skip] ${branch}: excluded from this auto-finish sweep.`);
2535
- continue;
2536
- }
2537
-
2538
- if (branch === baseBranch) {
2539
- summary.skipped += 1;
2540
- summary.details.push(`[skip] ${branch}: source branch equals base branch.`);
2541
- continue;
2542
- }
2543
-
2544
- let counts;
2545
- try {
2546
- counts = aheadBehind(repoRoot, branch, baseBranch);
2547
- } catch (error) {
2548
- summary.failed += 1;
2549
- summary.details.push(`[fail] ${branch}: unable to compute ahead/behind (${error.message}).`);
2550
- continue;
2551
- }
2552
-
2553
- if (counts.ahead <= 0) {
2554
- summary.skipped += 1;
2555
- summary.details.push(`[skip] ${branch}: already merged into ${baseBranch}.`);
2556
- continue;
2557
- }
2558
-
2559
- const branchWorktree = branchWorktrees.get(branch) || '';
2560
- if (branchWorktree && hasSignificantWorkingTreeChanges(branchWorktree)) {
2561
- summary.skipped += 1;
2562
- summary.details.push(`[skip] ${branch}: dirty worktree (${branchWorktree}).`);
2563
- continue;
2564
- }
475
+ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
476
+ const branchPrefix = protectedBaseSandboxBranchPrefix();
477
+ let selectedBranch = '';
478
+ let selectedWorktreePath = '';
2565
479
 
2566
- summary.attempted += 1;
2567
- const finishArgs = [
2568
- '--branch',
2569
- branch,
2570
- '--base',
2571
- baseBranch,
2572
- '--via-pr',
2573
- waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge',
2574
- '--cleanup',
2575
- ];
2576
- const finishResult = runPackageAsset('branchFinish', finishArgs, { cwd: repoRoot });
2577
- const combinedOutput = [finishResult.stdout || '', finishResult.stderr || ''].join('\n').trim();
2578
-
2579
- if (finishResult.status === 0) {
2580
- summary.completed += 1;
2581
- summary.details.push(`[done] ${branch}: auto-finish completed.`);
480
+ for (let attempt = 0; attempt < 30; attempt += 1) {
481
+ const suffix = attempt === 0 ? sandboxSuffix : `${attempt + 1}-${sandboxSuffix}`;
482
+ const candidateBranch = `${branchPrefix}-${suffix}`;
483
+ const candidateWorktreePath = protectedBaseSandboxWorktreePath(blocked.repoRoot, candidateBranch);
484
+ if (gitRefExists(blocked.repoRoot, `refs/heads/${candidateBranch}`)) {
2582
485
  continue;
2583
486
  }
2584
-
2585
- const recoverableConflict = detectRecoverableAutoFinishConflict(combinedOutput);
2586
- if (recoverableConflict) {
2587
- summary.skipped += 1;
2588
- const tail = combinedOutput ? ` ${combinedOutput.split('\n').slice(-2).join(' | ')}` : '';
2589
- summary.details.push(`[skip] ${branch}: ${recoverableConflict.rawLabel}${tail}`);
487
+ if (fs.existsSync(candidateWorktreePath)) {
2590
488
  continue;
2591
489
  }
2592
-
2593
- summary.failed += 1;
2594
- const tail = combinedOutput ? ` ${combinedOutput.split('\n').slice(-2).join(' | ')}` : '';
2595
- summary.details.push(`[fail] ${branch}: auto-finish failed.${tail}`);
490
+ selectedBranch = candidateBranch;
491
+ selectedWorktreePath = candidateWorktreePath;
492
+ break;
2596
493
  }
2597
494
 
2598
- return summary;
2599
- }
2600
-
2601
- function ensureSetupProtectedBranches(repoRoot, dryRun) {
2602
- const localUserBranches = listLocalUserBranches(repoRoot);
2603
- if (localUserBranches.length === 0) {
2604
- return {
2605
- status: 'unchanged',
2606
- file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
2607
- note: 'no additional local user branches detected',
2608
- };
495
+ if (!selectedBranch || !selectedWorktreePath) {
496
+ throw new Error('Unable to allocate unique sandbox branch/worktree');
2609
497
  }
2610
498
 
2611
- const configured = readConfiguredProtectedBranches(repoRoot);
2612
- const currentBranches = configured || [...DEFAULT_PROTECTED_BRANCHES];
2613
- const missingBranches = localUserBranches.filter((branchName) => !currentBranches.includes(branchName));
2614
- if (missingBranches.length === 0) {
2615
- return {
2616
- status: 'unchanged',
2617
- file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
2618
- note: 'local user branches already protected',
2619
- };
499
+ fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true });
500
+ const startRef = resolveProtectedBaseSandboxStartRef(blocked.repoRoot, blocked.branch);
501
+ const addArgs = startRef
502
+ ? ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef]
503
+ : ['-C', blocked.repoRoot, 'worktree', 'add', '--orphan', selectedWorktreePath];
504
+ const addResult = run('git', addArgs);
505
+ if (isSpawnFailure(addResult)) {
506
+ throw addResult.error;
507
+ }
508
+ if (addResult.status !== 0) {
509
+ throw new Error((addResult.stderr || addResult.stdout || 'failed to create sandbox').trim());
2620
510
  }
2621
511
 
2622
- const nextBranches = uniquePreserveOrder([...currentBranches, ...missingBranches]);
2623
- if (!dryRun) {
2624
- writeProtectedBranches(repoRoot, nextBranches);
512
+ if (!startRef) {
513
+ const renameResult = run(
514
+ 'git',
515
+ ['-C', selectedWorktreePath, 'branch', '-m', selectedBranch],
516
+ { timeout: 20_000 },
517
+ );
518
+ if (isSpawnFailure(renameResult)) {
519
+ throw renameResult.error;
520
+ }
521
+ if (renameResult.status !== 0) {
522
+ throw new Error(
523
+ (renameResult.stderr || renameResult.stdout || 'failed to name orphan sandbox branch').trim(),
524
+ );
525
+ }
2625
526
  }
2626
527
 
2627
528
  return {
2628
- status: dryRun ? 'would-update' : 'updated',
2629
- file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
2630
- note: `added local user branch(es): ${missingBranches.join(', ')}`,
529
+ metadata: {
530
+ branch: selectedBranch,
531
+ worktreePath: selectedWorktreePath,
532
+ },
533
+ stdout:
534
+ `[agent-branch-start] Created branch: ${selectedBranch}\n` +
535
+ `[agent-branch-start] Worktree: ${selectedWorktreePath}\n`,
536
+ stderr: addResult.stderr || '',
2631
537
  };
2632
538
  }
2633
539
 
2634
- function readProtectedBranches(repoRoot) {
2635
- const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
2636
- if (result.status !== 0) {
2637
- return [...DEFAULT_PROTECTED_BRANCHES];
2638
- }
2639
-
2640
- const parsed = uniquePreserveOrder(parseBranchList(result.stdout.trim()));
2641
- if (parsed.length === 0) {
2642
- return [...DEFAULT_PROTECTED_BRANCHES];
2643
- }
2644
- return parsed;
2645
- }
2646
-
2647
- function writeProtectedBranches(repoRoot, branches) {
2648
- if (branches.length === 0) {
2649
- gitRun(repoRoot, ['config', '--unset-all', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
2650
- return;
2651
- }
2652
- gitRun(repoRoot, ['config', GIT_PROTECTED_BRANCHES_KEY, branches.join(' ')]);
2653
- }
2654
-
2655
- function readGitConfig(repoRoot, key) {
2656
- const result = gitRun(repoRoot, ['config', '--get', key], { allowFailure: true });
2657
- if (result.status !== 0) {
2658
- return '';
2659
- }
2660
- return (result.stdout || '').trim();
2661
- }
2662
-
2663
- function resolveBaseBranch(repoRoot, explicitBase) {
2664
- if (explicitBase) {
2665
- return explicitBase;
2666
- }
2667
- const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
2668
- return configured || DEFAULT_BASE_BRANCH;
2669
- }
2670
-
2671
- function resolveSyncStrategy(repoRoot, explicitStrategy) {
2672
- const strategy = (explicitStrategy || readGitConfig(repoRoot, GIT_SYNC_STRATEGY_KEY) || DEFAULT_SYNC_STRATEGY)
2673
- .trim()
2674
- .toLowerCase();
2675
- if (strategy !== 'rebase' && strategy !== 'merge') {
2676
- throw new Error(`Invalid sync strategy '${strategy}' (expected: rebase or merge)`);
540
+ function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) {
541
+ if (sandboxSuffix === 'gx-doctor') {
542
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
2677
543
  }
2678
- return strategy;
2679
- }
2680
544
 
2681
- function currentBranchName(repoRoot) {
2682
- const result = gitRun(repoRoot, ['branch', '--show-current'], { allowFailure: true });
2683
- if (result.status !== 0) {
2684
- throw new Error('Unable to detect current branch');
545
+ const startResult = runPackageAsset('branchStart', [
546
+ '--task',
547
+ taskName,
548
+ '--agent',
549
+ SHORT_TOOL_NAME,
550
+ '--base',
551
+ blocked.branch,
552
+ ], { cwd: blocked.repoRoot });
553
+ if (isSpawnFailure(startResult)) {
554
+ throw startResult.error;
2685
555
  }
2686
- const branch = (result.stdout || '').trim();
2687
- if (!branch) {
2688
- throw new Error('Detached HEAD is not supported for sync operations');
556
+ if (startResult.status !== 0) {
557
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
2689
558
  }
2690
- return branch;
2691
- }
2692
559
 
2693
- function repoHasHeadCommit(repoRoot) {
2694
- return gitRun(repoRoot, ['rev-parse', '--verify', 'HEAD'], { allowFailure: true }).status === 0;
2695
- }
560
+ const metadata = extractAgentBranchStartMetadata(startResult.stdout);
561
+ const currentBranch = currentBranchName(blocked.repoRoot);
562
+ const worktreePath = metadata.worktreePath ? path.resolve(metadata.worktreePath) : '';
563
+ const repoRootPath = path.resolve(blocked.repoRoot);
564
+ const hasSafeWorktree = Boolean(worktreePath) && worktreePath !== repoRootPath;
565
+ const branchChanged = Boolean(currentBranch) && currentBranch !== blocked.branch;
2696
566
 
2697
- function readBranchDisplayName(repoRoot) {
2698
- const symbolic = gitRun(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { allowFailure: true });
2699
- if (symbolic.status === 0) {
2700
- const branch = String(symbolic.stdout || '').trim();
2701
- if (!branch) {
2702
- return '(unknown)';
567
+ if (!hasSafeWorktree || branchChanged) {
568
+ const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
569
+ if (!restoreResult.ok) {
570
+ const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim();
571
+ throw new Error(
572
+ `sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
573
+ (detail ? `\n${detail}` : ''),
574
+ );
2703
575
  }
2704
- return repoHasHeadCommit(repoRoot) ? branch : `${branch} (unborn; no commits yet)`;
2705
- }
2706
-
2707
- const detached = gitRun(repoRoot, ['rev-parse', '--short', 'HEAD'], { allowFailure: true });
2708
- if (detached.status === 0) {
2709
- return `(detached at ${String(detached.stdout || '').trim()})`;
576
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
2710
577
  }
2711
- return '(unknown)';
2712
- }
2713
578
 
2714
- function repoHasOriginRemote(repoRoot) {
2715
- return gitRun(repoRoot, ['remote', 'get-url', 'origin'], { allowFailure: true }).status === 0;
579
+ return {
580
+ metadata,
581
+ stdout: startResult.stdout || '',
582
+ stderr: startResult.stderr || '',
583
+ };
2716
584
  }
2717
585
 
2718
- function detectComposeHintFiles(repoRoot) {
2719
- return COMPOSE_HINT_FILES.filter((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
2720
- }
586
+ function cleanupProtectedBaseSandbox(repoRoot, metadata) {
587
+ const result = {
588
+ worktree: 'skipped',
589
+ branch: 'skipped',
590
+ note: 'missing sandbox metadata',
591
+ };
2721
592
 
2722
- function printSetupRepoHints(repoRoot, baseBranch, repoLabel = '') {
2723
- const branchDisplay = readBranchDisplayName(repoRoot);
2724
- const hasHeadCommit = repoHasHeadCommit(repoRoot);
2725
- const hasOrigin = repoHasOriginRemote(repoRoot);
2726
- const composeFiles = detectComposeHintFiles(repoRoot);
2727
- if (hasHeadCommit && hasOrigin && composeFiles.length === 0) {
2728
- return;
593
+ if (!metadata?.worktreePath || !metadata?.branch) {
594
+ return result;
2729
595
  }
2730
596
 
2731
- const label = repoLabel ? ` ${repoLabel}` : '';
2732
- if (!hasHeadCommit) {
2733
- console.log(`[${TOOL_NAME}] Fresh repo onboarding${label}: current branch is ${branchDisplay}.`);
2734
- console.log(`[${TOOL_NAME}] Bootstrap commit${label}: git add . && git commit -m "bootstrap gitguardex"`);
2735
- console.log(
2736
- `[${TOOL_NAME}] First agent flow${label}: ` +
2737
- `gx branch start "<task>" "codex" -> ` +
2738
- `gx locks claim --branch "$(git branch --show-current)" <file...> -> ` +
2739
- `gx branch finish --branch "$(git branch --show-current)" --base ${baseBranch} --via-pr --wait-for-merge`,
2740
- );
2741
- }
2742
- if (!hasOrigin) {
2743
- console.log(`[${TOOL_NAME}] No origin remote${label}: finish and auto-merge flows stay local until you add one.`);
2744
- }
2745
- if (composeFiles.length > 0) {
2746
- console.log(
2747
- `[${TOOL_NAME}] Docker Compose helper${label}: detected ${composeFiles.join(', ')}. ` +
2748
- `Set GUARDEX_DOCKER_SERVICE and run 'bash scripts/guardex-docker-loader.sh -- <command...>'.`,
597
+ if (fs.existsSync(metadata.worktreePath)) {
598
+ const removeResult = run(
599
+ 'git',
600
+ ['-C', repoRoot, 'worktree', 'remove', '--force', metadata.worktreePath],
601
+ { timeout: 30_000 },
2749
602
  );
603
+ if (isSpawnFailure(removeResult)) {
604
+ throw removeResult.error;
605
+ }
606
+ if (removeResult.status !== 0) {
607
+ throw new Error(
608
+ (removeResult.stderr || removeResult.stdout || 'failed to remove sandbox worktree').trim(),
609
+ );
610
+ }
611
+ result.worktree = 'removed';
612
+ } else {
613
+ result.worktree = 'missing';
2750
614
  }
2751
- }
2752
-
2753
- function workingTreeIsDirty(repoRoot) {
2754
- const result = gitRun(repoRoot, ['status', '--porcelain'], { allowFailure: true });
2755
- if (result.status !== 0) {
2756
- throw new Error('Unable to inspect git working tree status');
2757
- }
2758
- const lines = (result.stdout || '').split('\n').filter((line) => line.length > 0);
2759
- const significant = lines.filter((line) => {
2760
- const pathPart = (line.length > 3 ? line.slice(3) : '').trim();
2761
- if (!pathPart) return false;
2762
- if (pathPart === LOCK_FILE_RELATIVE) return false;
2763
- if (pathPart.startsWith(`${LOCK_FILE_RELATIVE} -> `)) return false;
2764
- if (pathPart.endsWith(` -> ${LOCK_FILE_RELATIVE}`)) return false;
2765
- return true;
2766
- });
2767
- return significant.length > 0;
2768
- }
2769
615
 
2770
- function ensureOriginBaseRef(repoRoot, baseBranch) {
2771
- const fetch = gitRun(repoRoot, ['fetch', 'origin', baseBranch, '--quiet'], { allowFailure: true });
2772
- if (fetch.status !== 0) {
2773
- throw new Error(
2774
- `Unable to fetch origin/${baseBranch}. Ensure remote 'origin' exists and branch '${baseBranch}' is available.`,
616
+ if (gitRefExists(repoRoot, `refs/heads/${metadata.branch}`)) {
617
+ const branchDeleteResult = run(
618
+ 'git',
619
+ ['-C', repoRoot, 'branch', '-D', metadata.branch],
620
+ { timeout: 20_000 },
2775
621
  );
2776
- }
2777
- const hasRemoteBase = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${baseBranch}`], {
2778
- allowFailure: true,
2779
- });
2780
- if (hasRemoteBase.status !== 0) {
2781
- throw new Error(`Remote base branch not found: origin/${baseBranch}`);
2782
- }
2783
- }
2784
-
2785
- function aheadBehind(repoRoot, branchRef, baseRef) {
2786
- const result = gitRun(repoRoot, ['rev-list', '--left-right', '--count', `${branchRef}...${baseRef}`], {
2787
- allowFailure: true,
2788
- });
2789
- if (result.status !== 0) {
2790
- throw new Error(`Unable to compute ahead/behind for ${branchRef} vs ${baseRef}`);
2791
- }
2792
- const parts = (result.stdout || '').trim().split(/\s+/).filter(Boolean);
2793
- const ahead = Number.parseInt(parts[0] || '0', 10);
2794
- const behind = Number.parseInt(parts[1] || '0', 10);
2795
- return { ahead: Number.isFinite(ahead) ? ahead : 0, behind: Number.isFinite(behind) ? behind : 0 };
2796
- }
2797
-
2798
- function lockRegistryStatus(repoRoot) {
2799
- const result = gitRun(repoRoot, ['status', '--porcelain', '--', LOCK_FILE_RELATIVE], { allowFailure: true });
2800
- if (result.status !== 0) {
2801
- return { dirty: false, untracked: false };
2802
- }
2803
- const lines = (result.stdout || '').split('\n').filter((line) => line.length > 0);
2804
- if (lines.length === 0) {
2805
- return { dirty: false, untracked: false };
2806
- }
2807
- const untracked = lines.some((line) => line.startsWith('??'));
2808
- return { dirty: true, untracked };
2809
- }
2810
-
2811
-
2812
- function listAgentWorktrees(repoRoot) {
2813
- const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
2814
- if (result.status !== 0) {
2815
- throw new Error('Unable to list git worktrees for finish command');
2816
- }
2817
-
2818
- const entries = [];
2819
- let currentPath = '';
2820
- let currentBranchRef = '';
2821
- const lines = String(result.stdout || '').split('\n');
2822
- for (const line of lines) {
2823
- if (!line.trim()) {
2824
- if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
2825
- entries.push({
2826
- worktreePath: currentPath,
2827
- branch: currentBranchRef.replace(/^refs\/heads\//, ''),
2828
- });
2829
- }
2830
- currentPath = '';
2831
- currentBranchRef = '';
2832
- continue;
2833
- }
2834
- if (line.startsWith('worktree ')) {
2835
- currentPath = line.slice('worktree '.length).trim();
2836
- continue;
622
+ if (isSpawnFailure(branchDeleteResult)) {
623
+ throw branchDeleteResult.error;
2837
624
  }
2838
- if (line.startsWith('branch ')) {
2839
- currentBranchRef = line.slice('branch '.length).trim();
2840
- continue;
625
+ if (branchDeleteResult.status !== 0) {
626
+ throw new Error(
627
+ (branchDeleteResult.stderr || branchDeleteResult.stdout || 'failed to delete sandbox branch').trim(),
628
+ );
2841
629
  }
2842
- }
2843
- if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
2844
- entries.push({
2845
- worktreePath: currentPath,
2846
- branch: currentBranchRef.replace(/^refs\/heads\//, ''),
2847
- });
630
+ result.branch = 'deleted';
631
+ } else {
632
+ result.branch = 'missing';
2848
633
  }
2849
634
 
2850
- return entries;
635
+ result.note = 'sandbox worktree pruned';
636
+ return result;
2851
637
  }
2852
638
 
2853
- function listLocalAgentBranchesForFinish(repoRoot) {
2854
- const result = gitRun(
2855
- repoRoot,
2856
- ['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent/'],
2857
- { allowFailure: true },
2858
- );
2859
- if (result.status !== 0) {
2860
- throw new Error('Unable to list local agent branches');
2861
- }
2862
- return uniquePreserveOrder(
2863
- String(result.stdout || '')
2864
- .split('\n')
2865
- .map((line) => line.trim())
2866
- .filter((line) => line.startsWith('agent/')),
2867
- );
2868
- }
639
+ function runSetupInSandbox(options, blocked, repoLabel = '') {
640
+ const startResult = startProtectedBaseSandbox(blocked, {
641
+ taskName: `${SHORT_TOOL_NAME}-setup`,
642
+ sandboxSuffix: 'gx-setup',
643
+ });
644
+ const metadata = startResult.metadata;
2869
645
 
2870
- function gitQuietChangeResult(worktreePath, args) {
2871
- const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
2872
- if (result.status === 0) {
2873
- return false;
2874
- }
2875
- if (result.status === 1) {
2876
- return true;
2877
- }
2878
- throw new Error(
2879
- `git ${args.join(' ')} failed in ${worktreePath}: ${(
2880
- result.stderr || result.stdout || ''
2881
- ).trim()}`,
646
+ if (startResult.stdout) process.stdout.write(startResult.stdout);
647
+ if (startResult.stderr) process.stderr.write(startResult.stderr);
648
+ console.log(
649
+ `[${TOOL_NAME}] setup blocked on protected branch '${blocked.branch}' in an initialized repo; ` +
650
+ 'refreshing through a sandbox worktree and syncing managed bootstrap files back locally.',
2882
651
  );
2883
- }
2884
652
 
2885
- function worktreeHasLocalChanges(worktreePath) {
2886
- const hasUnstaged = gitQuietChangeResult(worktreePath, [
2887
- 'diff',
2888
- '--quiet',
2889
- '--',
2890
- '.',
2891
- ':(exclude).omx/state/agent-file-locks.json',
2892
- ]);
2893
- if (hasUnstaged) {
2894
- return true;
653
+ const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
654
+ const nestedResult = run(
655
+ process.execPath,
656
+ [__filename, ...buildSandboxSetupArgs(options, sandboxTarget)],
657
+ { cwd: metadata.worktreePath },
658
+ );
659
+ if (isSpawnFailure(nestedResult)) {
660
+ throw nestedResult.error;
661
+ }
662
+ if (nestedResult.status !== 0) {
663
+ if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
664
+ if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
665
+ throw new Error(
666
+ `sandboxed setup failed for protected branch '${blocked.branch}'. ` +
667
+ `Inspect sandbox at ${metadata.worktreePath}`,
668
+ );
2895
669
  }
2896
670
 
2897
- const hasStaged = gitQuietChangeResult(worktreePath, [
2898
- 'diff',
2899
- '--cached',
2900
- '--quiet',
2901
- '--',
2902
- '.',
2903
- ':(exclude).omx/state/agent-file-locks.json',
2904
- ]);
2905
- if (hasStaged) {
2906
- return true;
671
+ const syncOptions = {
672
+ ...options,
673
+ target: blocked.repoRoot,
674
+ recursive: false,
675
+ allowProtectedBaseWrite: true,
676
+ };
677
+ const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(syncOptions);
678
+ printOperations(`Setup/install${repoLabel}`, installPayload, syncOptions.dryRun);
679
+ printOperations(`Setup/fix${repoLabel}`, fixPayload, syncOptions.dryRun);
680
+ if (!syncOptions.dryRun && parentWorkspace) {
681
+ console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
2907
682
  }
2908
683
 
2909
- const untracked = run('git', ['-C', worktreePath, 'ls-files', '--others', '--exclude-standard'], {
2910
- stdio: 'pipe',
684
+ const scanResult = runScanInternal({ target: blocked.repoRoot, json: false });
685
+ const currentBaseBranch = currentBranchName(scanResult.repoRoot);
686
+ const autoFinishSummary = doctorModule.autoFinishReadyAgentBranches(scanResult.repoRoot, {
687
+ baseBranch: currentBaseBranch,
688
+ dryRun: syncOptions.dryRun,
2911
689
  });
2912
- if (untracked.status !== 0) {
2913
- throw new Error(`Unable to inspect untracked files in ${worktreePath}`);
2914
- }
2915
- return String(untracked.stdout || '').trim().length > 0;
2916
- }
2917
-
2918
- function gitOutputLines(worktreePath, args) {
2919
- const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
2920
- if (result.status !== 0) {
2921
- throw new Error(
2922
- `git ${args.join(' ')} failed in ${worktreePath}: ${(
2923
- result.stderr || result.stdout || ''
2924
- ).trim()}`,
690
+ printScanResult(scanResult, false);
691
+ if (autoFinishSummary.enabled) {
692
+ console.log(
693
+ `[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
2925
694
  );
2926
- }
2927
- return String(result.stdout || '')
2928
- .split('\n')
2929
- .map((line) => line.trim())
2930
- .filter(Boolean);
2931
- }
2932
-
2933
- function claimLocksForAutoCommit(repoRoot, worktreePath, branch) {
2934
- const changedFiles = uniquePreserveOrder([
2935
- ...gitOutputLines(worktreePath, ['diff', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
2936
- ...gitOutputLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
2937
- ...gitOutputLines(worktreePath, ['ls-files', '--others', '--exclude-standard']),
2938
- ]);
2939
-
2940
- if (changedFiles.length > 0) {
2941
- const claim = runPackageAsset('lockTool', ['claim', '--branch', branch, ...changedFiles], {
2942
- cwd: repoRoot,
2943
- stdio: 'pipe',
2944
- });
2945
- if (claim.status !== 0) {
2946
- throw new Error(
2947
- `Lock claim failed for ${branch}: ${(
2948
- claim.stderr || claim.stdout || ''
2949
- ).trim()}`,
2950
- );
695
+ for (const detail of autoFinishSummary.details) {
696
+ console.log(`[${TOOL_NAME}] ${detail}`);
2951
697
  }
698
+ } else if (autoFinishSummary.details.length > 0) {
699
+ console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
2952
700
  }
2953
701
 
2954
- const deletedFiles = uniquePreserveOrder([
2955
- ...gitOutputLines(worktreePath, [
2956
- 'diff',
2957
- '--name-only',
2958
- '--diff-filter=D',
2959
- '--',
2960
- '.',
2961
- ':(exclude).omx/state/agent-file-locks.json',
2962
- ]),
2963
- ...gitOutputLines(worktreePath, [
2964
- 'diff',
2965
- '--cached',
2966
- '--name-only',
2967
- '--diff-filter=D',
2968
- '--',
2969
- '.',
2970
- ':(exclude).omx/state/agent-file-locks.json',
2971
- ]),
2972
- ]);
2973
-
2974
- if (deletedFiles.length > 0) {
2975
- const allowDelete = runPackageAsset('lockTool', ['allow-delete', '--branch', branch, ...deletedFiles], {
2976
- cwd: repoRoot,
2977
- stdio: 'pipe',
2978
- });
2979
- if (allowDelete.status !== 0) {
2980
- throw new Error(
2981
- `Delete-lock grant failed for ${branch}: ${(
2982
- allowDelete.stderr || allowDelete.stdout || ''
2983
- ).trim()}`,
2984
- );
2985
- }
2986
- }
2987
- }
702
+ const cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
703
+ console.log(
704
+ `[${TOOL_NAME}] Protected-base setup sandbox cleanup: ${cleanupResult.note} ` +
705
+ `(worktree=${cleanupResult.worktree}, branch=${cleanupResult.branch}).`,
706
+ );
2988
707
 
2989
- function branchExists(repoRoot, branch) {
2990
- const result = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
2991
- allowFailure: true,
2992
- });
2993
- return result.status === 0;
708
+ return {
709
+ scanResult,
710
+ };
2994
711
  }
2995
712
 
2996
- function resolveFinishBaseBranch(repoRoot, _sourceBranch, explicitBase) {
2997
- if (explicitBase) {
2998
- return explicitBase;
2999
- }
3000
713
 
3001
- const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
3002
- if (configured) {
3003
- return configured;
3004
- }
714
+ function todayDateStamp() {
715
+ return new Date().toISOString().slice(0, 10);
716
+ }
717
+
718
+ function inferGithubRepoFromOrigin(repoRoot) {
719
+ const rawOrigin = readGitConfig(repoRoot, 'remote.origin.url');
720
+ if (!rawOrigin) return '';
3005
721
 
3006
- return DEFAULT_BASE_BRANCH;
722
+ const httpsMatch = rawOrigin.match(/github\.com[:/](.+?)(?:\.git)?$/i);
723
+ if (!httpsMatch) return '';
724
+ const slug = (httpsMatch[1] || '').replace(/^\/+/, '').trim();
725
+ if (!slug || !slug.includes('/')) return '';
726
+ return `github.com/${slug}`;
3007
727
  }
3008
728
 
3009
- function branchMergedIntoBase(repoRoot, branch, baseBranch) {
3010
- if (!branchExists(repoRoot, baseBranch)) {
3011
- return false;
3012
- }
3013
- const result = gitRun(repoRoot, ['merge-base', '--is-ancestor', branch, baseBranch], {
3014
- allowFailure: true,
3015
- });
3016
- if (result.status === 0) {
3017
- return true;
3018
- }
3019
- if (result.status === 1) {
3020
- return false;
3021
- }
3022
- throw new Error(`Unable to determine merge status for ${branch} -> ${baseBranch}`);
729
+ function inferGithubRepoSlug(rawValue) {
730
+ const raw = String(rawValue || '').trim();
731
+ if (!raw) return '';
732
+ const match = raw.match(/github\.com[:/](.+?)(?:\.git)?$/i);
733
+ if (!match) return '';
734
+ const slug = String(match[1] || '')
735
+ .replace(/^\/+/, '')
736
+ .replace(/^github\.com\//i, '')
737
+ .trim();
738
+ if (!slug || !slug.includes('/')) return '';
739
+ return slug;
3023
740
  }
3024
741
 
3025
- function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) {
3026
- const hasChanges = worktreeHasLocalChanges(worktreePath);
3027
- if (!hasChanges) {
3028
- return { changed: false, committed: false };
742
+ function resolveScorecardRepo(repoRoot, explicitRepo) {
743
+ if (explicitRepo) {
744
+ return explicitRepo.trim();
3029
745
  }
746
+ const inferred = inferGithubRepoFromOrigin(repoRoot);
747
+ if (inferred) return inferred;
748
+ throw new Error(
749
+ 'Unable to infer GitHub repo from origin remote. Pass --repo github.com/<owner>/<repo>.',
750
+ );
751
+ }
3030
752
 
3031
- if (options.noAutoCommit) {
753
+ function runScorecardJson(repo) {
754
+ const result = run(SCORECARD_BIN, ['--repo', repo, '--format', 'json'], { allowFailure: true });
755
+ if (result.status !== 0) {
756
+ const details = (result.stderr || result.stdout || '').trim();
3032
757
  throw new Error(
3033
- `Branch '${branch}' has local changes in ${worktreePath}. Re-run without --no-auto-commit or commit manually first.`,
758
+ `Failed to run scorecard CLI ('${SCORECARD_BIN} --repo ${repo} --format json').${details ? `\n${details}` : ''}`,
3034
759
  );
3035
760
  }
3036
761
 
3037
- if (options.dryRun) {
3038
- return { changed: true, committed: false, dryRun: true };
3039
- }
3040
-
3041
- claimLocksForAutoCommit(repoRoot, worktreePath, branch);
3042
-
3043
- const addResult = run('git', ['-C', worktreePath, 'add', '-A'], { stdio: 'pipe' });
3044
- if (addResult.status !== 0) {
3045
- throw new Error(`git add failed in ${worktreePath}: ${(addResult.stderr || addResult.stdout || '').trim()}`);
3046
- }
3047
-
3048
- const stagedHasChanges = gitQuietChangeResult(worktreePath, [
3049
- 'diff',
3050
- '--cached',
3051
- '--quiet',
3052
- '--',
3053
- '.',
3054
- ':(exclude).omx/state/agent-file-locks.json',
3055
- ]);
3056
- if (!stagedHasChanges) {
3057
- return { changed: true, committed: false };
3058
- }
3059
-
3060
- const commitMessage = options.commitMessage || `Auto-finish: ${branch}`;
3061
- const commitResult = run('git', ['-C', worktreePath, 'commit', '-m', commitMessage], { stdio: 'pipe' });
3062
- if (commitResult.status !== 0) {
3063
- throw new Error(
3064
- `Auto-commit failed on '${branch}': ${(
3065
- commitResult.stderr || commitResult.stdout || ''
3066
- ).trim()}`,
3067
- );
762
+ try {
763
+ return JSON.parse(result.stdout || '{}');
764
+ } catch (error) {
765
+ throw new Error(`Unable to parse scorecard JSON output: ${error.message}`);
3068
766
  }
3069
-
3070
- return { changed: true, committed: true, message: commitMessage };
3071
767
  }
3072
768
 
3073
- function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
3074
- if (strategy === 'rebase') {
3075
- if (ffOnly) {
3076
- throw new Error('--ff-only is only supported with --strategy merge');
3077
- }
3078
- const rebased = run('git', ['-C', repoRoot, 'rebase', baseRef], { stdio: 'pipe' });
3079
- if (rebased.status !== 0) {
3080
- const details = (rebased.stderr || rebased.stdout || '').trim();
3081
- const gitDir = path.join(repoRoot, '.git');
3082
- const rebaseActive = fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'));
3083
- const help = rebaseActive
3084
- ? '\nResolve conflicts, then run: git rebase --continue\nOr abort: git rebase --abort'
3085
- : '';
3086
- throw new Error(`Sync failed during rebase onto ${baseRef}.${details ? `\n${details}` : ''}${help}`);
3087
- }
3088
- return;
3089
- }
3090
-
3091
- const mergeArgs = ['-C', repoRoot, 'merge', '--no-edit'];
3092
- if (ffOnly) {
3093
- mergeArgs.push('--ff-only');
769
+ function readScorecardJsonFile(filePath) {
770
+ const absolute = path.resolve(filePath);
771
+ if (!fs.existsSync(absolute)) {
772
+ throw new Error(`scorecard JSON file not found: ${absolute}`);
3094
773
  }
3095
- mergeArgs.push(baseRef);
3096
- const merged = run('git', mergeArgs, { stdio: 'pipe' });
3097
- if (merged.status !== 0) {
3098
- const details = (merged.stderr || merged.stdout || '').trim();
3099
- const gitDir = path.join(repoRoot, '.git');
3100
- const mergeActive = fs.existsSync(path.join(gitDir, 'MERGE_HEAD'));
3101
- const help = mergeActive ? '\nResolve conflicts, then run: git commit\nOr abort: git merge --abort' : '';
3102
- throw new Error(`Sync failed during merge from ${baseRef}.${details ? `\n${details}` : ''}${help}`);
774
+ try {
775
+ return JSON.parse(fs.readFileSync(absolute, 'utf8'));
776
+ } catch (error) {
777
+ throw new Error(`Unable to parse scorecard JSON file: ${error.message}`);
3103
778
  }
3104
779
  }
3105
780
 
3106
- function isInteractiveTerminal() {
3107
- return Boolean(process.stdin.isTTY && process.stdout.isTTY);
781
+ function normalizeScorecardChecks(payload) {
782
+ const rawChecks = Array.isArray(payload?.checks) ? payload.checks : [];
783
+ return rawChecks.map((check) => {
784
+ const name = String(check?.name || 'Unknown');
785
+ const rawScore = Number(check?.score);
786
+ const score = Number.isFinite(rawScore) ? rawScore : 0;
787
+ return {
788
+ name,
789
+ score,
790
+ risk: SCORECARD_RISK_BY_CHECK[name] || 'Unknown',
791
+ };
792
+ });
3108
793
  }
3109
794
 
3110
- const stdinWaitArray = new Int32Array(new SharedArrayBuffer(4));
795
+ function renderScorecardBaselineMarkdown({ repo, score, checks, capturedAt, scorecardVersion, reportDate }) {
796
+ const rows = checks
797
+ .map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`)
798
+ .join('\n');
3111
799
 
3112
- function sleepSyncMs(milliseconds) {
3113
- Atomics.wait(stdinWaitArray, 0, 0, milliseconds);
800
+ return [
801
+ '# OpenSSF Scorecard Baseline Report',
802
+ '',
803
+ `- **Repository:** \`${repo}\``,
804
+ '- **Source:** generated by `gx report scorecard`',
805
+ `- **Captured at:** ${capturedAt}`,
806
+ `- **Scorecard version:** \`${scorecardVersion}\``,
807
+ `- **Overall score:** **${score} / 10**`,
808
+ '',
809
+ '## Check breakdown',
810
+ '',
811
+ '| Check | Score | Risk |',
812
+ '|---|---:|---|',
813
+ rows || '| (none) | 0 | Unknown |',
814
+ '',
815
+ `## Report date`,
816
+ '',
817
+ `- ${reportDate}`,
818
+ '',
819
+ ].join('\n');
3114
820
  }
3115
821
 
3116
- function readSingleLineFromStdin() {
3117
- let input = '';
3118
- const buffer = Buffer.alloc(1);
822
+ function renderScorecardRemediationPlanMarkdown({ baselineRelativePath, checks }) {
823
+ const failing = checks.filter((item) => item.score < 10);
824
+ const failingRows = failing
825
+ .sort((a, b) => a.score - b.score || a.name.localeCompare(b.name))
826
+ .map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`)
827
+ .join('\n');
3119
828
 
3120
- while (true) {
3121
- let bytesRead = 0;
3122
- try {
3123
- bytesRead = fs.readSync(process.stdin.fd, buffer, 0, 1);
3124
- } catch (error) {
3125
- if (error && ['EAGAIN', 'EWOULDBLOCK', 'EINTR'].includes(error.code)) {
3126
- sleepSyncMs(15);
3127
- continue;
3128
- }
3129
- return input;
3130
- }
829
+ return [
830
+ '# OpenSSF Scorecard Remediation Plan',
831
+ '',
832
+ `Based on baseline report: \`${baselineRelativePath}\`.`,
833
+ '',
834
+ '## Failing checks',
835
+ '',
836
+ '| Check | Score | Risk |',
837
+ '|---|---:|---|',
838
+ (failingRows || '| None | 10 | N/A |'),
839
+ '',
840
+ '## Priority order',
841
+ '',
842
+ '1. Fix **High** risk checks first (especially score 0 items).',
843
+ '2. Then close **Medium** risk checks with score < 10.',
844
+ '3. Finally address **Low** risk ecosystem/process checks.',
845
+ '',
846
+ '## Verification loop',
847
+ '',
848
+ '1. Run scorecard again.',
849
+ '2. Re-generate baseline + remediation files.',
850
+ '3. Compare score deltas and track improved checks.',
851
+ '',
852
+ ].join('\n');
853
+ }
3131
854
 
3132
- if (bytesRead === 0) {
3133
- if (process.stdin.isTTY) {
3134
- sleepSyncMs(15);
3135
- continue;
3136
- }
3137
- return input;
3138
- }
855
+ function parseBranchList(rawValue) {
856
+ return String(rawValue || '')
857
+ .split(/[\s,]+/)
858
+ .map((item) => item.trim())
859
+ .filter(Boolean);
860
+ }
3139
861
 
3140
- const char = buffer.toString('utf8', 0, bytesRead);
3141
- if (char === '\n' || char === '\r') {
3142
- return input;
3143
- }
3144
- input += char;
862
+ function originRemoteLooksLikeGithub(repoRoot) {
863
+ const originUrl = readGitConfig(repoRoot, 'remote.origin.url');
864
+ if (!originUrl) {
865
+ return false;
3145
866
  }
867
+ return /github\.com[:/]/i.test(originUrl);
868
+ }
869
+
870
+ function isInteractiveTerminal() {
871
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
3146
872
  }
3147
873
 
3148
874
  function parseAutoApproval(name) {
@@ -3223,38 +949,6 @@ function describeGuardexRepoToggle(toggle) {
3223
949
  return `${toggle.source} (${GUARDEX_REPO_TOGGLE_ENV}=${toggle.raw})`;
3224
950
  }
3225
951
 
3226
- function parseVersionString(version) {
3227
- const match = String(version || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
3228
- if (!match) return null;
3229
- return [
3230
- Number.parseInt(match[1], 10),
3231
- Number.parseInt(match[2], 10),
3232
- Number.parseInt(match[3], 10),
3233
- ];
3234
- }
3235
-
3236
- function compareParsedVersions(left, right) {
3237
- if (!left || !right) return 0;
3238
- for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
3239
- const leftValue = left[index] || 0;
3240
- const rightValue = right[index] || 0;
3241
- if (leftValue > rightValue) return 1;
3242
- if (leftValue < rightValue) return -1;
3243
- }
3244
- return 0;
3245
- }
3246
-
3247
- function isNewerVersion(latest, current) {
3248
- const latestParts = parseVersionString(latest);
3249
- const currentParts = parseVersionString(current);
3250
-
3251
- if (!latestParts || !currentParts) {
3252
- return String(latest || '').trim() !== String(current || '').trim();
3253
- }
3254
-
3255
- return compareParsedVersions(latestParts, currentParts) > 0;
3256
- }
3257
-
3258
952
  function parseNpmVersionOutput(stdout) {
3259
953
  const trimmed = String(stdout || '').trim();
3260
954
  if (!trimmed) return '';
@@ -3308,7 +1002,7 @@ function printUpdateAvailableBanner(current, latest) {
3308
1002
  }
3309
1003
 
3310
1004
  function maybeSelfUpdateBeforeStatus() {
3311
- return getToolchainApi().maybeSelfUpdateBeforeStatus();
1005
+ return toolchainModule.maybeSelfUpdateBeforeStatus();
3312
1006
  }
3313
1007
 
3314
1008
  function readInstalledGuardexVersion() {
@@ -3443,7 +1137,7 @@ function printOpenSpecUpdateAvailableBanner(current, latest) {
3443
1137
  }
3444
1138
 
3445
1139
  function maybeOpenSpecUpdateBeforeStatus() {
3446
- return getToolchainApi().maybeOpenSpecUpdateBeforeStatus();
1140
+ return toolchainModule.maybeOpenSpecUpdateBeforeStatus();
3447
1141
  }
3448
1142
 
3449
1143
  function promptYesNoStrict(question) {
@@ -3622,7 +1316,7 @@ function askGlobalInstallForMissing(options, missingPackages, missingLocalTools)
3622
1316
  }
3623
1317
 
3624
1318
  function installGlobalToolchain(options) {
3625
- return getToolchainApi().installGlobalToolchain(options);
1319
+ return toolchainModule.installGlobalToolchain(options);
3626
1320
  }
3627
1321
 
3628
1322
  function findStaleLockPaths(repoRoot, locks) {
@@ -3686,6 +1380,7 @@ function runInstallInternal(options) {
3686
1380
  ),
3687
1381
  );
3688
1382
  }
1383
+ operations.push(...materializePackageRepoTemplateFiles(repoRoot, TEMPLATE_FILES, Boolean(options.dryRun)));
3689
1384
  operations.push(...ensureTargetedLegacyWorkflowShims(repoRoot, options));
3690
1385
  for (const hookName of HOOK_NAMES) {
3691
1386
  const hookRelativePath = path.posix.join('.githooks', hookName);
@@ -3742,6 +1437,7 @@ function runFixInternal(options) {
3742
1437
  }
3743
1438
  operations.push(ensureTemplateFilePresent(repoRoot, templateFile, Boolean(options.dryRun)));
3744
1439
  }
1440
+ operations.push(...materializePackageRepoTemplateFiles(repoRoot, TEMPLATE_FILES, Boolean(options.dryRun)));
3745
1441
  operations.push(...ensureTargetedLegacyWorkflowShims(repoRoot, options));
3746
1442
  for (const hookName of HOOK_NAMES) {
3747
1443
  const hookRelativePath = path.posix.join('.githooks', hookName);
@@ -3982,9 +1678,9 @@ function status(rawArgs) {
3982
1678
  json: false,
3983
1679
  });
3984
1680
 
3985
- const toolchain = detectGlobalToolchainPackages();
1681
+ const toolchain = toolchainModule.detectGlobalToolchainPackages();
3986
1682
  const npmServices = GLOBAL_TOOLCHAIN_PACKAGES.map((pkg) => {
3987
- const service = getGlobalToolchainService(pkg);
1683
+ const service = toolchainModule.getGlobalToolchainService(pkg);
3988
1684
  if (!toolchain.ok) {
3989
1685
  return {
3990
1686
  name: service.name,
@@ -4002,12 +1698,12 @@ function status(rawArgs) {
4002
1698
  status: toolchain.installed.includes(pkg) ? 'active' : 'inactive',
4003
1699
  };
4004
1700
  });
4005
- const localCompanionServices = detectOptionalLocalCompanionTools().map((tool) => ({
1701
+ const localCompanionServices = toolchainModule.detectOptionalLocalCompanionTools().map((tool) => ({
4006
1702
  name: tool.name,
4007
1703
  displayName: tool.displayName || tool.name,
4008
1704
  status: tool.status,
4009
1705
  }));
4010
- const requiredSystemTools = detectRequiredSystemTools();
1706
+ const requiredSystemTools = toolchainModule.detectRequiredSystemTools();
4011
1707
  const services = [
4012
1708
  ...npmServices,
4013
1709
  ...localCompanionServices,
@@ -4076,7 +1772,7 @@ function status(rawArgs) {
4076
1772
  console.log(
4077
1773
  `[${TOOL_NAME}] Optional companion tools inactive: ${inactiveOptionalCompanions.join(', ')}`,
4078
1774
  );
4079
- for (const warning of describeMissingGlobalDependencyWarnings(
1775
+ for (const warning of toolchainModule.describeMissingGlobalDependencyWarnings(
4080
1776
  npmServices
4081
1777
  .filter((service) => service.status === 'inactive')
4082
1778
  .map((service) => service.packageName),
@@ -4333,7 +2029,13 @@ function doctor(rawArgs) {
4333
2029
 
4334
2030
  const blocked = protectedBaseWriteBlock(singleRepoOptions, { requireBootstrap: false });
4335
2031
  if (blocked) {
4336
- runDoctorInSandbox(singleRepoOptions, blocked);
2032
+ doctorModule.runDoctorInSandbox(singleRepoOptions, blocked, {
2033
+ startProtectedBaseSandbox,
2034
+ cleanupProtectedBaseSandbox,
2035
+ ensureOmxScaffold,
2036
+ configureHooks,
2037
+ autoFinishReadyAgentBranches: doctorModule.autoFinishReadyAgentBranches,
2038
+ });
4337
2039
  return;
4338
2040
  }
4339
2041
 
@@ -4350,7 +2052,7 @@ function doctor(rawArgs) {
4350
2052
  failed: 0,
4351
2053
  details: [],
4352
2054
  }
4353
- : autoFinishReadyAgentBranches(scanResult.repoRoot, {
2055
+ : doctorModule.autoFinishReadyAgentBranches(scanResult.repoRoot, {
4354
2056
  baseBranch: currentBaseBranch,
4355
2057
  dryRun: singleRepoOptions.dryRun,
4356
2058
  waitForMerge: singleRepoOptions.waitForMerge,
@@ -4454,10 +2156,15 @@ function processAlive(pid) {
4454
2156
  }
4455
2157
  try {
4456
2158
  process.kill(normalizedPid, 0);
4457
- return true;
4458
2159
  } catch (_error) {
4459
2160
  return false;
4460
2161
  }
2162
+
2163
+ const state = readProcessState(normalizedPid);
2164
+ if (state.startsWith('Z')) {
2165
+ return false;
2166
+ }
2167
+ return true;
4461
2168
  }
4462
2169
 
4463
2170
  function sleepSeconds(seconds) {
@@ -4475,6 +2182,14 @@ function readProcessCommand(pid) {
4475
2182
  return String(result.stdout || '').trim();
4476
2183
  }
4477
2184
 
2185
+ function readProcessState(pid) {
2186
+ const result = run('ps', ['-o', 'stat=', '-p', String(pid)]);
2187
+ if (isSpawnFailure(result) || result.status !== 0) {
2188
+ return '';
2189
+ }
2190
+ return String(result.stdout || '').trim();
2191
+ }
2192
+
4478
2193
  function stopAgentProcessByPid(pid, expectedToken = '') {
4479
2194
  const normalizedPid = Number.parseInt(String(pid || ''), 10);
4480
2195
  if (!Number.isInteger(normalizedPid) || normalizedPid <= 0) {
@@ -4666,6 +2381,16 @@ function agents(rawArgs) {
4666
2381
  }
4667
2382
 
4668
2383
  if (options.subcommand === 'stop') {
2384
+ if (options.pid) {
2385
+ const stopResult = stopAgentProcessByPid(options.pid);
2386
+ const success = ['stopped', 'not-running'].includes(stopResult.status);
2387
+ console.log(
2388
+ `[${TOOL_NAME}] Stopped agent pid ${options.pid} (${stopResult.status}).`,
2389
+ );
2390
+ process.exitCode = success ? 0 : 1;
2391
+ return;
2392
+ }
2393
+
4669
2394
  const existingState = readAgentsState(repoRoot);
4670
2395
  if (!existingState) {
4671
2396
  console.log(`[${TOOL_NAME}] Repo agents are not running for ${repoRoot}.`);
@@ -4709,15 +2434,29 @@ function report(rawArgs) {
4709
2434
  console.log(
4710
2435
  `${TOOL_NAME} report commands:\n` +
4711
2436
  ` ${TOOL_NAME} report scorecard [--target <path>] [--repo github.com/<owner>/<repo>] [--scorecard-json <file>] [--output-dir <path>] [--date YYYY-MM-DD] [--dry-run] [--json]\n` +
2437
+ ` ${TOOL_NAME} report session-severity --task-size <narrow-patch|medium-change|large-change> --tokens <count> --exec-count <count> --write-stdin-count <count> --completion-before-tail <yes|no> [--expected-bound <count>] [--fragmentation <preset|0-25>] [--finish-path <preset|0-15>] [--post-proof <preset|0-15>] [--json]\n` +
4712
2438
  `\n` +
4713
2439
  `Examples:\n` +
4714
2440
  ` ${TOOL_NAME} report scorecard --repo github.com/recodeecom/multiagent-safety\n` +
4715
- ` ${TOOL_NAME} report scorecard --scorecard-json ./scorecard.json --date 2026-04-10`,
2441
+ ` ${TOOL_NAME} report scorecard --scorecard-json ./scorecard.json --date 2026-04-10\n` +
2442
+ ` ${TOOL_NAME} report session-severity --task-size narrow-patch --tokens 3850000 --exec-count 18 --write-stdin-count 6 --completion-before-tail yes --fragmentation 14 --finish-path 6 --post-proof 4`,
4716
2443
  );
4717
2444
  process.exitCode = 0;
4718
2445
  return;
4719
2446
  }
4720
2447
 
2448
+ if (subcommand === 'session-severity') {
2449
+ const payload = sessionSeverityReport.buildSessionSeverityReport(options);
2450
+ if (options.json) {
2451
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
2452
+ process.exitCode = 0;
2453
+ return;
2454
+ }
2455
+ console.log(sessionSeverityReport.renderSessionSeverityReport(payload));
2456
+ process.exitCode = 0;
2457
+ return;
2458
+ }
2459
+
4721
2460
  if (subcommand !== 'scorecard') {
4722
2461
  throw new Error(`Unknown report subcommand: ${subcommand}`);
4723
2462
  }
@@ -4806,7 +2545,7 @@ function setup(rawArgs) {
4806
2545
  allowProtectedBaseWrite: false,
4807
2546
  });
4808
2547
 
4809
- const globalInstallStatus = installGlobalToolchain(options);
2548
+ const globalInstallStatus = toolchainModule.installGlobalToolchain(options);
4810
2549
  if (globalInstallStatus.status === 'installed') {
4811
2550
  console.log(
4812
2551
  `[${TOOL_NAME}] ✅ Companion tools installed (${(globalInstallStatus.packages || []).join(', ')}).`,
@@ -4814,7 +2553,7 @@ function setup(rawArgs) {
4814
2553
  } else if (globalInstallStatus.status === 'already-installed') {
4815
2554
  console.log(`[${TOOL_NAME}] ✅ Companion tools already installed. Skipping.`);
4816
2555
  } else if (globalInstallStatus.status === 'failed') {
4817
- const installCommands = describeCompanionInstallCommands(
2556
+ const installCommands = toolchainModule.describeCompanionInstallCommands(
4818
2557
  GLOBAL_TOOLCHAIN_PACKAGES,
4819
2558
  OPTIONAL_LOCAL_COMPANION_TOOLS,
4820
2559
  );
@@ -4830,13 +2569,13 @@ function setup(rawArgs) {
4830
2569
  );
4831
2570
  } else if (globalInstallStatus.status === 'skipped') {
4832
2571
  console.log(`[${TOOL_NAME}] ⚠️ Companion installs skipped by user choice.`);
4833
- for (const warning of describeMissingGlobalDependencyWarnings(
2572
+ for (const warning of toolchainModule.describeMissingGlobalDependencyWarnings(
4834
2573
  globalInstallStatus.missingPackages || [],
4835
2574
  )) {
4836
2575
  console.log(`[${TOOL_NAME}] ⚠️ ${warning}`);
4837
2576
  }
4838
2577
  }
4839
- const requiredSystemTools = detectRequiredSystemTools();
2578
+ const requiredSystemTools = toolchainModule.detectRequiredSystemTools();
4840
2579
  const missingSystemTools = requiredSystemTools.filter((tool) => tool.status !== 'active');
4841
2580
  if (missingSystemTools.length === 0) {
4842
2581
  console.log(`[${TOOL_NAME}] ✅ Required system tools available (${requiredSystemTools.map((tool) => tool.name).join(', ')}).`);
@@ -4904,7 +2643,7 @@ function setup(rawArgs) {
4904
2643
 
4905
2644
  const scanResult = runScanInternal({ target: repoPath, json: false });
4906
2645
  const currentBaseBranch = currentBranchName(scanResult.repoRoot);
4907
- const autoFinishSummary = autoFinishReadyAgentBranches(scanResult.repoRoot, {
2646
+ const autoFinishSummary = doctorModule.autoFinishReadyAgentBranches(scanResult.repoRoot, {
4908
2647
  baseBranch: currentBaseBranch,
4909
2648
  dryRun: perRepoOptions.dryRun,
4910
2649
  });
@@ -5226,26 +2965,59 @@ function copyCommands() {
5226
2965
  function prompt(rawArgs) {
5227
2966
  const args = Array.isArray(rawArgs) ? rawArgs : [];
5228
2967
  let variant = 'prompt';
5229
- for (const arg of args) {
2968
+ let listParts = false;
2969
+ const selectedParts = [];
2970
+ for (let index = 0; index < args.length; index += 1) {
2971
+ const arg = args[index];
5230
2972
  if (arg === '--exec' || arg === '--commands') variant = 'exec';
5231
2973
  else if (arg === '--snippet' || arg === '--agents') variant = 'snippet';
5232
2974
  else if (arg === '--prompt' || arg === '--full') variant = 'prompt';
2975
+ else if (arg === '--list-parts') listParts = true;
2976
+ else if (arg === '--part' || arg === '--parts') {
2977
+ const rawValue = args[index + 1];
2978
+ if (!rawValue || rawValue.startsWith('--')) {
2979
+ throw new Error(`${arg} requires a value`);
2980
+ }
2981
+ selectedParts.push(...parseAiSetupPartNames(rawValue));
2982
+ index += 1;
2983
+ } else if (arg.startsWith('--part=')) {
2984
+ selectedParts.push(...parseAiSetupPartNames(arg.slice('--part='.length)));
2985
+ } else if (arg.startsWith('--parts=')) {
2986
+ selectedParts.push(...parseAiSetupPartNames(arg.slice('--parts='.length)));
2987
+ }
5233
2988
  else if (arg === '-h' || arg === '--help') variant = 'help';
5234
2989
  else throw new Error(`Unknown option: ${arg}`);
5235
2990
  }
5236
2991
  if (variant === 'help') {
5237
2992
  console.log(
5238
2993
  `${SHORT_TOOL_NAME} prompt commands:\n` +
5239
- ` ${SHORT_TOOL_NAME} prompt Print AI setup checklist\n` +
5240
- ` ${SHORT_TOOL_NAME} prompt --exec Print setup commands only (shell-ready)\n` +
5241
- ` ${SHORT_TOOL_NAME} prompt --snippet Print the AGENTS.md managed-block template`,
2994
+ ` ${SHORT_TOOL_NAME} prompt Print AI setup checklist\n` +
2995
+ ` ${SHORT_TOOL_NAME} prompt --exec Print setup commands only (shell-ready)\n` +
2996
+ ` ${SHORT_TOOL_NAME} prompt --part <name> Print only the named checklist slice(s)\n` +
2997
+ ` ${SHORT_TOOL_NAME} prompt --exec --part <name> Print only the named exec-capable slice(s)\n` +
2998
+ ` ${SHORT_TOOL_NAME} prompt --list-parts List prompt part names\n` +
2999
+ ` ${SHORT_TOOL_NAME} prompt --exec --list-parts List exec-capable prompt part names\n` +
3000
+ ` ${SHORT_TOOL_NAME} prompt --snippet Print the AGENTS.md managed-block template`,
5242
3001
  );
5243
3002
  process.exitCode = 0;
5244
3003
  return;
5245
3004
  }
5246
- if (variant === 'exec') return copyCommands();
5247
- if (variant === 'snippet') return printAgentsSnippet();
5248
- return copyPrompt();
3005
+ if (variant === 'snippet') {
3006
+ if (listParts || selectedParts.length > 0) {
3007
+ throw new Error('--snippet does not support --list-parts or --part');
3008
+ }
3009
+ return printAgentsSnippet();
3010
+ }
3011
+ if (listParts) {
3012
+ if (selectedParts.length > 0) {
3013
+ throw new Error('--list-parts does not support --part');
3014
+ }
3015
+ process.stdout.write(`${listAiSetupPartNames({ execOnly: variant === 'exec' }).join('\n')}\n`);
3016
+ process.exitCode = 0;
3017
+ return;
3018
+ }
3019
+ process.stdout.write(renderAiSetupPrompt({ exec: variant === 'exec', parts: selectedParts }));
3020
+ process.exitCode = 0;
5249
3021
  }
5250
3022
 
5251
3023
  function branch(rawArgs) {
@@ -5376,19 +3148,19 @@ function migrate(rawArgs) {
5376
3148
  }
5377
3149
 
5378
3150
  function cleanup(rawArgs) {
5379
- return getFinishApi().cleanup(rawArgs);
3151
+ return finishCommands.cleanup(rawArgs);
5380
3152
  }
5381
3153
 
5382
3154
  function merge(rawArgs) {
5383
- return getFinishApi().merge(rawArgs);
3155
+ return finishCommands.merge(rawArgs);
5384
3156
  }
5385
3157
 
5386
3158
  function finish(rawArgs, defaults = {}) {
5387
- return getFinishApi().finish(rawArgs, defaults);
3159
+ return finishCommands.finish(rawArgs, defaults);
5388
3160
  }
5389
3161
 
5390
3162
  function sync(rawArgs) {
5391
- return getFinishApi().sync(rawArgs);
3163
+ return finishCommands.sync(rawArgs);
5392
3164
  }
5393
3165
 
5394
3166
  function protect(rawArgs) {
@@ -5473,8 +3245,8 @@ function main() {
5473
3245
  const args = process.argv.slice(2);
5474
3246
 
5475
3247
  if (args.length === 0) {
5476
- maybeSelfUpdateBeforeStatus();
5477
- maybeOpenSpecUpdateBeforeStatus();
3248
+ toolchainModule.maybeSelfUpdateBeforeStatus();
3249
+ toolchainModule.maybeOpenSpecUpdateBeforeStatus();
5478
3250
  status([]);
5479
3251
  return;
5480
3252
  }
@@ -5488,7 +3260,7 @@ function main() {
5488
3260
  }
5489
3261
 
5490
3262
  if (command === '--version' || command === '-v' || command === 'version') {
5491
- maybeSelfUpdateBeforeStatus();
3263
+ toolchainModule.maybeSelfUpdateBeforeStatus();
5492
3264
  console.log(packageJson.version);
5493
3265
  return;
5494
3266
  }