@imdeadpool/guardex 7.0.21 → 7.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/main.js CHANGED
@@ -3,7 +3,8 @@
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');
7
8
  const {
8
9
  fs,
9
10
  path,
@@ -60,6 +61,9 @@ const {
60
61
  DEPRECATED_COMMAND_ALIASES,
61
62
  envFlagIsTruthy,
62
63
  defaultAgentWorktreeRelativeDir,
64
+ listAiSetupPartNames,
65
+ parseAiSetupPartNames,
66
+ renderAiSetupPrompt,
63
67
  AI_SETUP_PROMPT,
64
68
  AI_SETUP_COMMANDS,
65
69
  SCORECARD_RISK_BY_CHECK,
@@ -69,6 +73,37 @@ const {
69
73
  resolveRepoRoot,
70
74
  isGitRepo,
71
75
  discoverNestedGitRepos,
76
+ uniquePreserveOrder,
77
+ listLocalUserBranches,
78
+ listLocalAgentBranches,
79
+ mapWorktreePathsByBranch,
80
+ gitRefExists,
81
+ hasSignificantWorkingTreeChanges,
82
+ readConfiguredProtectedBranches,
83
+ readProtectedBranches,
84
+ ensureSetupProtectedBranches,
85
+ writeProtectedBranches,
86
+ readGitConfig,
87
+ resolveBaseBranch,
88
+ resolveSyncStrategy,
89
+ currentBranchName,
90
+ repoHasHeadCommit,
91
+ readBranchDisplayName,
92
+ hasOriginRemote: repoHasOriginRemote,
93
+ detectComposeHintFiles,
94
+ printSetupRepoHints,
95
+ ensureRepoBranch,
96
+ ensureOriginBaseRef,
97
+ workingTreeIsDirty,
98
+ aheadBehind,
99
+ lockRegistryStatus,
100
+ listAgentWorktrees,
101
+ listLocalAgentBranchesForFinish,
102
+ worktreeHasLocalChanges,
103
+ branchExists,
104
+ resolveFinishBaseBranch,
105
+ branchMergedIntoBase,
106
+ syncOperation,
72
107
  } = require('../git');
73
108
  const {
74
109
  run,
@@ -119,94 +154,30 @@ const {
119
154
  renderShellDispatchShim,
120
155
  renderPythonDispatchShim,
121
156
  managedForceConflictMessage,
157
+ renderManagedFile,
158
+ ensureGeneratedScriptShim,
159
+ ensureHookShim,
160
+ copyTemplateFile,
161
+ ensureTemplateFilePresent,
162
+ ensureOmxScaffold,
163
+ ensureLockRegistry,
164
+ lockStateOrError,
165
+ writeLockState,
166
+ removeLegacyPackageScripts,
167
+ installUserLevelAsset,
168
+ removeLegacyManagedRepoFile,
169
+ ensureAgentsSnippet,
170
+ ensureManagedGitignore,
171
+ stripJsonComments,
172
+ stripJsonTrailingCommas,
173
+ parseJsonObjectLikeFile,
174
+ buildRepoVscodeSettings,
175
+ ensureRepoVscodeSettings,
176
+ configureHooks,
122
177
  printOperations,
123
178
  printStandaloneOperations,
124
179
  } = require('../scaffold');
125
180
 
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
181
  /**
211
182
  * @typedef {Object} AutoFinishSummary
212
183
  * @property {boolean} [enabled]
@@ -261,116 +232,27 @@ function getFinishApi() {
261
232
  * @property {AutoFinishSummary} autoFinish
262
233
  * @property {string | null} sandboxLockContent
263
234
  */
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
-
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
235
 
329
- ensureParentDir(repoRoot, destinationPath, dryRun);
330
- if (!dryRun) {
331
- fs.writeFileSync(destinationPath, sourceContent, 'utf8');
332
- ensureExecutable(destinationPath, destinationRelativePath, dryRun);
236
+ function appendForceArgs(args, options) {
237
+ if (!options.force) {
238
+ return;
333
239
  }
334
-
335
- if (destinationExists && !force && isCriticalGuardrailPath(destinationRelativePath)) {
336
- return { status: dryRun ? 'would-repair-critical' : 'repaired-critical', file: destinationRelativePath };
240
+ args.push('--force');
241
+ for (const managedPath of options.forceManagedPaths || []) {
242
+ args.push(managedPath);
337
243
  }
338
-
339
- return { status: destinationExists ? 'overwritten' : 'created', file: destinationRelativePath };
340
244
  }
341
245
 
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 };
246
+ function shouldForceManagedPath(options, relativePath) {
247
+ if (!options.force) {
248
+ return false;
365
249
  }
366
-
367
- ensureParentDir(repoRoot, destinationPath, dryRun);
368
- if (!dryRun) {
369
- fs.writeFileSync(destinationPath, sourceContent, 'utf8');
370
- ensureExecutable(destinationPath, destinationRelativePath, dryRun);
250
+ const targetedPaths = Array.isArray(options.forceManagedPaths) ? options.forceManagedPaths : [];
251
+ if (targetedPaths.length === 0) {
252
+ return true;
371
253
  }
372
-
373
- return { status: 'created', file: destinationRelativePath };
254
+ const normalized = normalizeManagedForcePath(relativePath);
255
+ return normalized !== null && targetedPaths.includes(normalized);
374
256
  }
375
257
 
376
258
  function ensureTargetedLegacyWorkflowShims(repoRoot, options) {
@@ -389,2718 +271,595 @@ function ensureTargetedLegacyWorkflowShims(repoRoot, options) {
389
271
  return operations;
390
272
  }
391
273
 
392
- function lockFilePath(repoRoot) {
393
- return path.join(repoRoot, LOCK_FILE_RELATIVE);
274
+ function normalizeWorkspacePath(relativePath) {
275
+ return String(relativePath || '.').replace(/\\/g, '/');
394
276
  }
395
277
 
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
- }
278
+ function isCommandAvailable(commandName) {
279
+ return run('which', [commandName]).status === 0;
280
+ }
414
281
 
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
- }
282
+ function buildParentWorkspaceView(repoRoot) {
283
+ const parentDir = path.dirname(repoRoot);
284
+ const workspaceFileName = `${path.basename(repoRoot)}-branches.code-workspace`;
285
+ const workspacePath = path.join(parentDir, workspaceFileName);
286
+ const repoRelativePath = normalizeWorkspacePath(path.relative(parentDir, repoRoot) || '.');
424
287
 
425
- if (!dryRun) {
426
- fs.mkdirSync(absoluteDir, { recursive: true });
427
- }
428
- operations.push({ status: 'created', file: relativeDir });
429
- }
288
+ return {
289
+ workspacePath,
290
+ payload: {
291
+ folders: [
292
+ { path: repoRelativePath },
293
+ ...AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => ({
294
+ path: normalizeWorkspacePath(path.join(repoRelativePath === '.' ? '' : repoRelativePath, relativeDir)),
295
+ })),
296
+ ],
297
+ settings: {
298
+ 'scm.alwaysShowRepositories': true,
299
+ },
300
+ },
301
+ };
302
+ }
430
303
 
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
- }
304
+ function ensureParentWorkspaceView(repoRoot, dryRun) {
305
+ const { workspacePath, payload } = buildParentWorkspaceView(repoRoot);
306
+ const operationFile = path.relative(repoRoot, workspacePath) || path.basename(workspacePath);
307
+ const nextContent = `${JSON.stringify(payload, null, 2)}\n`;
308
+ const note = 'parent VS Code workspace view';
440
309
 
310
+ if (!fs.existsSync(workspacePath)) {
441
311
  if (!dryRun) {
442
- fs.mkdirSync(path.dirname(absoluteFile), { recursive: true });
443
- fs.writeFileSync(absoluteFile, defaultContent, 'utf8');
312
+ fs.writeFileSync(workspacePath, nextContent, 'utf8');
444
313
  }
445
- operations.push({ status: 'created', file: relativeFile });
314
+ return { status: dryRun ? 'would-create' : 'created', file: operationFile, note };
446
315
  }
447
316
 
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 };
317
+ const currentContent = fs.readFileSync(workspacePath, 'utf8');
318
+ if (currentContent === nextContent) {
319
+ return { status: 'unchanged', file: operationFile, note };
455
320
  }
456
321
 
457
322
  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}` };
323
+ fs.writeFileSync(workspacePath, nextContent, 'utf8');
491
324
  }
325
+ return { status: dryRun ? 'would-update' : 'updated', file: operationFile, note };
492
326
  }
493
327
 
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');
328
+ function hasGuardexBootstrapFiles(repoRoot) {
329
+ const required = [
330
+ 'AGENTS.md',
331
+ '.githooks/pre-commit',
332
+ '.githooks/pre-push',
333
+ LOCK_FILE_RELATIVE,
334
+ ];
335
+ return required.every((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
499
336
  }
500
337
 
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' };
338
+ function protectedBaseWriteBlock(options, { requireBootstrap = true } = {}) {
339
+ if (options.dryRun || options.allowProtectedBaseWrite) {
340
+ return null;
505
341
  }
506
342
 
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}`);
343
+ const repoRoot = resolveRepoRoot(options.target);
344
+ if (requireBootstrap && !hasGuardexBootstrapFiles(repoRoot)) {
345
+ return null;
512
346
  }
513
347
 
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
- }
348
+ const branch = currentBranchName(repoRoot);
349
+ if (branch !== 'main') {
350
+ return null;
524
351
  }
525
352
 
526
- if (!changed) {
527
- return { status: 'unchanged', file: 'package.json', note: 'no Guardex-managed agent:* scripts found' };
353
+ const protectedBranches = readProtectedBranches(repoRoot);
354
+ if (!protectedBranches.includes(branch)) {
355
+ return null;
528
356
  }
529
357
 
530
- if (!dryRun) {
531
- fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
532
- }
358
+ return {
359
+ repoRoot,
360
+ branch,
361
+ };
362
+ }
533
363
 
534
- return { status: dryRun ? 'would-update' : 'updated', file: 'package.json', note: 'removed Guardex-managed agent:* scripts' };
364
+ function assertProtectedMainWriteAllowed(options, commandName) {
365
+ return sandboxModule.assertProtectedMainWriteAllowed(options, commandName);
535
366
  }
536
367
 
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 };
368
+ function runSetupBootstrapInternal(options) {
369
+ const installPayload = runInstallInternal(options);
370
+ installPayload.operations.push(
371
+ ensureSetupProtectedBranches(installPayload.repoRoot, Boolean(options.dryRun)),
372
+ );
373
+
374
+ let parentWorkspace = null;
375
+ if (options.parentWorkspaceView) {
376
+ installPayload.operations.push(
377
+ ensureParentWorkspaceView(installPayload.repoRoot, Boolean(options.dryRun)),
378
+ );
379
+ if (!options.dryRun) {
380
+ parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot);
551
381
  }
552
382
  }
553
383
 
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 };
384
+ const fixPayload = runFixInternal({
385
+ target: installPayload.repoRoot,
386
+ dryRun: options.dryRun,
387
+ force: options.force,
388
+ forceManagedPaths: options.forceManagedPaths,
389
+ dropStaleLocks: true,
390
+ skipAgents: options.skipAgents,
391
+ skipPackageJson: options.skipPackageJson,
392
+ skipGitignore: options.skipGitignore,
393
+ allowProtectedBaseWrite: options.allowProtectedBaseWrite,
394
+ });
395
+
396
+ return {
397
+ installPayload,
398
+ fixPayload,
399
+ parentWorkspace,
400
+ };
559
401
  }
560
402
 
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
- }
403
+ function extractAgentBranchStartMetadata(output) {
404
+ const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m);
405
+ const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m);
406
+ return {
407
+ branch: branchMatch ? branchMatch[1].trim() : '',
408
+ worktreePath: worktreeMatch ? worktreeMatch[1].trim() : '',
409
+ };
410
+ }
571
411
 
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
- }
412
+ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
413
+ const resolvedTarget = path.resolve(targetPath);
414
+ const relativeTarget = path.relative(repoRoot, resolvedTarget);
415
+ if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
416
+ throw new Error(`sandbox target must stay inside repo root: ${resolvedTarget}`);
578
417
  }
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' };
418
+ if (!relativeTarget || relativeTarget === '.') {
419
+ return worktreePath;
586
420
  }
421
+ return path.join(worktreePath, relativeTarget);
422
+ }
587
423
 
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
- }
424
+ function buildSandboxSetupArgs(options, sandboxTarget) {
425
+ const args = ['setup', '--target', sandboxTarget, '--no-global-install', '--no-recursive'];
426
+ appendForceArgs(args, options);
427
+ if (options.skipAgents) args.push('--skip-agents');
428
+ if (options.skipPackageJson) args.push('--skip-package-json');
429
+ if (options.skipGitignore) args.push('--no-gitignore');
430
+ if (options.dryRun) args.push('--dry-run');
431
+ return args;
432
+ }
593
433
 
594
- if (!dryRun) {
595
- fs.rmSync(absolutePath, { force: true });
596
- }
597
- return { status: dryRun ? 'would-remove' : 'removed', file: relativePath };
434
+ function isSpawnFailure(result) {
435
+ return Boolean(result?.error) && typeof result?.status !== 'number';
598
436
  }
599
437
 
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
- );
438
+ function protectedBaseSandboxBranchPrefix() {
439
+ const now = new Date();
440
+ const stamp = [
441
+ now.getUTCFullYear(),
442
+ String(now.getUTCMonth() + 1).padStart(2, '0'),
443
+ String(now.getUTCDate()).padStart(2, '0'),
444
+ ].join('') + '-' + [
445
+ String(now.getUTCHours()).padStart(2, '0'),
446
+ String(now.getUTCMinutes()).padStart(2, '0'),
447
+ String(now.getUTCSeconds()).padStart(2, '0'),
448
+ ].join('');
449
+ return `agent/gx/${stamp}`;
450
+ }
607
451
 
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
- }
452
+ function protectedBaseSandboxWorktreePath(repoRoot, branchName) {
453
+ return path.join(repoRoot, defaultAgentWorktreeRelativeDir(), branchName.replace(/\//g, '__'));
454
+ }
614
455
 
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' };
456
+ function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) {
457
+ run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 });
458
+ if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) {
459
+ return `origin/${baseBranch}`;
625
460
  }
626
-
627
- if (existing.includes(AGENTS_MARKER_START)) {
628
- return { status: 'unchanged', file: 'AGENTS.md', note: 'existing marker found without managed end marker' };
461
+ if (gitRefExists(repoRoot, `refs/heads/${baseBranch}`)) {
462
+ return baseBranch;
629
463
  }
630
-
631
- const separator = existing.endsWith('\n') ? '\n' : '\n\n';
632
- if (!dryRun) {
633
- fs.writeFileSync(agentsPath, `${existing}${separator}${snippet}\n`, 'utf8');
464
+ if (currentBranchName(repoRoot) === baseBranch) {
465
+ return null;
634
466
  }
635
-
636
- return { status: 'updated', file: 'AGENTS.md' };
467
+ throw new Error(`Unable to find base ref for sandbox bootstrap: ${baseBranch}`);
637
468
  }
638
469
 
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
- }
470
+ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
471
+ const branchPrefix = protectedBaseSandboxBranchPrefix();
472
+ let selectedBranch = '';
473
+ let selectedWorktreePath = '';
2412
474
 
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();
475
+ for (let attempt = 0; attempt < 30; attempt += 1) {
476
+ const suffix = attempt === 0 ? sandboxSuffix : `${attempt + 1}-${sandboxSuffix}`;
477
+ const candidateBranch = `${branchPrefix}-${suffix}`;
478
+ const candidateWorktreePath = protectedBaseSandboxWorktreePath(blocked.repoRoot, candidateBranch);
479
+ if (gitRefExists(blocked.repoRoot, `refs/heads/${candidateBranch}`)) {
2418
480
  continue;
2419
481
  }
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
- }
482
+ if (fs.existsSync(candidateWorktreePath)) {
483
+ continue;
2425
484
  }
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;
485
+ selectedBranch = candidateBranch;
486
+ selectedWorktreePath = candidateWorktreePath;
487
+ break;
2501
488
  }
2502
489
 
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;
490
+ if (!selectedBranch || !selectedWorktreePath) {
491
+ throw new Error('Unable to allocate unique sandbox branch/worktree');
2514
492
  }
2515
493
 
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;
494
+ fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true });
495
+ const startRef = resolveProtectedBaseSandboxStartRef(blocked.repoRoot, blocked.branch);
496
+ const addArgs = startRef
497
+ ? ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef]
498
+ : ['-C', blocked.repoRoot, 'worktree', 'add', '--orphan', selectedWorktreePath];
499
+ const addResult = run('git', addArgs);
500
+ if (isSpawnFailure(addResult)) {
501
+ throw addResult.error;
2521
502
  }
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;
503
+ if (addResult.status !== 0) {
504
+ throw new Error((addResult.stderr || addResult.stdout || 'failed to create sandbox').trim());
2529
505
  }
2530
506
 
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
- }
2565
-
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.`);
2582
- continue;
507
+ if (!startRef) {
508
+ const renameResult = run(
509
+ 'git',
510
+ ['-C', selectedWorktreePath, 'branch', '-m', selectedBranch],
511
+ { timeout: 20_000 },
512
+ );
513
+ if (isSpawnFailure(renameResult)) {
514
+ throw renameResult.error;
2583
515
  }
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}`);
2590
- continue;
516
+ if (renameResult.status !== 0) {
517
+ throw new Error(
518
+ (renameResult.stderr || renameResult.stdout || 'failed to name orphan sandbox branch').trim(),
519
+ );
2591
520
  }
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}`);
2596
- }
2597
-
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
- };
2609
- }
2610
-
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
- };
2620
- }
2621
-
2622
- const nextBranches = uniquePreserveOrder([...currentBranches, ...missingBranches]);
2623
- if (!dryRun) {
2624
- writeProtectedBranches(repoRoot, nextBranches);
2625
521
  }
2626
522
 
2627
523
  return {
2628
- status: dryRun ? 'would-update' : 'updated',
2629
- file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
2630
- note: `added local user branch(es): ${missingBranches.join(', ')}`,
524
+ metadata: {
525
+ branch: selectedBranch,
526
+ worktreePath: selectedWorktreePath,
527
+ },
528
+ stdout:
529
+ `[agent-branch-start] Created branch: ${selectedBranch}\n` +
530
+ `[agent-branch-start] Worktree: ${selectedWorktreePath}\n`,
531
+ stderr: addResult.stderr || '',
2631
532
  };
2632
533
  }
2633
534
 
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)`);
535
+ function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) {
536
+ if (sandboxSuffix === 'gx-doctor') {
537
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
2677
538
  }
2678
- return strategy;
2679
- }
2680
539
 
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');
540
+ const startResult = runPackageAsset('branchStart', [
541
+ '--task',
542
+ taskName,
543
+ '--agent',
544
+ SHORT_TOOL_NAME,
545
+ '--base',
546
+ blocked.branch,
547
+ ], { cwd: blocked.repoRoot });
548
+ if (isSpawnFailure(startResult)) {
549
+ throw startResult.error;
2685
550
  }
2686
- const branch = (result.stdout || '').trim();
2687
- if (!branch) {
2688
- throw new Error('Detached HEAD is not supported for sync operations');
551
+ if (startResult.status !== 0) {
552
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
2689
553
  }
2690
- return branch;
2691
- }
2692
554
 
2693
- function repoHasHeadCommit(repoRoot) {
2694
- return gitRun(repoRoot, ['rev-parse', '--verify', 'HEAD'], { allowFailure: true }).status === 0;
2695
- }
555
+ const metadata = extractAgentBranchStartMetadata(startResult.stdout);
556
+ const currentBranch = currentBranchName(blocked.repoRoot);
557
+ const worktreePath = metadata.worktreePath ? path.resolve(metadata.worktreePath) : '';
558
+ const repoRootPath = path.resolve(blocked.repoRoot);
559
+ const hasSafeWorktree = Boolean(worktreePath) && worktreePath !== repoRootPath;
560
+ const branchChanged = Boolean(currentBranch) && currentBranch !== blocked.branch;
2696
561
 
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)';
562
+ if (!hasSafeWorktree || branchChanged) {
563
+ const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
564
+ if (!restoreResult.ok) {
565
+ const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim();
566
+ throw new Error(
567
+ `sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
568
+ (detail ? `\n${detail}` : ''),
569
+ );
2703
570
  }
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()})`;
571
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
2710
572
  }
2711
- return '(unknown)';
2712
- }
2713
573
 
2714
- function repoHasOriginRemote(repoRoot) {
2715
- return gitRun(repoRoot, ['remote', 'get-url', 'origin'], { allowFailure: true }).status === 0;
574
+ return {
575
+ metadata,
576
+ stdout: startResult.stdout || '',
577
+ stderr: startResult.stderr || '',
578
+ };
2716
579
  }
2717
580
 
2718
- function detectComposeHintFiles(repoRoot) {
2719
- return COMPOSE_HINT_FILES.filter((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
2720
- }
581
+ function cleanupProtectedBaseSandbox(repoRoot, metadata) {
582
+ const result = {
583
+ worktree: 'skipped',
584
+ branch: 'skipped',
585
+ note: 'missing sandbox metadata',
586
+ };
2721
587
 
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;
588
+ if (!metadata?.worktreePath || !metadata?.branch) {
589
+ return result;
2729
590
  }
2730
591
 
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`,
592
+ if (fs.existsSync(metadata.worktreePath)) {
593
+ const removeResult = run(
594
+ 'git',
595
+ ['-C', repoRoot, 'worktree', 'remove', '--force', metadata.worktreePath],
596
+ { timeout: 30_000 },
2740
597
  );
598
+ if (isSpawnFailure(removeResult)) {
599
+ throw removeResult.error;
600
+ }
601
+ if (removeResult.status !== 0) {
602
+ throw new Error(
603
+ (removeResult.stderr || removeResult.stdout || 'failed to remove sandbox worktree').trim(),
604
+ );
605
+ }
606
+ result.worktree = 'removed';
607
+ } else {
608
+ result.worktree = 'missing';
2741
609
  }
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...>'.`,
610
+
611
+ if (gitRefExists(repoRoot, `refs/heads/${metadata.branch}`)) {
612
+ const branchDeleteResult = run(
613
+ 'git',
614
+ ['-C', repoRoot, 'branch', '-D', metadata.branch],
615
+ { timeout: 20_000 },
2749
616
  );
617
+ if (isSpawnFailure(branchDeleteResult)) {
618
+ throw branchDeleteResult.error;
619
+ }
620
+ if (branchDeleteResult.status !== 0) {
621
+ throw new Error(
622
+ (branchDeleteResult.stderr || branchDeleteResult.stdout || 'failed to delete sandbox branch').trim(),
623
+ );
624
+ }
625
+ result.branch = 'deleted';
626
+ } else {
627
+ result.branch = 'missing';
2750
628
  }
629
+
630
+ result.note = 'sandbox worktree pruned';
631
+ return result;
2751
632
  }
2752
633
 
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;
634
+ function runSetupInSandbox(options, blocked, repoLabel = '') {
635
+ const startResult = startProtectedBaseSandbox(blocked, {
636
+ taskName: `${SHORT_TOOL_NAME}-setup`,
637
+ sandboxSuffix: 'gx-setup',
2766
638
  });
2767
- return significant.length > 0;
2768
- }
639
+ const metadata = startResult.metadata;
640
+
641
+ if (startResult.stdout) process.stdout.write(startResult.stdout);
642
+ if (startResult.stderr) process.stderr.write(startResult.stderr);
643
+ console.log(
644
+ `[${TOOL_NAME}] setup blocked on protected branch '${blocked.branch}' in an initialized repo; ` +
645
+ 'refreshing through a sandbox worktree and syncing managed bootstrap files back locally.',
646
+ );
2769
647
 
2770
- function ensureOriginBaseRef(repoRoot, baseBranch) {
2771
- const fetch = gitRun(repoRoot, ['fetch', 'origin', baseBranch, '--quiet'], { allowFailure: true });
2772
- if (fetch.status !== 0) {
648
+ const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
649
+ const nestedResult = run(
650
+ process.execPath,
651
+ [__filename, ...buildSandboxSetupArgs(options, sandboxTarget)],
652
+ { cwd: metadata.worktreePath },
653
+ );
654
+ if (isSpawnFailure(nestedResult)) {
655
+ throw nestedResult.error;
656
+ }
657
+ if (nestedResult.status !== 0) {
658
+ if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
659
+ if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
2773
660
  throw new Error(
2774
- `Unable to fetch origin/${baseBranch}. Ensure remote 'origin' exists and branch '${baseBranch}' is available.`,
661
+ `sandboxed setup failed for protected branch '${blocked.branch}'. ` +
662
+ `Inspect sandbox at ${metadata.worktreePath}`,
2775
663
  );
2776
664
  }
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}`);
665
+
666
+ const syncOptions = {
667
+ ...options,
668
+ target: blocked.repoRoot,
669
+ recursive: false,
670
+ allowProtectedBaseWrite: true,
671
+ };
672
+ const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(syncOptions);
673
+ printOperations(`Setup/install${repoLabel}`, installPayload, syncOptions.dryRun);
674
+ printOperations(`Setup/fix${repoLabel}`, fixPayload, syncOptions.dryRun);
675
+ if (!syncOptions.dryRun && parentWorkspace) {
676
+ console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
2782
677
  }
2783
- }
2784
678
 
2785
- function aheadBehind(repoRoot, branchRef, baseRef) {
2786
- const result = gitRun(repoRoot, ['rev-list', '--left-right', '--count', `${branchRef}...${baseRef}`], {
2787
- allowFailure: true,
679
+ const scanResult = runScanInternal({ target: blocked.repoRoot, json: false });
680
+ const currentBaseBranch = currentBranchName(scanResult.repoRoot);
681
+ const autoFinishSummary = doctorModule.autoFinishReadyAgentBranches(scanResult.repoRoot, {
682
+ baseBranch: currentBaseBranch,
683
+ dryRun: syncOptions.dryRun,
2788
684
  });
2789
- if (result.status !== 0) {
2790
- throw new Error(`Unable to compute ahead/behind for ${branchRef} vs ${baseRef}`);
685
+ printScanResult(scanResult, false);
686
+ if (autoFinishSummary.enabled) {
687
+ console.log(
688
+ `[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
689
+ );
690
+ for (const detail of autoFinishSummary.details) {
691
+ console.log(`[${TOOL_NAME}] ${detail}`);
692
+ }
693
+ } else if (autoFinishSummary.details.length > 0) {
694
+ console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
2791
695
  }
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
696
 
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 };
697
+ const cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
698
+ console.log(
699
+ `[${TOOL_NAME}] Protected-base setup sandbox cleanup: ${cleanupResult.note} ` +
700
+ `(worktree=${cleanupResult.worktree}, branch=${cleanupResult.branch}).`,
701
+ );
702
+
703
+ return {
704
+ scanResult,
705
+ };
2809
706
  }
2810
707
 
2811
708
 
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
- }
709
+ function todayDateStamp() {
710
+ return new Date().toISOString().slice(0, 10);
711
+ }
2817
712
 
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;
2837
- }
2838
- if (line.startsWith('branch ')) {
2839
- currentBranchRef = line.slice('branch '.length).trim();
2840
- continue;
2841
- }
2842
- }
2843
- if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
2844
- entries.push({
2845
- worktreePath: currentPath,
2846
- branch: currentBranchRef.replace(/^refs\/heads\//, ''),
2847
- });
2848
- }
713
+ function inferGithubRepoFromOrigin(repoRoot) {
714
+ const rawOrigin = readGitConfig(repoRoot, 'remote.origin.url');
715
+ if (!rawOrigin) return '';
2849
716
 
2850
- return entries;
717
+ const httpsMatch = rawOrigin.match(/github\.com[:/](.+?)(?:\.git)?$/i);
718
+ if (!httpsMatch) return '';
719
+ const slug = (httpsMatch[1] || '').replace(/^\/+/, '').trim();
720
+ if (!slug || !slug.includes('/')) return '';
721
+ return `github.com/${slug}`;
2851
722
  }
2852
723
 
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
- );
724
+ function inferGithubRepoSlug(rawValue) {
725
+ const raw = String(rawValue || '').trim();
726
+ if (!raw) return '';
727
+ const match = raw.match(/github\.com[:/](.+?)(?:\.git)?$/i);
728
+ if (!match) return '';
729
+ const slug = String(match[1] || '')
730
+ .replace(/^\/+/, '')
731
+ .replace(/^github\.com\//i, '')
732
+ .trim();
733
+ if (!slug || !slug.includes('/')) return '';
734
+ return slug;
2868
735
  }
2869
736
 
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;
737
+ function resolveScorecardRepo(repoRoot, explicitRepo) {
738
+ if (explicitRepo) {
739
+ return explicitRepo.trim();
2877
740
  }
741
+ const inferred = inferGithubRepoFromOrigin(repoRoot);
742
+ if (inferred) return inferred;
2878
743
  throw new Error(
2879
- `git ${args.join(' ')} failed in ${worktreePath}: ${(
2880
- result.stderr || result.stdout || ''
2881
- ).trim()}`,
744
+ 'Unable to infer GitHub repo from origin remote. Pass --repo github.com/<owner>/<repo>.',
2882
745
  );
2883
746
  }
2884
747
 
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;
2895
- }
2896
-
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;
2907
- }
2908
-
2909
- const untracked = run('git', ['-C', worktreePath, 'ls-files', '--others', '--exclude-standard'], {
2910
- stdio: 'pipe',
2911
- });
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' });
748
+ function runScorecardJson(repo) {
749
+ const result = run(SCORECARD_BIN, ['--repo', repo, '--format', 'json'], { allowFailure: true });
2920
750
  if (result.status !== 0) {
751
+ const details = (result.stderr || result.stdout || '').trim();
2921
752
  throw new Error(
2922
- `git ${args.join(' ')} failed in ${worktreePath}: ${(
2923
- result.stderr || result.stdout || ''
2924
- ).trim()}`,
753
+ `Failed to run scorecard CLI ('${SCORECARD_BIN} --repo ${repo} --format json').${details ? `\n${details}` : ''}`,
2925
754
  );
2926
755
  }
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
- );
2951
- }
2952
- }
2953
756
 
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
- }
757
+ try {
758
+ return JSON.parse(result.stdout || '{}');
759
+ } catch (error) {
760
+ throw new Error(`Unable to parse scorecard JSON output: ${error.message}`);
2986
761
  }
2987
762
  }
2988
763
 
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;
2994
- }
2995
-
2996
- function resolveFinishBaseBranch(repoRoot, _sourceBranch, explicitBase) {
2997
- if (explicitBase) {
2998
- return explicitBase;
764
+ function readScorecardJsonFile(filePath) {
765
+ const absolute = path.resolve(filePath);
766
+ if (!fs.existsSync(absolute)) {
767
+ throw new Error(`scorecard JSON file not found: ${absolute}`);
2999
768
  }
3000
-
3001
- const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
3002
- if (configured) {
3003
- return configured;
769
+ try {
770
+ return JSON.parse(fs.readFileSync(absolute, 'utf8'));
771
+ } catch (error) {
772
+ throw new Error(`Unable to parse scorecard JSON file: ${error.message}`);
3004
773
  }
3005
-
3006
- return DEFAULT_BASE_BRANCH;
3007
774
  }
3008
775
 
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,
776
+ function normalizeScorecardChecks(payload) {
777
+ const rawChecks = Array.isArray(payload?.checks) ? payload.checks : [];
778
+ return rawChecks.map((check) => {
779
+ const name = String(check?.name || 'Unknown');
780
+ const rawScore = Number(check?.score);
781
+ const score = Number.isFinite(rawScore) ? rawScore : 0;
782
+ return {
783
+ name,
784
+ score,
785
+ risk: SCORECARD_RISK_BY_CHECK[name] || 'Unknown',
786
+ };
3015
787
  });
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}`);
3023
788
  }
3024
789
 
3025
- function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) {
3026
- const hasChanges = worktreeHasLocalChanges(worktreePath);
3027
- if (!hasChanges) {
3028
- return { changed: false, committed: false };
3029
- }
3030
-
3031
- if (options.noAutoCommit) {
3032
- throw new Error(
3033
- `Branch '${branch}' has local changes in ${worktreePath}. Re-run without --no-auto-commit or commit manually first.`,
3034
- );
3035
- }
3036
-
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
- }
790
+ function renderScorecardBaselineMarkdown({ repo, score, checks, capturedAt, scorecardVersion, reportDate }) {
791
+ const rows = checks
792
+ .map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`)
793
+ .join('\n');
3047
794
 
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
- }
795
+ return [
796
+ '# OpenSSF Scorecard Baseline Report',
797
+ '',
798
+ `- **Repository:** \`${repo}\``,
799
+ '- **Source:** generated by `gx report scorecard`',
800
+ `- **Captured at:** ${capturedAt}`,
801
+ `- **Scorecard version:** \`${scorecardVersion}\``,
802
+ `- **Overall score:** **${score} / 10**`,
803
+ '',
804
+ '## Check breakdown',
805
+ '',
806
+ '| Check | Score | Risk |',
807
+ '|---|---:|---|',
808
+ rows || '| (none) | 0 | Unknown |',
809
+ '',
810
+ `## Report date`,
811
+ '',
812
+ `- ${reportDate}`,
813
+ '',
814
+ ].join('\n');
815
+ }
3059
816
 
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
- );
3068
- }
817
+ function renderScorecardRemediationPlanMarkdown({ baselineRelativePath, checks }) {
818
+ const failing = checks.filter((item) => item.score < 10);
819
+ const failingRows = failing
820
+ .sort((a, b) => a.score - b.score || a.name.localeCompare(b.name))
821
+ .map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`)
822
+ .join('\n');
3069
823
 
3070
- return { changed: true, committed: true, message: commitMessage };
824
+ return [
825
+ '# OpenSSF Scorecard Remediation Plan',
826
+ '',
827
+ `Based on baseline report: \`${baselineRelativePath}\`.`,
828
+ '',
829
+ '## Failing checks',
830
+ '',
831
+ '| Check | Score | Risk |',
832
+ '|---|---:|---|',
833
+ (failingRows || '| None | 10 | N/A |'),
834
+ '',
835
+ '## Priority order',
836
+ '',
837
+ '1. Fix **High** risk checks first (especially score 0 items).',
838
+ '2. Then close **Medium** risk checks with score < 10.',
839
+ '3. Finally address **Low** risk ecosystem/process checks.',
840
+ '',
841
+ '## Verification loop',
842
+ '',
843
+ '1. Run scorecard again.',
844
+ '2. Re-generate baseline + remediation files.',
845
+ '3. Compare score deltas and track improved checks.',
846
+ '',
847
+ ].join('\n');
3071
848
  }
3072
849
 
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
- }
850
+ function parseBranchList(rawValue) {
851
+ return String(rawValue || '')
852
+ .split(/[\s,]+/)
853
+ .map((item) => item.trim())
854
+ .filter(Boolean);
855
+ }
3090
856
 
3091
- const mergeArgs = ['-C', repoRoot, 'merge', '--no-edit'];
3092
- if (ffOnly) {
3093
- mergeArgs.push('--ff-only');
3094
- }
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}`);
857
+ function originRemoteLooksLikeGithub(repoRoot) {
858
+ const originUrl = readGitConfig(repoRoot, 'remote.origin.url');
859
+ if (!originUrl) {
860
+ return false;
3103
861
  }
862
+ return /github\.com[:/]/i.test(originUrl);
3104
863
  }
3105
864
 
3106
865
  function isInteractiveTerminal() {
@@ -3308,7 +1067,7 @@ function printUpdateAvailableBanner(current, latest) {
3308
1067
  }
3309
1068
 
3310
1069
  function maybeSelfUpdateBeforeStatus() {
3311
- return getToolchainApi().maybeSelfUpdateBeforeStatus();
1070
+ return toolchainModule.maybeSelfUpdateBeforeStatus();
3312
1071
  }
3313
1072
 
3314
1073
  function readInstalledGuardexVersion() {
@@ -3443,7 +1202,7 @@ function printOpenSpecUpdateAvailableBanner(current, latest) {
3443
1202
  }
3444
1203
 
3445
1204
  function maybeOpenSpecUpdateBeforeStatus() {
3446
- return getToolchainApi().maybeOpenSpecUpdateBeforeStatus();
1205
+ return toolchainModule.maybeOpenSpecUpdateBeforeStatus();
3447
1206
  }
3448
1207
 
3449
1208
  function promptYesNoStrict(question) {
@@ -3622,7 +1381,7 @@ function askGlobalInstallForMissing(options, missingPackages, missingLocalTools)
3622
1381
  }
3623
1382
 
3624
1383
  function installGlobalToolchain(options) {
3625
- return getToolchainApi().installGlobalToolchain(options);
1384
+ return toolchainModule.installGlobalToolchain(options);
3626
1385
  }
3627
1386
 
3628
1387
  function findStaleLockPaths(repoRoot, locks) {
@@ -3982,9 +1741,9 @@ function status(rawArgs) {
3982
1741
  json: false,
3983
1742
  });
3984
1743
 
3985
- const toolchain = detectGlobalToolchainPackages();
1744
+ const toolchain = toolchainModule.detectGlobalToolchainPackages();
3986
1745
  const npmServices = GLOBAL_TOOLCHAIN_PACKAGES.map((pkg) => {
3987
- const service = getGlobalToolchainService(pkg);
1746
+ const service = toolchainModule.getGlobalToolchainService(pkg);
3988
1747
  if (!toolchain.ok) {
3989
1748
  return {
3990
1749
  name: service.name,
@@ -4002,12 +1761,12 @@ function status(rawArgs) {
4002
1761
  status: toolchain.installed.includes(pkg) ? 'active' : 'inactive',
4003
1762
  };
4004
1763
  });
4005
- const localCompanionServices = detectOptionalLocalCompanionTools().map((tool) => ({
1764
+ const localCompanionServices = toolchainModule.detectOptionalLocalCompanionTools().map((tool) => ({
4006
1765
  name: tool.name,
4007
1766
  displayName: tool.displayName || tool.name,
4008
1767
  status: tool.status,
4009
1768
  }));
4010
- const requiredSystemTools = detectRequiredSystemTools();
1769
+ const requiredSystemTools = toolchainModule.detectRequiredSystemTools();
4011
1770
  const services = [
4012
1771
  ...npmServices,
4013
1772
  ...localCompanionServices,
@@ -4076,7 +1835,7 @@ function status(rawArgs) {
4076
1835
  console.log(
4077
1836
  `[${TOOL_NAME}] Optional companion tools inactive: ${inactiveOptionalCompanions.join(', ')}`,
4078
1837
  );
4079
- for (const warning of describeMissingGlobalDependencyWarnings(
1838
+ for (const warning of toolchainModule.describeMissingGlobalDependencyWarnings(
4080
1839
  npmServices
4081
1840
  .filter((service) => service.status === 'inactive')
4082
1841
  .map((service) => service.packageName),
@@ -4333,7 +2092,13 @@ function doctor(rawArgs) {
4333
2092
 
4334
2093
  const blocked = protectedBaseWriteBlock(singleRepoOptions, { requireBootstrap: false });
4335
2094
  if (blocked) {
4336
- runDoctorInSandbox(singleRepoOptions, blocked);
2095
+ doctorModule.runDoctorInSandbox(singleRepoOptions, blocked, {
2096
+ startProtectedBaseSandbox,
2097
+ cleanupProtectedBaseSandbox,
2098
+ ensureOmxScaffold,
2099
+ configureHooks,
2100
+ autoFinishReadyAgentBranches: doctorModule.autoFinishReadyAgentBranches,
2101
+ });
4337
2102
  return;
4338
2103
  }
4339
2104
 
@@ -4350,7 +2115,7 @@ function doctor(rawArgs) {
4350
2115
  failed: 0,
4351
2116
  details: [],
4352
2117
  }
4353
- : autoFinishReadyAgentBranches(scanResult.repoRoot, {
2118
+ : doctorModule.autoFinishReadyAgentBranches(scanResult.repoRoot, {
4354
2119
  baseBranch: currentBaseBranch,
4355
2120
  dryRun: singleRepoOptions.dryRun,
4356
2121
  waitForMerge: singleRepoOptions.waitForMerge,
@@ -4806,7 +2571,7 @@ function setup(rawArgs) {
4806
2571
  allowProtectedBaseWrite: false,
4807
2572
  });
4808
2573
 
4809
- const globalInstallStatus = installGlobalToolchain(options);
2574
+ const globalInstallStatus = toolchainModule.installGlobalToolchain(options);
4810
2575
  if (globalInstallStatus.status === 'installed') {
4811
2576
  console.log(
4812
2577
  `[${TOOL_NAME}] ✅ Companion tools installed (${(globalInstallStatus.packages || []).join(', ')}).`,
@@ -4814,7 +2579,7 @@ function setup(rawArgs) {
4814
2579
  } else if (globalInstallStatus.status === 'already-installed') {
4815
2580
  console.log(`[${TOOL_NAME}] ✅ Companion tools already installed. Skipping.`);
4816
2581
  } else if (globalInstallStatus.status === 'failed') {
4817
- const installCommands = describeCompanionInstallCommands(
2582
+ const installCommands = toolchainModule.describeCompanionInstallCommands(
4818
2583
  GLOBAL_TOOLCHAIN_PACKAGES,
4819
2584
  OPTIONAL_LOCAL_COMPANION_TOOLS,
4820
2585
  );
@@ -4830,13 +2595,13 @@ function setup(rawArgs) {
4830
2595
  );
4831
2596
  } else if (globalInstallStatus.status === 'skipped') {
4832
2597
  console.log(`[${TOOL_NAME}] ⚠️ Companion installs skipped by user choice.`);
4833
- for (const warning of describeMissingGlobalDependencyWarnings(
2598
+ for (const warning of toolchainModule.describeMissingGlobalDependencyWarnings(
4834
2599
  globalInstallStatus.missingPackages || [],
4835
2600
  )) {
4836
2601
  console.log(`[${TOOL_NAME}] ⚠️ ${warning}`);
4837
2602
  }
4838
2603
  }
4839
- const requiredSystemTools = detectRequiredSystemTools();
2604
+ const requiredSystemTools = toolchainModule.detectRequiredSystemTools();
4840
2605
  const missingSystemTools = requiredSystemTools.filter((tool) => tool.status !== 'active');
4841
2606
  if (missingSystemTools.length === 0) {
4842
2607
  console.log(`[${TOOL_NAME}] ✅ Required system tools available (${requiredSystemTools.map((tool) => tool.name).join(', ')}).`);
@@ -4904,7 +2669,7 @@ function setup(rawArgs) {
4904
2669
 
4905
2670
  const scanResult = runScanInternal({ target: repoPath, json: false });
4906
2671
  const currentBaseBranch = currentBranchName(scanResult.repoRoot);
4907
- const autoFinishSummary = autoFinishReadyAgentBranches(scanResult.repoRoot, {
2672
+ const autoFinishSummary = doctorModule.autoFinishReadyAgentBranches(scanResult.repoRoot, {
4908
2673
  baseBranch: currentBaseBranch,
4909
2674
  dryRun: perRepoOptions.dryRun,
4910
2675
  });
@@ -5226,26 +2991,59 @@ function copyCommands() {
5226
2991
  function prompt(rawArgs) {
5227
2992
  const args = Array.isArray(rawArgs) ? rawArgs : [];
5228
2993
  let variant = 'prompt';
5229
- for (const arg of args) {
2994
+ let listParts = false;
2995
+ const selectedParts = [];
2996
+ for (let index = 0; index < args.length; index += 1) {
2997
+ const arg = args[index];
5230
2998
  if (arg === '--exec' || arg === '--commands') variant = 'exec';
5231
2999
  else if (arg === '--snippet' || arg === '--agents') variant = 'snippet';
5232
3000
  else if (arg === '--prompt' || arg === '--full') variant = 'prompt';
3001
+ else if (arg === '--list-parts') listParts = true;
3002
+ else if (arg === '--part' || arg === '--parts') {
3003
+ const rawValue = args[index + 1];
3004
+ if (!rawValue || rawValue.startsWith('--')) {
3005
+ throw new Error(`${arg} requires a value`);
3006
+ }
3007
+ selectedParts.push(...parseAiSetupPartNames(rawValue));
3008
+ index += 1;
3009
+ } else if (arg.startsWith('--part=')) {
3010
+ selectedParts.push(...parseAiSetupPartNames(arg.slice('--part='.length)));
3011
+ } else if (arg.startsWith('--parts=')) {
3012
+ selectedParts.push(...parseAiSetupPartNames(arg.slice('--parts='.length)));
3013
+ }
5233
3014
  else if (arg === '-h' || arg === '--help') variant = 'help';
5234
3015
  else throw new Error(`Unknown option: ${arg}`);
5235
3016
  }
5236
3017
  if (variant === 'help') {
5237
3018
  console.log(
5238
3019
  `${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`,
3020
+ ` ${SHORT_TOOL_NAME} prompt Print AI setup checklist\n` +
3021
+ ` ${SHORT_TOOL_NAME} prompt --exec Print setup commands only (shell-ready)\n` +
3022
+ ` ${SHORT_TOOL_NAME} prompt --part <name> Print only the named checklist slice(s)\n` +
3023
+ ` ${SHORT_TOOL_NAME} prompt --exec --part <name> Print only the named exec-capable slice(s)\n` +
3024
+ ` ${SHORT_TOOL_NAME} prompt --list-parts List prompt part names\n` +
3025
+ ` ${SHORT_TOOL_NAME} prompt --exec --list-parts List exec-capable prompt part names\n` +
3026
+ ` ${SHORT_TOOL_NAME} prompt --snippet Print the AGENTS.md managed-block template`,
5242
3027
  );
5243
3028
  process.exitCode = 0;
5244
3029
  return;
5245
3030
  }
5246
- if (variant === 'exec') return copyCommands();
5247
- if (variant === 'snippet') return printAgentsSnippet();
5248
- return copyPrompt();
3031
+ if (variant === 'snippet') {
3032
+ if (listParts || selectedParts.length > 0) {
3033
+ throw new Error('--snippet does not support --list-parts or --part');
3034
+ }
3035
+ return printAgentsSnippet();
3036
+ }
3037
+ if (listParts) {
3038
+ if (selectedParts.length > 0) {
3039
+ throw new Error('--list-parts does not support --part');
3040
+ }
3041
+ process.stdout.write(`${listAiSetupPartNames({ execOnly: variant === 'exec' }).join('\n')}\n`);
3042
+ process.exitCode = 0;
3043
+ return;
3044
+ }
3045
+ process.stdout.write(renderAiSetupPrompt({ exec: variant === 'exec', parts: selectedParts }));
3046
+ process.exitCode = 0;
5249
3047
  }
5250
3048
 
5251
3049
  function branch(rawArgs) {
@@ -5376,19 +3174,19 @@ function migrate(rawArgs) {
5376
3174
  }
5377
3175
 
5378
3176
  function cleanup(rawArgs) {
5379
- return getFinishApi().cleanup(rawArgs);
3177
+ return finishCommands.cleanup(rawArgs);
5380
3178
  }
5381
3179
 
5382
3180
  function merge(rawArgs) {
5383
- return getFinishApi().merge(rawArgs);
3181
+ return finishCommands.merge(rawArgs);
5384
3182
  }
5385
3183
 
5386
3184
  function finish(rawArgs, defaults = {}) {
5387
- return getFinishApi().finish(rawArgs, defaults);
3185
+ return finishCommands.finish(rawArgs, defaults);
5388
3186
  }
5389
3187
 
5390
3188
  function sync(rawArgs) {
5391
- return getFinishApi().sync(rawArgs);
3189
+ return finishCommands.sync(rawArgs);
5392
3190
  }
5393
3191
 
5394
3192
  function protect(rawArgs) {
@@ -5473,8 +3271,8 @@ function main() {
5473
3271
  const args = process.argv.slice(2);
5474
3272
 
5475
3273
  if (args.length === 0) {
5476
- maybeSelfUpdateBeforeStatus();
5477
- maybeOpenSpecUpdateBeforeStatus();
3274
+ toolchainModule.maybeSelfUpdateBeforeStatus();
3275
+ toolchainModule.maybeOpenSpecUpdateBeforeStatus();
5478
3276
  status([]);
5479
3277
  return;
5480
3278
  }
@@ -5488,7 +3286,7 @@ function main() {
5488
3286
  }
5489
3287
 
5490
3288
  if (command === '--version' || command === '-v' || command === 'version') {
5491
- maybeSelfUpdateBeforeStatus();
3289
+ toolchainModule.maybeSelfUpdateBeforeStatus();
5492
3290
  console.log(packageJson.version);
5493
3291
  return;
5494
3292
  }