@imdeadpool/guardex 7.0.43 → 7.1.0

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.
Files changed (63) hide show
  1. package/README.md +26 -0
  2. package/package.json +2 -1
  3. package/skills/gx-act/SKILL.md +82 -0
  4. package/src/agents/inspect.js +17 -4
  5. package/src/agents/launch.js +10 -1
  6. package/src/agents/status.js +9 -6
  7. package/src/budget/index.js +2 -1
  8. package/src/cli/args.js +52 -2
  9. package/src/cli/commands/agents.js +364 -0
  10. package/src/cli/commands/bootstrap.js +92 -0
  11. package/src/cli/commands/branch.js +127 -0
  12. package/src/cli/commands/claude.js +674 -0
  13. package/src/cli/commands/doctor.js +268 -0
  14. package/src/cli/commands/finish.js +26 -0
  15. package/src/cli/commands/mcp.js +122 -0
  16. package/src/cli/commands/misc.js +304 -0
  17. package/src/cli/commands/pr.js +439 -0
  18. package/src/cli/commands/prompt.js +92 -0
  19. package/src/cli/commands/release.js +305 -0
  20. package/src/cli/commands/report.js +244 -0
  21. package/src/cli/commands/review.js +32 -0
  22. package/src/cli/commands/setup.js +242 -0
  23. package/src/cli/commands/status.js +338 -0
  24. package/src/cli/commands/watch.js +234 -0
  25. package/src/cli/main.js +68 -3726
  26. package/src/cli/shared/repo-env.js +161 -0
  27. package/src/cli/shared/sandbox.js +417 -0
  28. package/src/cli/shared/scaffolding.js +535 -0
  29. package/src/cli/shared/toolchain-shims.js +420 -0
  30. package/src/context.js +229 -11
  31. package/src/core/runtime.js +6 -1
  32. package/src/doctor/index.js +42 -13
  33. package/src/finish/index.js +147 -5
  34. package/src/finish/preflight.js +177 -0
  35. package/src/finish/review-gate.js +182 -0
  36. package/src/git/index.js +446 -4
  37. package/src/hooks/index.js +0 -64
  38. package/src/mcp/collect.js +370 -0
  39. package/src/mcp/server.js +157 -0
  40. package/src/output/index.js +67 -1
  41. package/src/pr-review.js +23 -0
  42. package/src/pr.js +381 -0
  43. package/src/sandbox/index.js +13 -2
  44. package/src/scaffold/agent-worktree-prep.js +213 -0
  45. package/src/scaffold/index.js +108 -10
  46. package/src/speckit/index.js +226 -0
  47. package/src/terminal/index.js +1 -76
  48. package/src/terminal/tmux.js +0 -1
  49. package/src/toolchain/index.js +20 -0
  50. package/templates/AGENTS.monorepo-apps.md +26 -0
  51. package/templates/AGENTS.multiagent-safety.md +61 -347
  52. package/templates/AGENTS.multiagent-safety.min.md +11 -0
  53. package/templates/codex/skills/gx-act/SKILL.md +82 -0
  54. package/templates/githooks/pre-commit +22 -19
  55. package/templates/scripts/agent-branch-finish.sh +8 -30
  56. package/templates/scripts/agent-branch-merge.sh +4 -1
  57. package/templates/scripts/agent-branch-start.sh +88 -3
  58. package/templates/scripts/agent-preflight.sh +31 -5
  59. package/templates/scripts/agent-worktree-prune.sh +1 -1
  60. package/templates/scripts/codex-agent.sh +0 -91
  61. package/src/agents/detect.js +0 -160
  62. package/src/cockpit/keybindings.js +0 -224
  63. package/src/cockpit/layout.js +0 -224
package/src/cli/main.js CHANGED
@@ -1,151 +1,20 @@
1
1
  #!/usr/bin/env node
2
+ //
3
+ // Thin dispatcher for the `gx` CLI. Every subcommand handler lives in
4
+ // src/cli/commands/<verb>.js; shared scaffolding/sandbox helpers live in
5
+ // src/cli/shared/. The dispatcher only:
6
+ // - routes argv to the right handler module
7
+ // - applies typo/deprecation/suggestion normalization
8
+ // - implements the no-args default flow (cockpit + status + auto-doctor)
9
+ //
10
+ // All handler bodies were extracted verbatim during the v7.0.43 refactor.
2
11
 
3
- const hooksModule = require('../hooks');
4
- const sandboxModule = require('../sandbox');
12
+ const { cp, path, packageJson, TOOL_NAME, SHORT_TOOL_NAME, DEPRECATED_COMMAND_ALIASES } = require('../context');
5
13
  const toolchainModule = require('../toolchain');
6
- const finishCommands = require('../finish');
7
- const doctorModule = require('../doctor');
8
- const submoduleModule = require('../submodule');
9
- const agentInspect = require('../agents/inspect');
10
- const agentStatus = require('../agents/status');
11
- const agentCleanupSessions = require('../agents/cleanup-sessions');
12
- const { finishAgentSession } = require('../agents/finish');
13
- const sessionSeverityReport = require('../report/session-severity');
14
14
  const budgetModule = require('../budget');
15
15
  const ciInitModule = require('../ci-init');
16
- const cockpitModule = require('../cockpit');
17
- const agentsStart = require('../agents/start');
18
- const prReviewModule = require('../pr-review');
19
- const {
20
- fs,
21
- path,
22
- cp,
23
- packageJson,
24
- TOOL_NAME,
25
- SHORT_TOOL_NAME,
26
- OPENSPEC_PACKAGE,
27
- NPX_BIN,
28
- GUARDEX_HOME_DIR,
29
- GLOBAL_TOOLCHAIN_SERVICES,
30
- GLOBAL_TOOLCHAIN_PACKAGES,
31
- OPTIONAL_LOCAL_COMPANION_TOOLS,
32
- GH_BIN,
33
- REQUIRED_SYSTEM_TOOLS,
34
- MAINTAINER_RELEASE_REPO,
35
- NPM_BIN,
36
- OPENSPEC_BIN,
37
- SCORECARD_BIN,
38
- GIT_PROTECTED_BRANCHES_KEY,
39
- GIT_BASE_BRANCH_KEY,
40
- GIT_SYNC_STRATEGY_KEY,
41
- GUARDEX_REPO_TOGGLE_ENV,
42
- DEFAULT_PROTECTED_BRANCHES,
43
- DEFAULT_BASE_BRANCH,
44
- DEFAULT_SYNC_STRATEGY,
45
- COMPOSE_HINT_FILES,
46
- TEMPLATE_ROOT,
47
- HOOK_NAMES,
48
- TEMPLATE_FILES,
49
- LEGACY_WORKFLOW_SHIM_SPECS,
50
- LEGACY_MANAGED_REPO_FILES,
51
- REQUIRED_MANAGED_REPO_FILES,
52
- LEGACY_MANAGED_PACKAGE_SCRIPTS,
53
- PACKAGE_SCRIPT_ASSETS,
54
- USER_LEVEL_SKILL_ASSETS,
55
- EXECUTABLE_RELATIVE_PATHS,
56
- CRITICAL_GUARDRAIL_PATHS,
57
- LOCK_FILE_RELATIVE,
58
- AGENTS_BOTS_STATE_RELATIVE,
59
- AGENTS_MARKER_START,
60
- AGENTS_MARKER_END,
61
- GITIGNORE_MARKER_START,
62
- GITIGNORE_MARKER_END,
63
- SHARED_VSCODE_SETTINGS_RELATIVE,
64
- REPO_SCAN_IGNORED_FOLDERS_SETTING,
65
- AGENT_WORKTREE_RELATIVE_DIRS,
66
- MANAGED_REPO_SCAN_IGNORED_FOLDERS,
67
- MANAGED_GITIGNORE_PATHS,
68
- REPO_SCAFFOLD_DIRECTORIES,
69
- OMX_SCAFFOLD_DIRECTORIES,
70
- OMX_SCAFFOLD_FILES,
71
- TARGETED_FORCEABLE_MANAGED_PATHS,
72
- DEPRECATED_COMMAND_ALIASES,
73
- envFlagIsTruthy,
74
- defaultAgentWorktreeRelativeDir,
75
- listAiSetupPartNames,
76
- parseAiSetupPartNames,
77
- renderAiSetupPrompt,
78
- AI_SETUP_PROMPT,
79
- AI_SETUP_COMMANDS,
80
- SCORECARD_RISK_BY_CHECK,
81
- } = require('../context');
82
- const {
83
- gitRun,
84
- resolveRepoRoot,
85
- isGitRepo,
86
- discoverNestedGitRepos,
87
- uniquePreserveOrder,
88
- listLocalUserBranches,
89
- listLocalAgentBranches,
90
- mapWorktreePathsByBranch,
91
- gitRefExists,
92
- hasSignificantWorkingTreeChanges,
93
- readConfiguredProtectedBranches,
94
- readProtectedBranches,
95
- ensureSetupProtectedBranches,
96
- ensureSubmoduleAutoSync,
97
- writeProtectedBranches,
98
- readGitConfig,
99
- resolveBaseBranch,
100
- resolveSyncStrategy,
101
- currentBranchName,
102
- repoHasHeadCommit,
103
- readBranchDisplayName,
104
- hasOriginRemote: repoHasOriginRemote,
105
- detectComposeHintFiles,
106
- printSetupRepoHints,
107
- ensureRepoBranch,
108
- ensureOriginBaseRef,
109
- workingTreeIsDirty,
110
- aheadBehind,
111
- lockRegistryStatus,
112
- listAgentWorktrees,
113
- listLocalAgentBranchesForFinish,
114
- worktreeHasLocalChanges,
115
- branchExists,
116
- resolveFinishBaseBranch,
117
- branchMergedIntoBase,
118
- syncOperation,
119
- } = require('../git');
120
- const {
121
- run,
122
- extractTargetedArgs,
123
- packageAssetEnv,
124
- runPackageAsset,
125
- runReviewBotCommand,
126
- invokePackageAsset,
127
- } = require('../core/runtime');
128
- const {
129
- parseVersionString,
130
- compareParsedVersions,
131
- isNewerVersion,
132
- } = require('../core/versions');
133
- const { readSingleLineFromStdin } = require('../core/stdin');
134
- const {
135
- normalizeManagedForcePath,
136
- parseCommonArgs,
137
- parseSetupArgs,
138
- parseDoctorArgs,
139
- parseTargetFlag,
140
- parseReviewArgs,
141
- parsePrReviewArgs,
142
- parseAgentsArgs,
143
- parseReportArgs,
144
- parseSyncArgs,
145
- parseCleanupArgs,
146
- parseMergeArgs,
147
- parseFinishArgs,
148
- } = require('./args');
16
+ const speckitModule = require('../speckit');
17
+ const { usage, startTransientSpinner } = require('../output');
149
18
  const {
150
19
  maybeSuggestCommand,
151
20
  normalizeCommandOrThrow,
@@ -153,778 +22,56 @@ const {
153
22
  extractFlag,
154
23
  } = require('./dispatch');
155
24
  const {
156
- runtimeVersion,
157
- colorize,
158
- colorizeDoctorOutput,
159
- statusDot,
160
- printToolLogsSummary,
161
- getInvokedCliName,
162
- usage,
163
- formatElapsedDuration,
164
- startTransientSpinner,
165
- compactAutoFinishPathSegments,
166
- detectRecoverableAutoFinishConflict,
167
- printAutoFinishSummary,
168
- } = require('../output');
25
+ isInteractiveTerminal,
26
+ legacyDefaultStatusEnabled,
27
+ defaultCockpitDisabled,
28
+ parseBooleanLike,
29
+ } = require('./shared/repo-env');
30
+ const { resolveRepoRoot } = require('../git');
31
+
32
+ // Subcommand modules (each owns one or a small cluster of verbs).
33
+ const { status } = require('./commands/status');
34
+ const { setup } = require('./commands/setup');
35
+ const { install, fix, scan } = require('./commands/bootstrap');
36
+ const { doctor } = require('./commands/doctor');
37
+ const { review, prReview } = require('./commands/review');
38
+ const { pr: prCommand } = require('./commands/pr');
39
+ const { claude: claudeCommand } = require('./commands/claude');
40
+ const { agents } = require('./commands/agents');
41
+ const { mcp } = require('./commands/mcp');
42
+ const { report } = require('./commands/report');
43
+ const { release } = require('./commands/release');
44
+ const { watch } = require('./commands/watch');
169
45
  const {
170
- toDestinationPath,
171
- ensureParentDir,
172
- ensureExecutable,
173
- isCriticalGuardrailPath,
174
- shellSingleQuote,
175
- renderShellDispatchShim,
176
- renderPythonDispatchShim,
177
- managedForceConflictMessage,
178
- renderManagedFile,
179
- ensureGeneratedScriptShim,
180
- ensureHookShim,
181
- copyTemplateFile,
182
- ensureTemplateFilePresent,
183
- materializePackageRepoTemplateFiles,
184
- ensureOmxScaffold,
185
- ensureLockRegistry,
186
- lockStateOrError,
187
- writeLockState,
188
- removeLegacyPackageScripts,
189
- installUserLevelAsset,
190
- removeLegacyManagedRepoFile,
191
- ensureAgentsSnippet,
192
- ensureClaudeAgentsLink,
193
- ensureManagedGitignore,
194
- buildRepoVscodeSettings,
195
- ensureRepoVscodeSettings,
196
- configureHooks,
197
- printOperations,
198
- printStandaloneOperations,
199
- } = require('../scaffold');
200
-
201
- /**
202
- * @typedef {Object} AutoFinishSummary
203
- * @property {boolean} [enabled]
204
- * @property {number} [attempted]
205
- * @property {number} [completed]
206
- * @property {number} [skipped]
207
- * @property {number} [failed]
208
- * @property {string[]} [details]
209
- * @property {string} [baseBranch]
210
- */
211
-
212
- /**
213
- * @typedef {Object} OperationResult
214
- * @property {string} status
215
- * @property {string} note
216
- * @property {string} [stdout]
217
- * @property {string} [stderr]
218
- * @property {string} [prUrl]
219
- * @property {string[]} [stagedFiles]
220
- * @property {string} [commitMessage]
221
- * @property {unknown[]} [operations]
222
- * @property {OperationResult} [cleanup]
223
- * @property {OperationResult} [hookRefresh]
224
- */
225
-
226
- /**
227
- * @typedef {Object} SandboxMetadata
228
- * @property {string} branch
229
- * @property {string} worktreePath
230
- */
231
-
232
- /**
233
- * @typedef {Object} SandboxStartResult
234
- * @property {SandboxMetadata} metadata
235
- * @property {string} [stdout]
236
- * @property {string} [stderr]
237
- */
238
-
239
- /**
240
- * @typedef {Object} DoctorLockSyncState
241
- * @property {OperationResult} result
242
- * @property {string | null} sandboxLockContent
243
- */
244
-
245
- /**
246
- * @typedef {Object} DoctorSandboxExecution
247
- * @property {OperationResult} autoCommit
248
- * @property {OperationResult} finish
249
- * @property {OperationResult} protectedBaseRepairSync
250
- * @property {OperationResult} lockSync
251
- * @property {OperationResult} omxScaffoldSync
252
- * @property {AutoFinishSummary} autoFinish
253
- * @property {string | null} sandboxLockContent
254
- */
255
-
256
- function appendForceArgs(args, options) {
257
- if (!options.force) {
258
- return;
259
- }
260
- args.push('--force');
261
- for (const managedPath of options.forceManagedPaths || []) {
262
- args.push(managedPath);
263
- }
264
- }
265
-
266
- function shouldForceManagedPath(options, relativePath) {
267
- if (!options.force) {
268
- return false;
269
- }
270
- const targetedPaths = Array.isArray(options.forceManagedPaths) ? options.forceManagedPaths : [];
271
- if (targetedPaths.length === 0) {
272
- return true;
273
- }
274
- const normalized = normalizeManagedForcePath(relativePath);
275
- return normalized !== null && targetedPaths.includes(normalized);
276
- }
277
-
278
- function ensureTargetedLegacyWorkflowShims(repoRoot, options) {
279
- const targetedPaths = Array.isArray(options.forceManagedPaths) ? options.forceManagedPaths : [];
280
- if (targetedPaths.length === 0) {
281
- return [];
282
- }
283
-
284
- const operations = [];
285
- for (const shim of LEGACY_WORKFLOW_SHIM_SPECS) {
286
- if (!shouldForceManagedPath(options, shim.relativePath)) {
287
- continue;
288
- }
289
- operations.push(ensureGeneratedScriptShim(repoRoot, shim, { dryRun: options.dryRun, force: true }));
290
- }
291
- return operations;
292
- }
293
-
294
- function normalizeWorkspacePath(relativePath) {
295
- return String(relativePath || '.').replace(/\\/g, '/');
296
- }
297
-
298
- function isCommandAvailable(commandName) {
299
- return run('which', [commandName]).status === 0;
300
- }
301
-
302
- function buildParentWorkspaceView(repoRoot) {
303
- const parentDir = path.dirname(repoRoot);
304
- const workspaceFileName = `${path.basename(repoRoot)}-branches.code-workspace`;
305
- const workspacePath = path.join(parentDir, workspaceFileName);
306
- const repoRelativePath = normalizeWorkspacePath(path.relative(parentDir, repoRoot) || '.');
307
-
308
- return {
309
- workspacePath,
310
- payload: {
311
- folders: [
312
- { path: repoRelativePath },
313
- ...AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => ({
314
- path: normalizeWorkspacePath(path.join(repoRelativePath === '.' ? '' : repoRelativePath, relativeDir)),
315
- })),
316
- ],
317
- settings: {
318
- 'scm.alwaysShowRepositories': true,
319
- },
320
- },
321
- };
322
- }
323
-
324
- function ensureParentWorkspaceView(repoRoot, dryRun) {
325
- const { workspacePath, payload } = buildParentWorkspaceView(repoRoot);
326
- const operationFile = path.relative(repoRoot, workspacePath) || path.basename(workspacePath);
327
- const nextContent = `${JSON.stringify(payload, null, 2)}\n`;
328
- const note = 'parent VS Code workspace view';
329
-
330
- if (!fs.existsSync(workspacePath)) {
331
- if (!dryRun) {
332
- fs.writeFileSync(workspacePath, nextContent, 'utf8');
333
- }
334
- return { status: dryRun ? 'would-create' : 'created', file: operationFile, note };
335
- }
336
-
337
- const currentContent = fs.readFileSync(workspacePath, 'utf8');
338
- if (currentContent === nextContent) {
339
- return { status: 'unchanged', file: operationFile, note };
340
- }
341
-
342
- if (!dryRun) {
343
- fs.writeFileSync(workspacePath, nextContent, 'utf8');
344
- }
345
- return { status: dryRun ? 'would-update' : 'updated', file: operationFile, note };
346
- }
347
-
348
- function hasGuardexBootstrapFiles(repoRoot) {
349
- const required = [
350
- 'AGENTS.md',
351
- '.githooks/pre-commit',
352
- '.githooks/pre-push',
353
- LOCK_FILE_RELATIVE,
354
- ];
355
- return required.every((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
356
- }
357
-
358
- function protectedBaseWriteBlock(options, { requireBootstrap = true } = {}) {
359
- if (options.dryRun || options.allowProtectedBaseWrite) {
360
- return null;
361
- }
362
-
363
- const repoRoot = resolveRepoRoot(options.target);
364
- if (requireBootstrap && !hasGuardexBootstrapFiles(repoRoot)) {
365
- return null;
366
- }
367
-
368
- const branch = currentBranchName(repoRoot);
369
- if (branch !== 'main') {
370
- return null;
371
- }
372
-
373
- const protectedBranches = readProtectedBranches(repoRoot);
374
- if (!protectedBranches.includes(branch)) {
375
- return null;
376
- }
377
-
378
- return {
379
- repoRoot,
380
- branch,
381
- };
382
- }
383
-
384
- function assertProtectedMainWriteAllowed(options, commandName) {
385
- return sandboxModule.assertProtectedMainWriteAllowed(options, commandName);
386
- }
387
-
388
- function runSetupBootstrapInternal(options) {
389
- const installPayload = runInstallInternal(options);
390
- installPayload.operations.push(
391
- ensureSetupProtectedBranches(installPayload.repoRoot, Boolean(options.dryRun)),
392
- );
393
- installPayload.operations.push(
394
- ...ensureSubmoduleAutoSync(installPayload.repoRoot, Boolean(options.dryRun)),
395
- );
396
-
397
- let parentWorkspace = null;
398
- if (options.parentWorkspaceView) {
399
- installPayload.operations.push(
400
- ensureParentWorkspaceView(installPayload.repoRoot, Boolean(options.dryRun)),
401
- );
402
- if (!options.dryRun) {
403
- parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot);
404
- }
405
- }
406
-
407
- const fixPayload = runFixInternal({
408
- target: installPayload.repoRoot,
409
- dryRun: options.dryRun,
410
- force: options.force,
411
- forceManagedPaths: options.forceManagedPaths,
412
- dropStaleLocks: true,
413
- skipAgents: options.skipAgents,
414
- skipPackageJson: options.skipPackageJson,
415
- skipGitignore: options.skipGitignore,
416
- allowProtectedBaseWrite: options.allowProtectedBaseWrite,
417
- });
418
-
419
- return {
420
- installPayload,
421
- fixPayload,
422
- parentWorkspace,
423
- };
424
- }
425
-
426
- function extractAgentBranchStartMetadata(output) {
427
- const outputText = String(output || '');
428
- const branchMatch = outputText.match(/^\[agent-branch-start\] (?:Created branch|Reusing existing branch): (.+)$/m);
429
- const worktreeMatch = outputText.match(/^\[agent-branch-start\] Worktree: (.+)$/m);
430
- return {
431
- branch: branchMatch ? branchMatch[1].trim() : '',
432
- worktreePath: worktreeMatch ? worktreeMatch[1].trim() : '',
433
- };
434
- }
435
-
436
- function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
437
- const resolvedTarget = path.resolve(targetPath);
438
- const relativeTarget = path.relative(repoRoot, resolvedTarget);
439
- if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
440
- throw new Error(`sandbox target must stay inside repo root: ${resolvedTarget}`);
441
- }
442
- if (!relativeTarget || relativeTarget === '.') {
443
- return worktreePath;
444
- }
445
- return path.join(worktreePath, relativeTarget);
446
- }
447
-
448
- function buildSandboxSetupArgs(options, sandboxTarget) {
449
- const args = ['setup', '--target', sandboxTarget, '--no-global-install', '--no-recursive'];
450
- appendForceArgs(args, options);
451
- if (options.skipAgents) args.push('--skip-agents');
452
- if (options.skipPackageJson) args.push('--skip-package-json');
453
- if (options.skipGitignore) args.push('--no-gitignore');
454
- if (options.dryRun) args.push('--dry-run');
455
- return args;
456
- }
457
-
458
- function isSpawnFailure(result) {
459
- return Boolean(result?.error) && typeof result?.status !== 'number';
460
- }
461
-
462
- function protectedBaseSandboxBranchPrefix() {
463
- const now = new Date();
464
- const stamp = [
465
- now.getUTCFullYear(),
466
- String(now.getUTCMonth() + 1).padStart(2, '0'),
467
- String(now.getUTCDate()).padStart(2, '0'),
468
- ].join('') + '-' + [
469
- String(now.getUTCHours()).padStart(2, '0'),
470
- String(now.getUTCMinutes()).padStart(2, '0'),
471
- String(now.getUTCSeconds()).padStart(2, '0'),
472
- ].join('');
473
- return `agent/gx/${stamp}`;
474
- }
475
-
476
- function protectedBaseSandboxWorktreePath(repoRoot, branchName) {
477
- return path.join(repoRoot, defaultAgentWorktreeRelativeDir(), branchName.replace(/\//g, '__'));
478
- }
479
-
480
- function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) {
481
- run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 });
482
- if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) {
483
- return `origin/${baseBranch}`;
484
- }
485
- if (gitRefExists(repoRoot, `refs/heads/${baseBranch}`)) {
486
- return baseBranch;
487
- }
488
- if (currentBranchName(repoRoot) === baseBranch) {
489
- return null;
490
- }
491
- throw new Error(`Unable to find base ref for sandbox bootstrap: ${baseBranch}`);
492
- }
493
-
494
- function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
495
- const branchPrefix = protectedBaseSandboxBranchPrefix();
496
- let selectedBranch = '';
497
- let selectedWorktreePath = '';
498
-
499
- for (let attempt = 0; attempt < 30; attempt += 1) {
500
- const suffix = attempt === 0 ? sandboxSuffix : `${attempt + 1}-${sandboxSuffix}`;
501
- const candidateBranch = `${branchPrefix}-${suffix}`;
502
- const candidateWorktreePath = protectedBaseSandboxWorktreePath(blocked.repoRoot, candidateBranch);
503
- if (gitRefExists(blocked.repoRoot, `refs/heads/${candidateBranch}`)) {
504
- continue;
505
- }
506
- if (fs.existsSync(candidateWorktreePath)) {
507
- continue;
508
- }
509
- selectedBranch = candidateBranch;
510
- selectedWorktreePath = candidateWorktreePath;
511
- break;
512
- }
513
-
514
- if (!selectedBranch || !selectedWorktreePath) {
515
- throw new Error('Unable to allocate unique sandbox branch/worktree');
516
- }
517
-
518
- fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true });
519
- const startRef = resolveProtectedBaseSandboxStartRef(blocked.repoRoot, blocked.branch);
520
- const addArgs = startRef
521
- ? ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef]
522
- : ['-C', blocked.repoRoot, 'worktree', 'add', '--orphan', selectedWorktreePath];
523
- const addResult = run('git', addArgs);
524
- if (isSpawnFailure(addResult)) {
525
- throw addResult.error;
526
- }
527
- if (addResult.status !== 0) {
528
- throw new Error((addResult.stderr || addResult.stdout || 'failed to create sandbox').trim());
529
- }
530
-
531
- if (!startRef) {
532
- const renameResult = run(
533
- 'git',
534
- ['-C', selectedWorktreePath, 'branch', '-m', selectedBranch],
535
- { timeout: 20_000 },
536
- );
537
- if (isSpawnFailure(renameResult)) {
538
- throw renameResult.error;
539
- }
540
- if (renameResult.status !== 0) {
541
- throw new Error(
542
- (renameResult.stderr || renameResult.stdout || 'failed to name orphan sandbox branch').trim(),
543
- );
544
- }
545
- }
546
-
547
- return {
548
- metadata: {
549
- branch: selectedBranch,
550
- worktreePath: selectedWorktreePath,
551
- },
552
- stdout:
553
- `[agent-branch-start] Created branch: ${selectedBranch}\n` +
554
- `[agent-branch-start] Worktree: ${selectedWorktreePath}\n`,
555
- stderr: addResult.stderr || '',
556
- };
557
- }
558
-
559
- function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) {
560
- if (sandboxSuffix === 'gx-doctor') {
561
- return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
562
- }
563
-
564
- const startResult = runPackageAsset('branchStart', [
565
- '--task',
566
- taskName,
567
- '--agent',
568
- SHORT_TOOL_NAME,
569
- '--base',
570
- blocked.branch,
571
- ], { cwd: blocked.repoRoot });
572
- if (isSpawnFailure(startResult)) {
573
- throw startResult.error;
574
- }
575
- if (startResult.status !== 0) {
576
- return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
577
- }
578
-
579
- const metadata = extractAgentBranchStartMetadata(startResult.stdout);
580
- const currentBranch = currentBranchName(blocked.repoRoot);
581
- const worktreePath = metadata.worktreePath ? path.resolve(metadata.worktreePath) : '';
582
- const repoRootPath = path.resolve(blocked.repoRoot);
583
- const hasSafeWorktree = Boolean(worktreePath) && worktreePath !== repoRootPath;
584
- const branchChanged = Boolean(currentBranch) && currentBranch !== blocked.branch;
585
-
586
- if (!hasSafeWorktree || branchChanged) {
587
- const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
588
- if (!restoreResult.ok) {
589
- const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim();
590
- throw new Error(
591
- `sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
592
- (detail ? `\n${detail}` : ''),
593
- );
594
- }
595
- return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
596
- }
597
-
598
- return {
599
- metadata,
600
- stdout: startResult.stdout || '',
601
- stderr: startResult.stderr || '',
602
- };
603
- }
604
-
605
- function cleanupProtectedBaseSandbox(repoRoot, metadata) {
606
- const result = {
607
- worktree: 'skipped',
608
- branch: 'skipped',
609
- note: 'missing sandbox metadata',
610
- };
611
-
612
- if (!metadata?.worktreePath || !metadata?.branch) {
613
- return result;
614
- }
615
-
616
- if (fs.existsSync(metadata.worktreePath)) {
617
- const removeResult = run(
618
- 'git',
619
- ['-C', repoRoot, 'worktree', 'remove', '--force', metadata.worktreePath],
620
- { timeout: 30_000 },
621
- );
622
- if (isSpawnFailure(removeResult)) {
623
- throw removeResult.error;
624
- }
625
- if (removeResult.status !== 0) {
626
- throw new Error(
627
- (removeResult.stderr || removeResult.stdout || 'failed to remove sandbox worktree').trim(),
628
- );
629
- }
630
- result.worktree = 'removed';
631
- } else {
632
- result.worktree = 'missing';
633
- }
634
-
635
- if (gitRefExists(repoRoot, `refs/heads/${metadata.branch}`)) {
636
- const branchDeleteResult = run(
637
- 'git',
638
- ['-C', repoRoot, 'branch', '-D', metadata.branch],
639
- { timeout: 20_000 },
640
- );
641
- if (isSpawnFailure(branchDeleteResult)) {
642
- throw branchDeleteResult.error;
643
- }
644
- if (branchDeleteResult.status !== 0) {
645
- throw new Error(
646
- (branchDeleteResult.stderr || branchDeleteResult.stdout || 'failed to delete sandbox branch').trim(),
647
- );
648
- }
649
- result.branch = 'deleted';
650
- } else {
651
- result.branch = 'missing';
652
- }
653
-
654
- result.note = 'sandbox worktree pruned';
655
- return result;
656
- }
657
-
658
- function runSetupInSandbox(options, blocked, repoLabel = '') {
659
- const startResult = startProtectedBaseSandbox(blocked, {
660
- taskName: `${SHORT_TOOL_NAME}-setup`,
661
- sandboxSuffix: 'gx-setup',
662
- });
663
- const metadata = startResult.metadata;
664
-
665
- if (startResult.stdout) process.stdout.write(startResult.stdout);
666
- if (startResult.stderr) process.stderr.write(startResult.stderr);
667
- console.log(
668
- `[${TOOL_NAME}] setup blocked on protected branch '${blocked.branch}' in an initialized repo; ` +
669
- 'refreshing through a sandbox worktree and syncing managed bootstrap files back locally.',
670
- );
671
-
672
- const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
673
- const nestedResult = run(
674
- process.execPath,
675
- [__filename, ...buildSandboxSetupArgs(options, sandboxTarget)],
676
- { cwd: metadata.worktreePath, env: { GUARDEX_DOCTOR_SANDBOX: '1' } },
677
- );
678
- if (isSpawnFailure(nestedResult)) {
679
- throw nestedResult.error;
680
- }
681
- if (nestedResult.status !== 0) {
682
- if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
683
- if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
684
- throw new Error(
685
- `sandboxed setup failed for protected branch '${blocked.branch}'. ` +
686
- `Inspect sandbox at ${metadata.worktreePath}`,
687
- );
688
- }
689
-
690
- const syncOptions = {
691
- ...options,
692
- target: blocked.repoRoot,
693
- recursive: false,
694
- allowProtectedBaseWrite: true,
695
- };
696
- const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(syncOptions);
697
- printOperations(`Setup/install${repoLabel}`, installPayload, syncOptions.dryRun);
698
- printOperations(`Setup/fix${repoLabel}`, fixPayload, syncOptions.dryRun);
699
- if (!syncOptions.dryRun && parentWorkspace) {
700
- console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
701
- }
702
-
703
- const scanResult = runScanInternal({ target: blocked.repoRoot, json: false });
704
- const currentBaseBranch = currentBranchName(scanResult.repoRoot);
705
- const autoFinishSummary = doctorModule.autoFinishReadyAgentBranches(scanResult.repoRoot, {
706
- baseBranch: currentBaseBranch,
707
- dryRun: syncOptions.dryRun,
708
- });
709
- printScanResult(scanResult, false);
710
- if (autoFinishSummary.enabled) {
711
- console.log(
712
- `[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
713
- );
714
- for (const detail of autoFinishSummary.details) {
715
- console.log(`[${TOOL_NAME}] ${detail}`);
716
- }
717
- } else if (autoFinishSummary.details.length > 0) {
718
- console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
719
- }
720
-
721
- const prunePayload = doctorModule.pruneStaleAgentWorktrees(scanResult.repoRoot, {
722
- baseBranch: currentBaseBranch,
723
- dryRun: syncOptions.dryRun,
724
- });
725
- printWorktreePruneSummary(prunePayload, { baseBranch: currentBaseBranch });
726
-
727
- const cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
728
- console.log(
729
- `[${TOOL_NAME}] Protected-base setup sandbox cleanup: ${cleanupResult.note} ` +
730
- `(worktree=${cleanupResult.worktree}, branch=${cleanupResult.branch}).`,
731
- );
732
-
733
- return {
734
- scanResult,
735
- };
736
- }
737
-
738
-
739
- function todayDateStamp() {
740
- return new Date().toISOString().slice(0, 10);
741
- }
742
-
743
- function inferGithubRepoFromOrigin(repoRoot) {
744
- const rawOrigin = readGitConfig(repoRoot, 'remote.origin.url');
745
- if (!rawOrigin) return '';
746
-
747
- const httpsMatch = rawOrigin.match(/github\.com[:/](.+?)(?:\.git)?$/i);
748
- if (!httpsMatch) return '';
749
- const slug = (httpsMatch[1] || '').replace(/^\/+/, '').trim();
750
- if (!slug || !slug.includes('/')) return '';
751
- return `github.com/${slug}`;
752
- }
753
-
754
- function inferGithubRepoSlug(rawValue) {
755
- const raw = String(rawValue || '').trim();
756
- if (!raw) return '';
757
- const match = raw.match(/github\.com[:/](.+?)(?:\.git)?$/i);
758
- if (!match) return '';
759
- const slug = String(match[1] || '')
760
- .replace(/^\/+/, '')
761
- .replace(/^github\.com\//i, '')
762
- .trim();
763
- if (!slug || !slug.includes('/')) return '';
764
- return slug;
765
- }
766
-
767
- function resolveScorecardRepo(repoRoot, explicitRepo) {
768
- if (explicitRepo) {
769
- return explicitRepo.trim();
770
- }
771
- const inferred = inferGithubRepoFromOrigin(repoRoot);
772
- if (inferred) return inferred;
773
- throw new Error(
774
- 'Unable to infer GitHub repo from origin remote. Pass --repo github.com/<owner>/<repo>.',
775
- );
776
- }
777
-
778
- function runScorecardJson(repo) {
779
- const result = run(SCORECARD_BIN, ['--repo', repo, '--format', 'json'], { allowFailure: true });
780
- if (result.status !== 0) {
781
- const details = (result.stderr || result.stdout || '').trim();
782
- throw new Error(
783
- `Failed to run scorecard CLI ('${SCORECARD_BIN} --repo ${repo} --format json').${details ? `\n${details}` : ''}`,
784
- );
785
- }
786
-
787
- try {
788
- return JSON.parse(result.stdout || '{}');
789
- } catch (error) {
790
- throw new Error(`Unable to parse scorecard JSON output: ${error.message}`);
791
- }
792
- }
793
-
794
- function readScorecardJsonFile(filePath) {
795
- const absolute = path.resolve(filePath);
796
- if (!fs.existsSync(absolute)) {
797
- throw new Error(`scorecard JSON file not found: ${absolute}`);
798
- }
799
- try {
800
- return JSON.parse(fs.readFileSync(absolute, 'utf8'));
801
- } catch (error) {
802
- throw new Error(`Unable to parse scorecard JSON file: ${error.message}`);
803
- }
804
- }
805
-
806
- function normalizeScorecardChecks(payload) {
807
- const rawChecks = Array.isArray(payload?.checks) ? payload.checks : [];
808
- return rawChecks.map((check) => {
809
- const name = String(check?.name || 'Unknown');
810
- const rawScore = Number(check?.score);
811
- const score = Number.isFinite(rawScore) ? rawScore : 0;
812
- return {
813
- name,
814
- score,
815
- risk: SCORECARD_RISK_BY_CHECK[name] || 'Unknown',
816
- };
817
- });
818
- }
819
-
820
- function renderScorecardBaselineMarkdown({ repo, score, checks, capturedAt, scorecardVersion, reportDate }) {
821
- const rows = checks
822
- .map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`)
823
- .join('\n');
824
-
825
- return [
826
- '# OpenSSF Scorecard Baseline Report',
827
- '',
828
- `- **Repository:** \`${repo}\``,
829
- '- **Source:** generated by `gx report scorecard`',
830
- `- **Captured at:** ${capturedAt}`,
831
- `- **Scorecard version:** \`${scorecardVersion}\``,
832
- `- **Overall score:** **${score} / 10**`,
833
- '',
834
- '## Check breakdown',
835
- '',
836
- '| Check | Score | Risk |',
837
- '|---|---:|---|',
838
- rows || '| (none) | 0 | Unknown |',
839
- '',
840
- `## Report date`,
841
- '',
842
- `- ${reportDate}`,
843
- '',
844
- ].join('\n');
845
- }
846
-
847
- function renderScorecardRemediationPlanMarkdown({ baselineRelativePath, checks }) {
848
- const failing = checks.filter((item) => item.score < 10);
849
- const failingRows = failing
850
- .sort((a, b) => a.score - b.score || a.name.localeCompare(b.name))
851
- .map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`)
852
- .join('\n');
853
-
854
- return [
855
- '# OpenSSF Scorecard Remediation Plan',
856
- '',
857
- `Based on baseline report: \`${baselineRelativePath}\`.`,
858
- '',
859
- '## Failing checks',
860
- '',
861
- '| Check | Score | Risk |',
862
- '|---|---:|---|',
863
- (failingRows || '| None | 10 | N/A |'),
864
- '',
865
- '## Priority order',
866
- '',
867
- '1. Fix **High** risk checks first (especially score 0 items).',
868
- '2. Then close **Medium** risk checks with score < 10.',
869
- '3. Finally address **Low** risk ecosystem/process checks.',
870
- '',
871
- '## Verification loop',
872
- '',
873
- '1. Run scorecard again.',
874
- '2. Re-generate baseline + remediation files.',
875
- '3. Compare score deltas and track improved checks.',
876
- '',
877
- ].join('\n');
878
- }
879
-
880
- function parseBranchList(rawValue) {
881
- return String(rawValue || '')
882
- .split(/[\s,]+/)
883
- .map((item) => item.trim())
884
- .filter(Boolean);
885
- }
886
-
887
- function originRemoteLooksLikeGithub(repoRoot) {
888
- const originUrl = readGitConfig(repoRoot, 'remote.origin.url');
889
- if (!originUrl) {
890
- return false;
891
- }
892
- return /github\.com[:/]/i.test(originUrl);
893
- }
894
-
895
- function isInteractiveTerminal() {
896
- return Boolean(process.stdin.isTTY && process.stdout.isTTY);
897
- }
898
-
899
- function legacyDefaultStatusEnabled() {
900
- return envFlagIsTruthy(process.env.GUARDEX_LEGACY_STATUS);
901
- }
902
-
903
- function defaultCockpitDisabled() {
904
- const raw = process.env.GUARDEX_DEFAULT_COCKPIT;
905
- if (raw == null) return false;
906
- const normalized = String(raw).trim().toLowerCase();
907
- return ['0', 'false', 'no', 'off'].includes(normalized);
908
- }
909
-
910
- function parseAutoApproval(name) {
911
- const raw = process.env[name];
912
- if (raw == null) return null;
913
- const normalized = String(raw).trim().toLowerCase();
914
- if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
915
- if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
916
- return null;
917
- }
918
-
919
- function parseBooleanLike(raw) {
920
- if (raw == null) return null;
921
- const normalized = String(raw).trim().toLowerCase();
922
- if (!normalized) return null;
923
- if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
924
- if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
925
- return null;
926
- }
927
-
46
+ prompt,
47
+ printAgentsSnippet,
48
+ copyPrompt,
49
+ copyCommands,
50
+ } = require('./commands/prompt');
51
+ const {
52
+ branch,
53
+ pivot,
54
+ ship,
55
+ locks,
56
+ worktree,
57
+ } = require('./commands/branch');
58
+ const {
59
+ cleanup,
60
+ merge,
61
+ finish,
62
+ sync,
63
+ } = require('./commands/finish');
64
+ const {
65
+ hook,
66
+ internal,
67
+ installAgentSkills,
68
+ migrate,
69
+ submodule,
70
+ cockpit,
71
+ protect,
72
+ } = require('./commands/misc');
73
+
74
+ // `gx` (no args) — auto-doctor wiring. Reused only by the default flow.
928
75
  function autoDoctorEnabledForCurrentSession() {
929
76
  const explicit = parseBooleanLike(process.env.GUARDEX_AUTO_DOCTOR);
930
77
  if (explicit != null) {
@@ -999,2823 +146,13 @@ async function maybeAutoRunDoctorFromDefaultStatus(statusPayload) {
999
146
  return true;
1000
147
  }
1001
148
 
1002
- function parseDotenvAssignmentValue(raw) {
1003
- let value = String(raw || '').trim();
1004
- if (!value) return '';
1005
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
1006
- return value.slice(1, -1).trim();
1007
- }
1008
- value = value.replace(/\s+#.*$/, '').trim();
1009
- return value;
1010
- }
1011
-
1012
- function readRepoDotenvValue(repoRoot, name) {
1013
- const envPath = path.join(repoRoot, '.env');
1014
- if (!fs.existsSync(envPath)) return null;
1015
- const pattern = new RegExp(`^\\s*(?:export\\s+)?${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*(.*)$`);
1016
- const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/);
1017
- for (const line of lines) {
1018
- const trimmed = line.trim();
1019
- if (!trimmed || trimmed.startsWith('#')) continue;
1020
- const match = line.match(pattern);
1021
- if (!match) continue;
1022
- return parseDotenvAssignmentValue(match[1]);
1023
- }
1024
- return null;
1025
- }
1026
-
1027
- function resolveGuardexRepoToggle(repoRoot, env = process.env) {
1028
- const envRaw = env[GUARDEX_REPO_TOGGLE_ENV];
1029
- const envEnabled = parseBooleanLike(envRaw);
1030
- if (envEnabled !== null) {
1031
- return {
1032
- enabled: envEnabled,
1033
- source: 'process environment',
1034
- raw: String(envRaw).trim(),
1035
- };
1036
- }
1037
-
1038
- const dotenvRaw = readRepoDotenvValue(repoRoot, GUARDEX_REPO_TOGGLE_ENV);
1039
- const dotenvEnabled = parseBooleanLike(dotenvRaw);
1040
- if (dotenvEnabled !== null) {
1041
- return {
1042
- enabled: dotenvEnabled,
1043
- source: 'repo .env',
1044
- raw: String(dotenvRaw).trim(),
1045
- };
1046
- }
1047
-
1048
- return {
1049
- enabled: true,
1050
- source: 'default',
1051
- raw: '',
1052
- };
1053
- }
1054
-
1055
- function describeGuardexRepoToggle(toggle) {
1056
- if (!toggle || toggle.source === 'default') {
1057
- return 'default enabled mode';
1058
- }
1059
- return `${toggle.source} (${GUARDEX_REPO_TOGGLE_ENV}=${toggle.raw})`;
1060
- }
1061
-
1062
- function parseNpmVersionOutput(stdout) {
1063
- const trimmed = String(stdout || '').trim();
1064
- if (!trimmed) return '';
1065
-
1066
- try {
1067
- const parsed = JSON.parse(trimmed);
1068
- if (Array.isArray(parsed)) {
1069
- return String(parsed[parsed.length - 1] || '').trim();
1070
- }
1071
- return String(parsed || '').trim();
1072
- } catch {
1073
- const firstLine = trimmed.split('\n').map((line) => line.trim()).find(Boolean);
1074
- return firstLine || '';
1075
- }
1076
- }
1077
-
1078
- function checkForGuardexUpdate() {
1079
- if (envFlagIsTruthy(process.env.GUARDEX_SKIP_UPDATE_CHECK)) {
1080
- return { checked: false, reason: 'disabled' };
1081
- }
1082
-
1083
- const forceCheck = envFlagIsTruthy(process.env.GUARDEX_FORCE_UPDATE_CHECK);
1084
- if (!forceCheck && !isInteractiveTerminal()) {
1085
- return { checked: false, reason: 'non-interactive' };
1086
- }
1087
-
1088
- const result = run(NPM_BIN, ['view', packageJson.name, 'version', '--json'], { timeout: 5000 });
1089
- if (result.status !== 0) {
1090
- return { checked: false, reason: 'lookup-failed' };
1091
- }
1092
-
1093
- const latest = parseNpmVersionOutput(result.stdout);
1094
- if (!latest) {
1095
- return { checked: false, reason: 'invalid-latest-version' };
1096
- }
1097
-
1098
- return {
1099
- checked: true,
1100
- current: packageJson.version,
1101
- latest,
1102
- updateAvailable: isNewerVersion(latest, packageJson.version),
1103
- };
1104
- }
1105
-
1106
- function printUpdateAvailableBanner(current, latest) {
1107
- const title = colorize('UPDATE AVAILABLE', '1;33');
1108
- console.log(`[${TOOL_NAME}] ${title}`);
1109
- console.log(`[${TOOL_NAME}] Current: ${current}`);
1110
- console.log(`[${TOOL_NAME}] Latest : ${latest}`);
1111
- console.log(`[${TOOL_NAME}] Command: ${NPM_BIN} i -g ${packageJson.name}@latest`);
1112
- }
1113
-
1114
- function maybeSelfUpdateBeforeStatus() {
1115
- return toolchainModule.maybeSelfUpdateBeforeStatus();
1116
- }
1117
-
1118
- function readInstalledGuardexVersion() {
1119
- const installInfo = readInstalledGuardexInstallInfo();
1120
- return installInfo ? installInfo.version : null;
1121
- }
1122
-
1123
- function readInstalledGuardexInstallInfo() {
1124
- // Resolves the globally-installed package's on-disk version so we can
1125
- // verify npm actually wrote new bytes. Uses `npm root -g` to locate the
1126
- // global install root so we don't accidentally read the running source
1127
- // tree (which is the file the CLI was spawned from — that IS the global
1128
- // copy in the normal case, but a bump should be visible via a fresh read
1129
- // either way). Returns null if we can't determine it.
1130
- try {
1131
- const rootResult = run(NPM_BIN, ['root', '-g'], { timeout: 5000 });
1132
- if (rootResult.status !== 0) {
1133
- return null;
1134
- }
1135
- const globalRoot = String(rootResult.stdout || '').trim();
1136
- if (!globalRoot) {
1137
- return null;
1138
- }
1139
- const installedPkgPath = path.join(globalRoot, packageJson.name, 'package.json');
1140
- if (!fs.existsSync(installedPkgPath)) {
1141
- return null;
1142
- }
1143
- const parsed = JSON.parse(fs.readFileSync(installedPkgPath, 'utf8'));
1144
- if (parsed && typeof parsed.version === 'string') {
1145
- let binRelative = null;
1146
- if (typeof parsed.bin === 'string') {
1147
- binRelative = parsed.bin;
1148
- } else if (parsed.bin && typeof parsed.bin === 'object') {
1149
- const invokedName = path.basename(process.argv[1] || '');
1150
- binRelative =
1151
- parsed.bin[invokedName] ||
1152
- parsed.bin[SHORT_TOOL_NAME] ||
1153
- Object.values(parsed.bin).find((value) => typeof value === 'string') ||
1154
- null;
1155
- }
1156
- const packageRoot = path.dirname(installedPkgPath);
1157
- const binPath = binRelative ? path.join(packageRoot, binRelative) : null;
1158
- return {
1159
- version: parsed.version,
1160
- packageRoot,
1161
- binPath,
1162
- };
1163
- }
1164
- } catch (error) {
1165
- return null;
1166
- }
1167
- return null;
1168
- }
1169
-
1170
- function restartIntoUpdatedGuardex(expectedVersion) {
1171
- const installInfo = readInstalledGuardexInstallInfo();
1172
- if (!installInfo || installInfo.version !== expectedVersion || installInfo.version === packageJson.version) {
1173
- return;
1174
- }
1175
- if (!installInfo.binPath || !fs.existsSync(installInfo.binPath)) {
1176
- console.log(`[${TOOL_NAME}] Restart required to use ${installInfo.version}. Rerun ${SHORT_TOOL_NAME}.`);
1177
- return;
1178
- }
1179
-
1180
- console.log(`[${TOOL_NAME}] Restarting into ${installInfo.version}…`);
1181
- const restartResult = cp.spawnSync(
1182
- process.execPath,
1183
- [installInfo.binPath, ...process.argv.slice(2)],
1184
- {
1185
- cwd: process.cwd(),
1186
- env: {
1187
- ...process.env,
1188
- GUARDEX_SKIP_UPDATE_CHECK: '1',
1189
- },
1190
- stdio: 'inherit',
1191
- },
1192
- );
1193
- if (restartResult.error) {
1194
- console.log(
1195
- `[${TOOL_NAME}] Restart into ${installInfo.version} failed. Rerun ${SHORT_TOOL_NAME}.`,
1196
- );
1197
- return;
1198
- }
1199
- process.exit(restartResult.status == null ? 0 : restartResult.status);
1200
- }
1201
-
1202
- function checkForOpenSpecPackageUpdate() {
1203
- if (envFlagIsTruthy(process.env.GUARDEX_SKIP_OPENSPEC_UPDATE_CHECK)) {
1204
- return { checked: false, reason: 'disabled' };
1205
- }
1206
-
1207
- const forceCheck = envFlagIsTruthy(process.env.GUARDEX_FORCE_OPENSPEC_UPDATE_CHECK);
1208
- if (!forceCheck && !isInteractiveTerminal()) {
1209
- return { checked: false, reason: 'non-interactive' };
1210
- }
1211
-
1212
- const detection = detectGlobalToolchainPackages();
1213
- if (!detection.ok) {
1214
- return { checked: false, reason: 'package-detect-failed' };
1215
- }
1216
-
1217
- const current = String((detection.installedVersions || {})[OPENSPEC_PACKAGE] || '').trim();
1218
- if (!current) {
1219
- return { checked: false, reason: 'not-installed' };
1220
- }
1221
-
1222
- const latestResult = run(NPM_BIN, ['view', OPENSPEC_PACKAGE, 'version', '--json'], { timeout: 5000 });
1223
- if (latestResult.status !== 0) {
1224
- return { checked: false, reason: 'lookup-failed' };
1225
- }
1226
-
1227
- const latest = parseNpmVersionOutput(latestResult.stdout);
1228
- if (!latest) {
1229
- return { checked: false, reason: 'invalid-latest-version' };
1230
- }
1231
-
1232
- return {
1233
- checked: true,
1234
- current,
1235
- latest,
1236
- updateAvailable: isNewerVersion(latest, current),
1237
- };
1238
- }
1239
-
1240
- function printOpenSpecUpdateAvailableBanner(current, latest) {
1241
- const title = colorize('OPENSPEC UPDATE AVAILABLE', '1;33');
1242
- console.log(`[${TOOL_NAME}] ${title}`);
1243
- console.log(`[${TOOL_NAME}] Current: ${current}`);
1244
- console.log(`[${TOOL_NAME}] Latest : ${latest}`);
1245
- console.log(`[${TOOL_NAME}] Command: ${NPM_BIN} i -g ${OPENSPEC_PACKAGE}@latest`);
1246
- console.log(`[${TOOL_NAME}] Then : ${OPENSPEC_BIN} update`);
1247
- }
1248
-
1249
- function maybeOpenSpecUpdateBeforeStatus() {
1250
- return toolchainModule.maybeOpenSpecUpdateBeforeStatus();
1251
- }
1252
-
1253
- function promptYesNoStrict(question) {
1254
- while (true) {
1255
- process.stdout.write(`${question} [y/n] `);
1256
- const answer = readSingleLineFromStdin().trim().toLowerCase();
1257
-
1258
- if (answer === 'y' || answer === 'yes') {
1259
- process.stdout.write('\n');
1260
- return true;
1261
- }
1262
- if (answer === 'n' || answer === 'no') {
1263
- process.stdout.write('\n');
1264
- return false;
1265
- }
1266
-
1267
- process.stdout.write('Please answer with y or n.\n');
1268
- }
1269
- }
1270
-
1271
- function resolveGlobalInstallApproval(options) {
1272
- if (options.yesGlobalInstall && options.noGlobalInstall) {
1273
- throw new Error('Cannot use both --yes-global-install and --no-global-install');
1274
- }
1275
-
1276
- if (options.yesGlobalInstall) {
1277
- return { approved: true, source: 'flag' };
1278
- }
1279
-
1280
- if (options.noGlobalInstall) {
1281
- return { approved: false, source: 'flag' };
1282
- }
1283
-
1284
- if (!isInteractiveTerminal()) {
1285
- return { approved: false, source: 'non-interactive-default' };
1286
- }
1287
- return { approved: true, source: 'prompt' };
1288
- }
1289
-
1290
- function getGlobalToolchainService(packageName) {
1291
- const service = GLOBAL_TOOLCHAIN_SERVICES.find(
1292
- (candidate) => candidate.packageName === packageName,
1293
- );
1294
- return service || { name: packageName, packageName };
1295
- }
1296
-
1297
- function formatGlobalToolchainServiceName(packageName) {
1298
- return getGlobalToolchainService(packageName).name;
1299
- }
1300
-
1301
- function describeMissingGlobalDependencyWarnings(packageNames) {
1302
- return packageNames
1303
- .map((packageName) => getGlobalToolchainService(packageName))
1304
- .filter((service) => service.dependencyUrl)
1305
- .map(
1306
- (service) =>
1307
- `Guardex needs ${service.name} as a dependency: ${service.dependencyUrl}`,
1308
- );
1309
- }
1310
-
1311
- function buildMissingCompanionInstallPrompt(missingPackages, missingLocalTools) {
1312
- const dependencyWarnings = describeMissingGlobalDependencyWarnings(missingPackages);
1313
- const installCommands = describeCompanionInstallCommands(missingPackages, missingLocalTools);
1314
- const dependencyPrefix = dependencyWarnings.length > 0
1315
- ? `${dependencyWarnings.join(' ')} `
1316
- : '';
1317
- return `${dependencyPrefix}Install missing companion tools now? (${installCommands.join(' && ')})`;
1318
- }
1319
-
1320
- function detectGlobalToolchainPackages() {
1321
- const result = run(NPM_BIN, ['list', '-g', '--depth=0', '--json']);
1322
- if (result.status !== 0) {
1323
- const stderr = (result.stderr || '').trim();
1324
- return {
1325
- ok: false,
1326
- error: stderr || 'Unable to detect globally installed npm packages',
1327
- };
1328
- }
1329
-
1330
- let parsed;
1331
- try {
1332
- parsed = JSON.parse(result.stdout || '{}');
1333
- } catch (error) {
1334
- return {
1335
- ok: false,
1336
- error: `Failed to parse npm list output: ${error.message}`,
1337
- };
1338
- }
1339
-
1340
- const dependencyMap = parsed && parsed.dependencies && typeof parsed.dependencies === 'object'
1341
- ? parsed.dependencies
1342
- : {};
1343
- const installedSet = new Set(Object.keys(dependencyMap));
1344
-
1345
- const installed = [];
1346
- const missing = [];
1347
- const installedVersions = {};
1348
- for (const pkg of GLOBAL_TOOLCHAIN_PACKAGES) {
1349
- if (installedSet.has(pkg)) {
1350
- installed.push(pkg);
1351
- const rawVersion = dependencyMap[pkg] && dependencyMap[pkg].version;
1352
- const version = String(rawVersion || '').trim();
1353
- if (version) {
1354
- installedVersions[pkg] = version;
1355
- }
1356
- } else {
1357
- missing.push(pkg);
1358
- }
1359
- }
1360
-
1361
- return { ok: true, installed, missing, installedVersions };
1362
- }
1363
-
1364
- function detectRequiredSystemTools() {
1365
- const services = [];
1366
- for (const tool of REQUIRED_SYSTEM_TOOLS) {
1367
- const result = run(tool.command, ['--version']);
1368
- const active = result.status === 0;
1369
- const rawReason = result.error && result.error.code
1370
- ? result.error.code
1371
- : (result.stderr || '').trim();
1372
- const reason = rawReason.split('\n')[0] || '';
1373
- services.push({
1374
- name: tool.name,
1375
- displayName: tool.displayName || tool.name,
1376
- command: tool.command,
1377
- installHint: tool.installHint,
1378
- status: active ? 'active' : 'inactive',
1379
- reason,
1380
- });
1381
- }
1382
- return services;
1383
- }
1384
-
1385
- function detectOptionalLocalCompanionTools() {
1386
- return OPTIONAL_LOCAL_COMPANION_TOOLS.map((tool) => {
1387
- const detectedPath = tool.candidatePaths
1388
- .map((relativePath) => path.join(GUARDEX_HOME_DIR, relativePath))
1389
- .find((candidatePath) => fs.existsSync(candidatePath));
1390
- return {
1391
- name: tool.name,
1392
- displayName: tool.displayName || tool.name,
1393
- installCommand: tool.installCommand,
1394
- installArgs: [...tool.installArgs],
1395
- status: detectedPath ? 'active' : 'inactive',
1396
- detectedPath: detectedPath || null,
1397
- };
1398
- });
1399
- }
1400
-
1401
- function describeCompanionInstallCommands(missingPackages, missingLocalTools) {
1402
- const commands = [];
1403
- if (missingPackages.length > 0) {
1404
- commands.push(`${NPM_BIN} i -g ${missingPackages.join(' ')}`);
1405
- }
1406
- for (const tool of missingLocalTools) {
1407
- commands.push(tool.installCommand);
1408
- }
1409
- return commands;
1410
- }
1411
-
1412
- function askGlobalInstallForMissing(options, missingPackages, missingLocalTools) {
1413
- const approval = resolveGlobalInstallApproval(options);
1414
- if (!approval.approved) {
1415
- return approval;
1416
- }
1417
-
1418
- if (approval.source === 'prompt') {
1419
- const approved = promptYesNoStrict(
1420
- buildMissingCompanionInstallPrompt(missingPackages, missingLocalTools),
1421
- );
1422
- return { approved, source: 'prompt' };
1423
- }
1424
-
1425
- return approval;
1426
- }
1427
-
1428
- function installGlobalToolchain(options) {
1429
- return toolchainModule.installGlobalToolchain(options);
1430
- }
1431
-
1432
- function findStaleLockPaths(repoRoot, locks) {
1433
- const stale = [];
1434
-
1435
- for (const [filePath, rawEntry] of Object.entries(locks)) {
1436
- const entry = rawEntry && typeof rawEntry === 'object' ? rawEntry : {};
1437
- const ownerBranch = String(entry.branch || '');
1438
-
1439
- const hasOwner = ownerBranch.length > 0;
1440
- const localRef = hasOwner ? `refs/heads/${ownerBranch}` : null;
1441
- const remoteRef = hasOwner ? `refs/remotes/origin/${ownerBranch}` : null;
1442
- const branchExists = hasOwner
1443
- ? gitRefExists(repoRoot, localRef) || gitRefExists(repoRoot, remoteRef)
1444
- : false;
1445
-
1446
- const pathExists = fs.existsSync(path.join(repoRoot, filePath));
1447
-
1448
- if (!hasOwner || !branchExists || !pathExists) {
1449
- stale.push(filePath);
1450
- }
1451
- }
1452
-
1453
- return stale;
1454
- }
1455
-
1456
- function runInstallInternal(options) {
1457
- const repoRoot = resolveRepoRoot(options.target);
1458
- const guardexToggle = resolveGuardexRepoToggle(repoRoot);
1459
- if (!guardexToggle.enabled) {
1460
- return {
1461
- repoRoot,
1462
- operations: [
1463
- {
1464
- status: 'skipped',
1465
- file: '.env',
1466
- note: `Guardex disabled by ${describeGuardexRepoToggle(guardexToggle)}`,
1467
- },
1468
- ],
1469
- hookResult: { status: 'skipped', key: 'core.hooksPath', value: '(unchanged)' },
1470
- guardexEnabled: false,
1471
- guardexToggle,
1472
- };
1473
- }
1474
- const operations = [];
1475
-
1476
- if (!options.skipGitignore) {
1477
- operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
1478
- }
1479
- operations.push(ensureRepoVscodeSettings(repoRoot, Boolean(options.dryRun)));
1480
-
1481
- operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));
1482
-
1483
- for (const templateFile of TEMPLATE_FILES) {
1484
- operations.push(
1485
- copyTemplateFile(
1486
- repoRoot,
1487
- templateFile,
1488
- shouldForceManagedPath(options, toDestinationPath(templateFile)),
1489
- Boolean(options.dryRun),
1490
- ),
1491
- );
1492
- }
1493
- operations.push(...materializePackageRepoTemplateFiles(repoRoot, TEMPLATE_FILES, Boolean(options.dryRun)));
1494
- operations.push(...ensureTargetedLegacyWorkflowShims(repoRoot, options));
1495
- for (const hookName of HOOK_NAMES) {
1496
- const hookRelativePath = path.posix.join('.githooks', hookName);
1497
- operations.push(
1498
- ensureHookShim(repoRoot, hookName, {
1499
- dryRun: options.dryRun,
1500
- force: shouldForceManagedPath(options, hookRelativePath),
1501
- }),
1502
- );
1503
- }
1504
-
1505
- operations.push(ensureLockRegistry(repoRoot, Boolean(options.dryRun)));
1506
-
1507
- if (!options.skipAgents) {
1508
- operations.push(ensureAgentsSnippet(repoRoot, Boolean(options.dryRun), { force: Boolean(options.force) }));
1509
- operations.push(ensureClaudeAgentsLink(repoRoot, Boolean(options.dryRun)));
1510
- }
1511
-
1512
- const hookResult = configureHooks(repoRoot, Boolean(options.dryRun));
1513
-
1514
- return { repoRoot, operations, hookResult, guardexEnabled: true, guardexToggle };
1515
- }
1516
-
1517
- function runFixInternal(options) {
1518
- const repoRoot = resolveRepoRoot(options.target);
1519
- const guardexToggle = resolveGuardexRepoToggle(repoRoot);
1520
- if (!guardexToggle.enabled) {
1521
- return {
1522
- repoRoot,
1523
- operations: [
1524
- {
1525
- status: 'skipped',
1526
- file: '.env',
1527
- note: `Guardex disabled by ${describeGuardexRepoToggle(guardexToggle)}`,
1528
- },
1529
- ],
1530
- hookResult: { status: 'skipped', key: 'core.hooksPath', value: '(unchanged)' },
1531
- guardexEnabled: false,
1532
- guardexToggle,
1533
- };
1534
- }
1535
- const operations = [];
1536
-
1537
- if (!options.skipGitignore) {
1538
- operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
1539
- }
1540
- operations.push(ensureRepoVscodeSettings(repoRoot, Boolean(options.dryRun)));
1541
-
1542
- operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));
1543
-
1544
- for (const templateFile of TEMPLATE_FILES) {
1545
- if (shouldForceManagedPath(options, toDestinationPath(templateFile))) {
1546
- operations.push(copyTemplateFile(repoRoot, templateFile, true, Boolean(options.dryRun)));
1547
- continue;
1548
- }
1549
- operations.push(ensureTemplateFilePresent(repoRoot, templateFile, Boolean(options.dryRun)));
1550
- }
1551
- operations.push(...materializePackageRepoTemplateFiles(repoRoot, TEMPLATE_FILES, Boolean(options.dryRun)));
1552
- operations.push(...ensureTargetedLegacyWorkflowShims(repoRoot, options));
1553
- for (const hookName of HOOK_NAMES) {
1554
- const hookRelativePath = path.posix.join('.githooks', hookName);
1555
- operations.push(
1556
- ensureHookShim(repoRoot, hookName, {
1557
- dryRun: options.dryRun,
1558
- force: shouldForceManagedPath(options, hookRelativePath),
1559
- }),
1560
- );
1561
- }
1562
-
1563
- operations.push(ensureLockRegistry(repoRoot, Boolean(options.dryRun)));
1564
-
1565
- const lockState = lockStateOrError(repoRoot);
1566
- if (!lockState.ok) {
1567
- if (!options.dryRun) {
1568
- writeLockState(repoRoot, { locks: {} }, false);
1569
- }
1570
- operations.push({
1571
- status: options.dryRun ? 'would-reset' : 'reset',
1572
- file: LOCK_FILE_RELATIVE,
1573
- note: 'invalid lock state reset to empty',
1574
- });
1575
- } else {
1576
- const staleLockPaths = options.dropStaleLocks ? findStaleLockPaths(repoRoot, lockState.locks) : [];
1577
- if (staleLockPaths.length > 0) {
1578
- const updated = { ...lockState.raw, locks: { ...lockState.locks } };
1579
- for (const filePath of staleLockPaths) {
1580
- delete updated.locks[filePath];
1581
- }
1582
- writeLockState(repoRoot, updated, Boolean(options.dryRun));
1583
- operations.push({
1584
- status: options.dryRun ? 'would-prune' : 'pruned',
1585
- file: LOCK_FILE_RELATIVE,
1586
- note: `removed ${staleLockPaths.length} stale lock(s)`,
1587
- });
1588
- }
1589
- }
1590
-
1591
- if (!options.skipAgents) {
1592
- operations.push(ensureAgentsSnippet(repoRoot, Boolean(options.dryRun), { force: Boolean(options.force) }));
1593
- operations.push(ensureClaudeAgentsLink(repoRoot, Boolean(options.dryRun)));
1594
- }
1595
-
1596
- const hookResult = configureHooks(repoRoot, Boolean(options.dryRun));
1597
-
1598
- return { repoRoot, operations, hookResult, guardexEnabled: true, guardexToggle };
1599
- }
1600
-
1601
- function runScanInternal(options) {
1602
- const repoRoot = resolveRepoRoot(options.target);
1603
- const guardexToggle = resolveGuardexRepoToggle(repoRoot);
1604
- const branch = readBranchDisplayName(repoRoot);
1605
- if (!guardexToggle.enabled) {
1606
- return {
1607
- repoRoot,
1608
- branch,
1609
- findings: [],
1610
- errors: 0,
1611
- warnings: 0,
1612
- guardexEnabled: false,
1613
- guardexToggle,
1614
- };
1615
- }
1616
- const findings = [];
1617
-
1618
- const requiredPaths = [
1619
- ...OMX_SCAFFOLD_DIRECTORIES,
1620
- ...Array.from(OMX_SCAFFOLD_FILES.keys()),
1621
- ...REQUIRED_MANAGED_REPO_FILES,
1622
- ];
1623
-
1624
- for (const relativePath of requiredPaths) {
1625
- const absolutePath = path.join(repoRoot, relativePath);
1626
- if (!fs.existsSync(absolutePath)) {
1627
- findings.push({
1628
- level: 'error',
1629
- code: 'missing-managed-file',
1630
- path: relativePath,
1631
- message: `Missing managed repo file: ${relativePath}`,
1632
- });
1633
- }
1634
- }
1635
-
1636
- const hooksPathResult = gitRun(repoRoot, ['config', '--get', 'core.hooksPath'], { allowFailure: true });
1637
- const hooksPath = hooksPathResult.status === 0 ? hooksPathResult.stdout.trim() : '';
1638
- if (hooksPath !== '.githooks') {
1639
- findings.push({
1640
- level: 'warn',
1641
- code: 'hooks-path-mismatch',
1642
- message: `git core.hooksPath is '${hooksPath || '(unset)'}' (expected '.githooks')`,
1643
- });
1644
- }
1645
-
1646
- const lockState = lockStateOrError(repoRoot);
1647
- if (!lockState.ok) {
1648
- findings.push({
1649
- level: 'error',
1650
- code: 'lock-state-invalid',
1651
- message: lockState.error,
1652
- });
1653
- } else {
1654
- for (const [filePath, rawEntry] of Object.entries(lockState.locks)) {
1655
- const entry = rawEntry && typeof rawEntry === 'object' ? rawEntry : {};
1656
- const ownerBranch = String(entry.branch || '');
1657
- const allowDelete = Boolean(entry.allow_delete);
1658
-
1659
- if (!ownerBranch) {
1660
- findings.push({
1661
- level: 'warn',
1662
- code: 'lock-missing-owner',
1663
- path: filePath,
1664
- message: `Lock entry has no owner branch: ${filePath}`,
1665
- });
1666
- }
1667
-
1668
- const absolutePath = path.join(repoRoot, filePath);
1669
- if (!fs.existsSync(absolutePath)) {
1670
- findings.push({
1671
- level: 'warn',
1672
- code: 'lock-target-missing',
1673
- path: filePath,
1674
- message: `Locked path is missing from disk: ${filePath}`,
1675
- });
1676
- }
1677
-
1678
- if (ownerBranch) {
1679
- const localRef = `refs/heads/${ownerBranch}`;
1680
- const remoteRef = `refs/remotes/origin/${ownerBranch}`;
1681
- if (!gitRefExists(repoRoot, localRef) && !gitRefExists(repoRoot, remoteRef)) {
1682
- findings.push({
1683
- level: 'warn',
1684
- code: 'stale-branch-lock',
1685
- path: filePath,
1686
- message: `Lock owner branch not found locally/remotely: ${ownerBranch} (${filePath})`,
1687
- });
1688
- }
1689
- }
1690
-
1691
- if (allowDelete && CRITICAL_GUARDRAIL_PATHS.has(filePath)) {
1692
- findings.push({
1693
- level: 'error',
1694
- code: 'guardrail-delete-approved',
1695
- path: filePath,
1696
- message: `Critical guardrail file is delete-approved: ${filePath}`,
1697
- });
1698
- }
1699
- }
1700
- }
1701
-
1702
- const errors = findings.filter((item) => item.level === 'error');
1703
- const warnings = findings.filter((item) => item.level === 'warn');
1704
-
1705
- return {
1706
- repoRoot,
1707
- branch,
1708
- findings,
1709
- errors: errors.length,
1710
- warnings: warnings.length,
1711
- guardexEnabled: true,
1712
- guardexToggle,
1713
- };
1714
- }
1715
-
1716
- function printWorktreePruneSummary(payload, options = {}) {
1717
- if (!payload || payload.enabled === false) {
1718
- if (payload && payload.details && payload.details[0]) {
1719
- console.log(`[${TOOL_NAME}] ${payload.details[0]}`);
1720
- }
1721
- return;
1722
- }
1723
- if (!payload.ran) {
1724
- return;
1725
- }
1726
- const baseLabel = options.baseBranch ? ` (base=${options.baseBranch})` : '';
1727
- const tag = payload.status === 'failed' ? '⚠️' : (payload.status === 'dry-run' ? '🔍' : '🧹');
1728
- console.log(
1729
- `[${TOOL_NAME}] ${tag} Stale agent-worktree prune${baseLabel}: status=${payload.status}`,
1730
- );
1731
- for (const detail of payload.details || []) {
1732
- console.log(`[${TOOL_NAME}] ${detail}`);
1733
- }
1734
- }
1735
-
1736
- function printScanResult(scan, json = false) {
1737
- if (json) {
1738
- process.stdout.write(
1739
- JSON.stringify(
1740
- {
1741
- repoRoot: scan.repoRoot,
1742
- branch: scan.branch,
1743
- guardexEnabled: scan.guardexEnabled !== false,
1744
- guardexToggle: scan.guardexToggle || null,
1745
- errors: scan.errors,
1746
- warnings: scan.warnings,
1747
- findings: scan.findings,
1748
- },
1749
- null,
1750
- 2,
1751
- ) + '\n',
1752
- );
1753
- return;
1754
- }
1755
-
1756
- console.log(`[${TOOL_NAME}] Scan target: ${scan.repoRoot}`);
1757
- console.log(`[${TOOL_NAME}] Branch: ${scan.branch}`);
1758
-
1759
- if (scan.guardexEnabled === false) {
1760
- console.log(
1761
- colorizeDoctorOutput(
1762
- `[${TOOL_NAME}] Guardex is disabled for this repo (${describeGuardexRepoToggle(scan.guardexToggle)}).`,
1763
- 'disabled',
1764
- ),
1765
- );
1766
- return;
1767
- }
1768
-
1769
- if (scan.findings.length === 0) {
1770
- console.log(colorizeDoctorOutput(`[${TOOL_NAME}] ✅ No safety issues detected.`, 'safe'));
1771
- return;
1772
- }
1773
-
1774
- for (const item of scan.findings) {
1775
- const target = item.path ? ` (${item.path})` : '';
1776
- console.log(
1777
- colorizeDoctorOutput(
1778
- `[${item.level.toUpperCase()}] ${item.code}${target}: ${item.message}`,
1779
- item.level,
1780
- ),
1781
- );
1782
- }
1783
- console.log(
1784
- colorizeDoctorOutput(
1785
- `[${TOOL_NAME}] Summary: ${scan.errors} error(s), ${scan.warnings} warning(s).`,
1786
- scan.errors > 0 ? 'error' : 'warn',
1787
- ),
1788
- );
1789
- }
1790
-
1791
- function setExitCodeFromScan(scan) {
1792
- if (scan.guardexEnabled === false) {
1793
- process.exitCode = 0;
1794
- return;
1795
- }
1796
- if (scan.errors > 0) {
1797
- process.exitCode = 2;
1798
- return;
1799
- }
1800
- if (scan.warnings > 0) {
1801
- process.exitCode = 1;
1802
- return;
1803
- }
1804
- process.exitCode = 0;
1805
- }
1806
-
1807
- function printStatusRepairHint(scanResult) {
1808
- if (!scanResult || scanResult.guardexEnabled === false) {
1809
- return;
1810
- }
1811
- if (scanResult.errors === 0 && scanResult.warnings === 0) {
1812
- return;
1813
- }
1814
-
1815
- const scanHint = scanResult.errors === 0
1816
- ? `review warning details with '${SHORT_TOOL_NAME} scan'`
1817
- : `inspect detailed findings with '${SHORT_TOOL_NAME} scan'`;
1818
- console.log(
1819
- `[${TOOL_NAME}] Quick fix: run '${SHORT_TOOL_NAME} doctor' to repair drift, or ${scanHint}.`,
1820
- );
1821
- }
1822
-
1823
- function countAgentWorktrees(repoRoot) {
1824
- if (!repoRoot || typeof repoRoot !== 'string') return 0;
1825
- const relPaths = ['.omc/agent-worktrees', '.omx/agent-worktrees'];
1826
- let count = 0;
1827
- for (const rel of relPaths) {
1828
- try {
1829
- const entries = fs.readdirSync(path.join(repoRoot, rel), { withFileTypes: true });
1830
- count += entries.filter((entry) => entry.isDirectory()).length;
1831
- } catch (_err) {
1832
- // missing dir or permission error; not an active-agent signal
1833
- }
1834
- }
1835
- return count;
1836
- }
1837
-
1838
- function deriveNextStepHint({ scanResult, worktreeCount, invoked, inGitRepo }) {
1839
- if (!inGitRepo) {
1840
- return `${invoked} setup --target <path-to-git-repo> # initialize guardrails in a repo`;
1841
- }
1842
- if (!scanResult) {
1843
- return `${invoked} setup # bootstrap repo guardrails`;
1844
- }
1845
- if (scanResult.guardexEnabled === false) {
1846
- return `set GUARDEX_ON=1 in .env # re-enable guardrails, then '${invoked} doctor'`;
1847
- }
1848
- const branch = scanResult.branch || '';
1849
- if (branch.startsWith('agent/')) {
1850
- return `${invoked} branch finish --branch "${branch}" --via-pr --wait-for-merge --cleanup`;
1851
- }
1852
- if (worktreeCount > 0) {
1853
- const plural = worktreeCount === 1 ? 'worktree' : 'worktrees';
1854
- return `${invoked} finish --all # ${worktreeCount} active agent ${plural}`;
1855
- }
1856
- if (scanResult.errors > 0 || scanResult.warnings > 0) {
1857
- return `${invoked} doctor # repair drift`;
1858
- }
1859
- return `${invoked} branch start "<task>" "<agent-name>" # start a sandboxed agent task`;
1860
- }
1861
-
1862
- function collectServicesSnapshot() {
1863
- const toolchain = toolchainModule.detectGlobalToolchainPackages();
1864
- const npmServices = GLOBAL_TOOLCHAIN_PACKAGES.map((pkg) => {
1865
- const service = toolchainModule.getGlobalToolchainService(pkg);
1866
- if (!toolchain.ok) {
1867
- return {
1868
- name: service.name,
1869
- displayName: service.name,
1870
- packageName: pkg,
1871
- dependencyUrl: service.dependencyUrl || null,
1872
- status: 'unknown',
1873
- };
1874
- }
1875
- return {
1876
- name: service.name,
1877
- displayName: service.name,
1878
- packageName: pkg,
1879
- dependencyUrl: service.dependencyUrl || null,
1880
- status: toolchain.installed.includes(pkg) ? 'active' : 'inactive',
1881
- };
1882
- });
1883
- const localCompanionServices = toolchainModule.detectOptionalLocalCompanionTools().map((tool) => ({
1884
- name: tool.name,
1885
- displayName: tool.displayName || tool.name,
1886
- installCommand: tool.installCommand,
1887
- installArgs: Array.isArray(tool.installArgs) ? [...tool.installArgs] : [],
1888
- status: tool.status,
1889
- }));
1890
- const requiredSystemTools = toolchainModule.detectRequiredSystemTools();
1891
- const services = [
1892
- ...npmServices,
1893
- ...localCompanionServices.map((tool) => ({
1894
- name: tool.name,
1895
- displayName: tool.displayName,
1896
- status: tool.status,
1897
- })),
1898
- ...requiredSystemTools.map((tool) => ({
1899
- name: tool.name,
1900
- displayName: tool.displayName || tool.name,
1901
- command: tool.command,
1902
- status: tool.status,
1903
- })),
1904
- ];
1905
- return { toolchain, npmServices, localCompanionServices, requiredSystemTools, services };
1906
- }
1907
-
1908
- function maybePromptInstallMissingCompanions(snapshot) {
1909
- if (envFlagIsTruthy(process.env.GUARDEX_SKIP_COMPANION_PROMPT)) {
1910
- return { handled: false, installed: false };
1911
- }
1912
- const interactive = Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY);
1913
- const autoApproval = toolchainModule.parseAutoApproval('GUARDEX_AUTO_COMPANION_APPROVAL');
1914
- if (!interactive && autoApproval == null) {
1915
- return { handled: false, installed: false };
1916
- }
1917
- if (!snapshot.toolchain.ok) {
1918
- return { handled: false, installed: false };
1919
- }
1920
-
1921
- const missingPackages = snapshot.npmServices
1922
- .filter((service) => service.status !== 'active')
1923
- .map((service) => service.packageName);
1924
- const missingLocalTools = snapshot.localCompanionServices.filter((tool) => tool.status !== 'active');
1925
- if (missingPackages.length === 0 && missingLocalTools.length === 0) {
1926
- return { handled: false, installed: false };
1927
- }
1928
-
1929
- const missingNames = [
1930
- ...missingPackages.map((pkg) => toolchainModule.formatGlobalToolchainServiceName(pkg)),
1931
- ...missingLocalTools.map((tool) => tool.displayName || tool.name),
1932
- ];
1933
- console.log(`[${TOOL_NAME}] Missing companion tools: ${missingNames.join(', ')}.`);
1934
-
1935
- const promptText = toolchainModule.buildMissingCompanionInstallPrompt(missingPackages, missingLocalTools);
1936
- const approved = interactive
1937
- ? toolchainModule.promptYesNoStrict(promptText)
1938
- : autoApproval;
1939
-
1940
- if (!approved) {
1941
- console.log(
1942
- `[${TOOL_NAME}] Skipped companion install. Set GUARDEX_SKIP_COMPANION_PROMPT=1 to silence this prompt, ` +
1943
- `or run '${getInvokedCliName()} setup --install-only' later to install manually.`,
1944
- );
1945
- return { handled: true, installed: false };
1946
- }
1947
-
1948
- const result = toolchainModule.performCompanionInstall(missingPackages, missingLocalTools);
1949
- if (result.status === 'installed') {
1950
- console.log(
1951
- `[${TOOL_NAME}] ✅ Companion tools installed (${(result.packages || []).join(', ')}).`,
1952
- );
1953
- return { handled: true, installed: true };
1954
- }
1955
- if (result.status === 'failed') {
1956
- console.log(
1957
- `[${TOOL_NAME}] ⚠️ Companion install failed: ${result.reason}. ` +
1958
- `Retry with '${getInvokedCliName()} setup --install-only'.`,
1959
- );
1960
- return { handled: true, installed: false };
1961
- }
1962
- return { handled: true, installed: false };
1963
- }
1964
-
1965
- function status(rawArgs) {
1966
- const { found: verboseFlag, remaining: afterVerbose } = extractFlag(rawArgs, '--verbose');
1967
- const options = parseCommonArgs(afterVerbose, {
1968
- target: process.cwd(),
1969
- json: false,
1970
- });
1971
- const forceExpand = envFlagIsTruthy(process.env.GUARDEX_VERBOSE_STATUS) || verboseFlag;
1972
- const invokedBasename = getInvokedCliName();
1973
-
1974
- let snapshot = collectServicesSnapshot();
1975
- if (!options.json) {
1976
- const result = maybePromptInstallMissingCompanions(snapshot);
1977
- if (result.installed) {
1978
- snapshot = collectServicesSnapshot();
1979
- }
1980
- }
1981
- let { toolchain, npmServices, localCompanionServices, requiredSystemTools, services } = snapshot;
1982
-
1983
- const targetPath = path.resolve(options.target);
1984
- const inGitRepo = isGitRepo(targetPath);
1985
- const scanResult = inGitRepo ? runScanInternal({ target: targetPath, json: false }) : null;
1986
- const repoServiceStatus = scanResult
1987
- ? (scanResult.guardexEnabled === false
1988
- ? 'disabled'
1989
- : (scanResult.errors === 0 && scanResult.warnings === 0 ? 'active' : 'degraded'))
1990
- : 'inactive';
1991
-
1992
- const payload = {
1993
- cli: {
1994
- name: packageJson.name,
1995
- version: packageJson.version,
1996
- runtime: runtimeVersion(),
1997
- },
1998
- services,
1999
- repo: {
2000
- target: targetPath,
2001
- inGitRepo,
2002
- serviceStatus: repoServiceStatus,
2003
- guardexEnabled: scanResult ? scanResult.guardexEnabled !== false : null,
2004
- guardexToggle: scanResult ? scanResult.guardexToggle || null : null,
2005
- scan: scanResult
2006
- ? {
2007
- repoRoot: scanResult.repoRoot,
2008
- branch: scanResult.branch,
2009
- errors: scanResult.errors,
2010
- warnings: scanResult.warnings,
2011
- findings: scanResult.findings.length,
2012
- }
2013
- : null,
2014
- },
2015
- detectionError: toolchain.ok ? null : toolchain.error,
2016
- };
2017
-
2018
- if (options.json) {
2019
- process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
2020
- process.exitCode = 0;
2021
- return payload;
2022
- }
2023
-
2024
- const compact = !forceExpand;
2025
- const activeServiceCount = services.filter((service) => service.status === 'active').length;
2026
- const inactiveServiceCount = services.length - activeServiceCount;
2027
-
2028
- console.log(`[${TOOL_NAME}] CLI: ${payload.cli.runtime}`);
2029
- if (!toolchain.ok) {
2030
- const detectionError = compact
2031
- ? String(toolchain.error || '').split(/\r?\n/).find(Boolean) || 'unknown error'
2032
- : toolchain.error;
2033
- console.log(`[${TOOL_NAME}] ⚠️ Could not detect global services: ${detectionError}`);
2034
- }
2035
-
2036
- if (compact) {
2037
- const serviceSummary = inactiveServiceCount === 0
2038
- ? `${activeServiceCount}/${services.length} ${statusDot('active')} active`
2039
- : `${activeServiceCount}/${services.length} ${statusDot('degraded')} active (${inactiveServiceCount} inactive)`;
2040
- console.log(
2041
- `[${TOOL_NAME}] Global services: ${serviceSummary}`,
2042
- );
2043
- } else {
2044
- console.log(`[${TOOL_NAME}] Global services:`);
2045
- for (const service of services) {
2046
- const serviceLabel = service.displayName || service.name;
2047
- console.log(` - ${statusDot(service.status)} ${serviceLabel}: ${service.status}`);
2048
- }
2049
- }
2050
- const inactiveOptionalCompanions = [...npmServices, ...localCompanionServices]
2051
- .filter((service) => service.status !== 'active')
2052
- .map((service) => service.displayName || service.name);
2053
- if (inactiveOptionalCompanions.length > 0) {
2054
- if (compact) {
2055
- console.log(
2056
- `[${TOOL_NAME}] Optional companion tools inactive: ${inactiveOptionalCompanions.length} (run '${SHORT_TOOL_NAME} setup')`,
2057
- );
2058
- } else {
2059
- console.log(
2060
- `[${TOOL_NAME}] Optional companion tools inactive: ${inactiveOptionalCompanions.join(', ')}`,
2061
- );
2062
- for (const warning of toolchainModule.describeMissingGlobalDependencyWarnings(
2063
- npmServices
2064
- .filter((service) => service.status === 'inactive')
2065
- .map((service) => service.packageName),
2066
- )) {
2067
- console.log(`[${TOOL_NAME}] ${warning}`);
2068
- }
2069
- console.log(
2070
- `[${TOOL_NAME}] Run '${SHORT_TOOL_NAME} setup' to install missing companions with an explicit Y/N prompt.`,
2071
- );
2072
- }
2073
- }
2074
- const missingSystemTools = requiredSystemTools.filter((tool) => tool.status !== 'active');
2075
- if (missingSystemTools.length > 0) {
2076
- const tools = missingSystemTools
2077
- .map((tool) => tool.displayName || tool.name)
2078
- .join(', ');
2079
- console.log(`[${TOOL_NAME}] ⚠️ Missing required system tool(s): ${tools}`);
2080
- if (!compact) {
2081
- for (const tool of missingSystemTools) {
2082
- const reasonText = tool.reason ? ` (${tool.reason})` : '';
2083
- console.log(` - install ${tool.name}: ${tool.installHint}${reasonText}`);
2084
- }
2085
- }
2086
- }
2087
-
2088
- if (!scanResult) {
2089
- console.log(
2090
- `[${TOOL_NAME}] Repo safety service: ${statusDot('inactive')} inactive (no git repository at target).`,
2091
- );
2092
- const inactiveHint = deriveNextStepHint({
2093
- scanResult: null,
2094
- worktreeCount: 0,
2095
- invoked: invokedBasename,
2096
- inGitRepo,
2097
- });
2098
- console.log(`[${TOOL_NAME}] Next: ${inactiveHint}`);
2099
- printToolLogsSummary({ invokedBasename, compact });
2100
- process.exitCode = 0;
2101
- return payload;
2102
- }
2103
-
2104
- if (scanResult.guardexEnabled === false) {
2105
- console.log(
2106
- `[${TOOL_NAME}] Repo safety service: ${statusDot('disabled')} disabled (${describeGuardexRepoToggle(scanResult.guardexToggle)}).`,
2107
- );
2108
- console.log(`[${TOOL_NAME}] Repo: ${scanResult.repoRoot}`);
2109
- console.log(`[${TOOL_NAME}] Branch: ${scanResult.branch}`);
2110
- const worktreeCountDisabled = countAgentWorktrees(scanResult.repoRoot);
2111
- if (worktreeCountDisabled > 0) {
2112
- const plural = worktreeCountDisabled === 1 ? 'worktree' : 'worktrees';
2113
- console.log(
2114
- `[${TOOL_NAME}] ⚠ ${worktreeCountDisabled} active agent ${plural} under .omc/agent-worktrees or .omx/agent-worktrees.`,
2115
- );
2116
- }
2117
- const disabledHint = deriveNextStepHint({
2118
- scanResult,
2119
- worktreeCount: worktreeCountDisabled,
2120
- invoked: invokedBasename,
2121
- inGitRepo,
2122
- });
2123
- console.log(`[${TOOL_NAME}] Next: ${disabledHint}`);
2124
- printToolLogsSummary({ invokedBasename, compact });
2125
- process.exitCode = 0;
2126
- return payload;
2127
- }
2128
-
2129
- if (scanResult.errors === 0 && scanResult.warnings === 0) {
2130
- console.log(`[${TOOL_NAME}] Repo safety service: ${statusDot('active')} active.`);
2131
- } else if (scanResult.errors === 0) {
2132
- console.log(
2133
- `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.warnings} warning(s)).`,
2134
- );
2135
- } else if (scanResult.warnings === 0) {
2136
- console.log(
2137
- `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.errors} error(s)).`,
2138
- );
2139
- } else {
2140
- console.log(
2141
- `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.errors} error(s), ${scanResult.warnings} warning(s)).`,
2142
- );
2143
- }
2144
- printStatusRepairHint(scanResult);
2145
- console.log(`[${TOOL_NAME}] Repo: ${scanResult.repoRoot}`);
2146
- console.log(`[${TOOL_NAME}] Branch: ${scanResult.branch}`);
2147
- const worktreeCountActive = countAgentWorktrees(scanResult.repoRoot);
2148
- if (worktreeCountActive > 0) {
2149
- const plural = worktreeCountActive === 1 ? 'worktree' : 'worktrees';
2150
- console.log(
2151
- `[${TOOL_NAME}] ⚠ ${worktreeCountActive} active agent ${plural} → ${invokedBasename} finish --all`,
2152
- );
2153
- }
2154
- const activeHint = deriveNextStepHint({
2155
- scanResult,
2156
- worktreeCount: worktreeCountActive,
2157
- invoked: invokedBasename,
2158
- inGitRepo,
2159
- });
2160
- console.log(`[${TOOL_NAME}] Next: ${activeHint}`);
2161
- printToolLogsSummary({ invokedBasename, compact });
2162
-
2163
- process.exitCode = 0;
2164
- return payload;
2165
- }
2166
-
2167
- function install(rawArgs) {
2168
- const options = parseCommonArgs(rawArgs, {
2169
- target: process.cwd(),
2170
- force: false,
2171
- skipAgents: false,
2172
- skipPackageJson: false,
2173
- skipGitignore: false,
2174
- dryRun: false,
2175
- allowProtectedBaseWrite: false,
2176
- });
2177
-
2178
- assertProtectedMainWriteAllowed(options, 'install');
2179
- const payload = runInstallInternal(options);
2180
- printOperations('Install target', payload, options.dryRun);
2181
-
2182
- if (!options.dryRun) {
2183
- if (payload.guardexEnabled === false) {
2184
- console.log(
2185
- `[${TOOL_NAME}] Guardex is disabled for this repo (${describeGuardexRepoToggle(payload.guardexToggle)}). Skipping repo bootstrap.`,
2186
- );
2187
- process.exitCode = 0;
2188
- return;
2189
- }
2190
- if (!options.skipAgents) {
2191
- console.log(`[${TOOL_NAME}] AGENTS.md managed policy block is configured by install.`);
2192
- }
2193
- console.log(`[${TOOL_NAME}] Installed. Next step: ${TOOL_NAME} setup`);
2194
- }
2195
-
2196
- process.exitCode = 0;
2197
- }
2198
-
2199
- function fix(rawArgs) {
2200
- const options = parseCommonArgs(rawArgs, {
2201
- target: process.cwd(),
2202
- dropStaleLocks: true,
2203
- skipAgents: false,
2204
- skipPackageJson: false,
2205
- skipGitignore: false,
2206
- dryRun: false,
2207
- allowProtectedBaseWrite: false,
2208
- });
2209
-
2210
- assertProtectedMainWriteAllowed(options, 'fix');
2211
- const payload = runFixInternal(options);
2212
- printOperations('Fix target', payload, options.dryRun);
2213
-
2214
- if (!options.dryRun) {
2215
- if (payload.guardexEnabled === false) {
2216
- console.log(
2217
- `[${TOOL_NAME}] Guardex is disabled for this repo (${describeGuardexRepoToggle(payload.guardexToggle)}). Skipping repo repair.`,
2218
- );
2219
- process.exitCode = 0;
2220
- return;
2221
- }
2222
- console.log(`[${TOOL_NAME}] Repair complete. Next step: ${TOOL_NAME} scan`);
2223
- }
2224
-
2225
- process.exitCode = 0;
2226
- }
2227
-
2228
- function scan(rawArgs) {
2229
- const options = parseCommonArgs(rawArgs, {
2230
- target: process.cwd(),
2231
- json: false,
2232
- });
2233
-
2234
- const result = runScanInternal(options);
2235
- printScanResult(result, options.json);
2236
- setExitCodeFromScan(result);
2237
- }
2238
-
2239
- function doctor(rawArgs) {
2240
- const options = parseDoctorArgs(rawArgs);
2241
- const topRepoRoot = resolveRepoRoot(options.target);
2242
- const discoveredRepos = options.recursive
2243
- ? discoverNestedGitRepos(topRepoRoot, {
2244
- maxDepth: options.nestedMaxDepth,
2245
- extraSkip: options.nestedSkipDirs,
2246
- includeSubmodules: options.includeSubmodules,
2247
- skipRelativeDirs: AGENT_WORKTREE_RELATIVE_DIRS,
2248
- })
2249
- : [topRepoRoot];
2250
-
2251
- if (discoveredRepos.length > 1) {
2252
- if (!options.json) {
2253
- console.log(
2254
- `[${TOOL_NAME}] Detected ${discoveredRepos.length} git repos under ${topRepoRoot}. ` +
2255
- `Repairing each with doctor (use --single-repo or --current to limit to the target).`,
2256
- );
2257
- }
2258
-
2259
- const repoResults = [];
2260
- let aggregateExitCode = 0;
2261
- for (let repoIndex = 0; repoIndex < discoveredRepos.length; repoIndex += 1) {
2262
- const repoPath = discoveredRepos[repoIndex];
2263
- const progressLabel = `${repoIndex + 1}/${discoveredRepos.length}`;
2264
- if (!options.json) {
2265
- console.log(`[${TOOL_NAME}] ── Doctor target: ${repoPath} [${progressLabel}] ──`);
2266
- }
2267
-
2268
- const childArgs = [
2269
- path.resolve(__filename),
2270
- 'doctor',
2271
- '--single-repo',
2272
- '--target',
2273
- repoPath,
2274
- ...(options.force ? ['--force', ...(options.forceManagedPaths || [])] : []),
2275
- ...(options.dropStaleLocks ? [] : ['--keep-stale-locks']),
2276
- ...(options.skipAgents ? ['--skip-agents'] : []),
2277
- ...(options.skipPackageJson ? ['--skip-package-json'] : []),
2278
- ...(options.skipGitignore ? ['--no-gitignore'] : []),
2279
- ...(options.dryRun ? ['--dry-run'] : []),
2280
- // Recursive child doctor runs should report pending PR state immediately instead of blocking the parent loop.
2281
- '--no-wait-for-merge',
2282
- ...(options.verboseAutoFinish ? ['--verbose-auto-finish'] : []),
2283
- ...(options.json ? ['--json'] : []),
2284
- ...(options.allowProtectedBaseWrite ? ['--allow-protected-base-write'] : []),
2285
- ];
2286
- const startedAt = Date.now();
2287
- const nestedResult = options.json
2288
- ? run(process.execPath, childArgs, { cwd: topRepoRoot })
2289
- : cp.spawnSync(process.execPath, childArgs, {
2290
- cwd: topRepoRoot,
2291
- encoding: 'utf8',
2292
- stdio: 'inherit',
2293
- });
2294
- if (isSpawnFailure(nestedResult)) {
2295
- throw nestedResult.error;
2296
- }
2297
-
2298
- const exitCode = typeof nestedResult.status === 'number' ? nestedResult.status : 1;
2299
- if (exitCode !== 0 && aggregateExitCode === 0) {
2300
- aggregateExitCode = exitCode;
2301
- }
2302
-
2303
- if (options.json) {
2304
- let parsedResult = null;
2305
- if (nestedResult.stdout) {
2306
- try {
2307
- parsedResult = JSON.parse(nestedResult.stdout);
2308
- } catch {
2309
- parsedResult = null;
2310
- }
2311
- }
2312
- repoResults.push(
2313
- parsedResult
2314
- ? { repoRoot: repoPath, exitCode, result: parsedResult }
2315
- : {
2316
- repoRoot: repoPath,
2317
- exitCode,
2318
- stdout: nestedResult.stdout || '',
2319
- stderr: nestedResult.stderr || '',
2320
- },
2321
- );
2322
- } else {
2323
- console.log(
2324
- `[${TOOL_NAME}] Doctor target complete: ${repoPath} [${progressLabel}] in ${formatElapsedDuration(Date.now() - startedAt)}.`,
2325
- );
2326
- if (repoIndex < discoveredRepos.length - 1) {
2327
- process.stdout.write('\n');
2328
- }
2329
- }
2330
- }
2331
-
2332
- if (options.json) {
2333
- process.stdout.write(
2334
- JSON.stringify(
2335
- {
2336
- repoRoot: topRepoRoot,
2337
- recursive: true,
2338
- repos: repoResults,
2339
- },
2340
- null,
2341
- 2,
2342
- ) + '\n',
2343
- );
2344
- }
2345
-
2346
- process.exitCode = aggregateExitCode;
2347
- return;
2348
- }
2349
-
2350
- const singleRepoOptions = {
2351
- ...options,
2352
- target: topRepoRoot,
2353
- };
2354
-
2355
- if (!singleRepoOptions.json) {
2356
- printRequiredSystemToolStatus();
2357
- }
2358
-
2359
- const blocked = protectedBaseWriteBlock(singleRepoOptions, { requireBootstrap: false });
2360
- if (blocked) {
2361
- doctorModule.runDoctorInSandbox(singleRepoOptions, blocked, {
2362
- startProtectedBaseSandbox,
2363
- cleanupProtectedBaseSandbox,
2364
- ensureOmxScaffold,
2365
- configureHooks,
2366
- autoFinishReadyAgentBranches: doctorModule.autoFinishReadyAgentBranches,
2367
- });
2368
- const primaryBaseBranch = currentBranchName(blocked.repoRoot);
2369
- const prunePayload = doctorModule.pruneStaleAgentWorktrees(blocked.repoRoot, {
2370
- baseBranch: primaryBaseBranch,
2371
- dryRun: singleRepoOptions.dryRun,
2372
- });
2373
- printWorktreePruneSummary(prunePayload, { baseBranch: primaryBaseBranch });
2374
- return;
2375
- }
2376
-
2377
- assertProtectedMainWriteAllowed(singleRepoOptions, 'doctor');
2378
- const fixPayload = runFixInternal(singleRepoOptions);
2379
- const scanResult = runScanInternal({ target: singleRepoOptions.target, json: false });
2380
- const currentBaseBranch = currentBranchName(scanResult.repoRoot);
2381
- const autoFinishSummary = scanResult.guardexEnabled === false
2382
- ? {
2383
- enabled: false,
2384
- attempted: 0,
2385
- completed: 0,
2386
- skipped: 0,
2387
- failed: 0,
2388
- details: [],
2389
- }
2390
- : doctorModule.autoFinishReadyAgentBranches(scanResult.repoRoot, {
2391
- baseBranch: currentBaseBranch,
2392
- dryRun: singleRepoOptions.dryRun,
2393
- waitForMerge: singleRepoOptions.waitForMerge,
2394
- });
2395
- const prunePayload = scanResult.guardexEnabled === false
2396
- ? { enabled: false, ran: false, status: 'skipped', details: ['Guardex disabled for this repo.'] }
2397
- : doctorModule.pruneStaleAgentWorktrees(scanResult.repoRoot, {
2398
- baseBranch: currentBaseBranch,
2399
- dryRun: singleRepoOptions.dryRun,
2400
- });
2401
- const safe = scanResult.guardexEnabled === false || (scanResult.errors === 0 && scanResult.warnings === 0);
2402
- const musafe = safe;
2403
-
2404
- if (singleRepoOptions.json) {
2405
- process.stdout.write(
2406
- JSON.stringify(
2407
- {
2408
- repoRoot: scanResult.repoRoot,
2409
- branch: scanResult.branch,
2410
- safe,
2411
- musafe,
2412
- fix: {
2413
- operations: fixPayload.operations,
2414
- hookResult: fixPayload.hookResult,
2415
- dryRun: Boolean(singleRepoOptions.dryRun),
2416
- },
2417
- scan: {
2418
- guardexEnabled: scanResult.guardexEnabled !== false,
2419
- guardexToggle: scanResult.guardexToggle || null,
2420
- errors: scanResult.errors,
2421
- warnings: scanResult.warnings,
2422
- findings: scanResult.findings,
2423
- },
2424
- autoFinish: autoFinishSummary,
2425
- worktreePrune: prunePayload,
2426
- },
2427
- null,
2428
- 2,
2429
- ) + '\n',
2430
- );
2431
- setExitCodeFromScan(scanResult);
2432
- return;
2433
- }
2434
-
2435
- printOperations('Doctor/fix', fixPayload, options.dryRun);
2436
- printScanResult(scanResult, false);
2437
- if (scanResult.guardexEnabled === false) {
2438
- console.log(`[${TOOL_NAME}] Repo-local Guardex enforcement is intentionally disabled.`);
2439
- setExitCodeFromScan(scanResult);
2440
- return;
2441
- }
2442
- printAutoFinishSummary(autoFinishSummary, {
2443
- baseBranch: currentBaseBranch,
2444
- verbose: singleRepoOptions.verboseAutoFinish,
2445
- });
2446
- printWorktreePruneSummary(prunePayload, { baseBranch: currentBaseBranch });
2447
- if (safe) {
2448
- console.log(colorizeDoctorOutput(`[${TOOL_NAME}] ✅ Repo is fully safe.`, 'safe'));
2449
- } else {
2450
- console.log(
2451
- colorizeDoctorOutput(
2452
- `[${TOOL_NAME}] ⚠️ Repo is not fully safe yet (${scanResult.errors} error(s), ${scanResult.warnings} warning(s)).`,
2453
- scanResult.errors > 0 ? 'unsafe' : 'warn',
2454
- ),
2455
- );
2456
- }
2457
- setExitCodeFromScan(scanResult);
2458
- }
2459
-
2460
- function review(rawArgs) {
2461
- const options = parseReviewArgs(rawArgs);
2462
- const repoRoot = resolveRepoRoot(options.target);
2463
- const result = runReviewBotCommand(repoRoot, options.passthroughArgs);
2464
- if (isSpawnFailure(result)) {
2465
- throw result.error;
2466
- }
2467
-
2468
- if (result.stdout) process.stdout.write(result.stdout);
2469
- if (result.stderr) process.stderr.write(result.stderr);
2470
- process.exitCode = typeof result.status === 'number' ? result.status : 1;
2471
- }
2472
-
2473
- function prReview(rawArgs) {
2474
- const options = parsePrReviewArgs(rawArgs);
2475
- const result = prReviewModule.runPrReview(options);
2476
- prReviewModule.printPrReviewResult(result);
2477
- process.exitCode = 0;
2478
- }
2479
-
2480
- function agentsStatePathForRepo(repoRoot) {
2481
- return path.join(repoRoot, AGENTS_BOTS_STATE_RELATIVE);
2482
- }
2483
-
2484
- function readAgentsState(repoRoot) {
2485
- const statePath = agentsStatePathForRepo(repoRoot);
2486
- if (!fs.existsSync(statePath)) {
2487
- return null;
2488
- }
2489
- try {
2490
- return JSON.parse(fs.readFileSync(statePath, 'utf8'));
2491
- } catch (_error) {
2492
- return null;
2493
- }
2494
- }
2495
-
2496
- function writeAgentsState(repoRoot, state) {
2497
- const statePath = agentsStatePathForRepo(repoRoot);
2498
- fs.mkdirSync(path.dirname(statePath), { recursive: true });
2499
- fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
2500
- }
2501
-
2502
- function processAlive(pid) {
2503
- const normalizedPid = Number.parseInt(String(pid || ''), 10);
2504
- if (!Number.isInteger(normalizedPid) || normalizedPid <= 0) {
2505
- return false;
2506
- }
2507
- try {
2508
- process.kill(normalizedPid, 0);
2509
- } catch (_error) {
2510
- return false;
2511
- }
2512
-
2513
- const state = readProcessState(normalizedPid);
2514
- if (state.startsWith('Z')) {
2515
- return false;
2516
- }
2517
- return true;
2518
- }
2519
-
2520
- function sleepSeconds(seconds) {
2521
- const result = run('sleep', [String(seconds)]);
2522
- if (isSpawnFailure(result) || result.status !== 0) {
2523
- throw new Error(`sleep command failed for ${seconds}s`);
2524
- }
2525
- }
2526
-
2527
- function readProcessCommand(pid) {
2528
- const result = run('ps', ['-o', 'command=', '-p', String(pid)]);
2529
- if (isSpawnFailure(result) || result.status !== 0) {
2530
- return '';
2531
- }
2532
- return String(result.stdout || '').trim();
2533
- }
2534
-
2535
- function readProcessState(pid) {
2536
- const result = run('ps', ['-o', 'stat=', '-p', String(pid)]);
2537
- if (isSpawnFailure(result) || result.status !== 0) {
2538
- return '';
2539
- }
2540
- return String(result.stdout || '').trim();
2541
- }
2542
-
2543
- function stopAgentProcessByPid(pid, expectedToken = '') {
2544
- const normalizedPid = Number.parseInt(String(pid || ''), 10);
2545
- if (!Number.isInteger(normalizedPid) || normalizedPid <= 0) {
2546
- return { status: 'invalid', pid: normalizedPid };
2547
- }
2548
- if (!processAlive(normalizedPid)) {
2549
- return { status: 'not-running', pid: normalizedPid };
2550
- }
2551
-
2552
- if (expectedToken) {
2553
- const cmdline = readProcessCommand(normalizedPid);
2554
- if (cmdline && !cmdline.includes(expectedToken)) {
2555
- return { status: 'mismatch', pid: normalizedPid, command: cmdline };
2556
- }
2557
- }
2558
-
2559
- try {
2560
- process.kill(-normalizedPid, 'SIGTERM');
2561
- } catch (_error) {
2562
- try {
2563
- process.kill(normalizedPid, 'SIGTERM');
2564
- } catch (_err) {
2565
- return { status: 'term-failed', pid: normalizedPid };
2566
- }
2567
- }
2568
-
2569
- const deadline = Date.now() + 3_000;
2570
- while (Date.now() < deadline) {
2571
- if (!processAlive(normalizedPid)) {
2572
- return { status: 'stopped', pid: normalizedPid };
2573
- }
2574
- sleepSeconds(0.1);
2575
- }
2576
-
2577
- try {
2578
- process.kill(-normalizedPid, 'SIGKILL');
2579
- } catch (_error) {
2580
- try {
2581
- process.kill(normalizedPid, 'SIGKILL');
2582
- } catch (_err) {
2583
- return { status: 'kill-failed', pid: normalizedPid };
2584
- }
2585
- }
2586
- sleepSeconds(0.1);
2587
-
2588
- return {
2589
- status: processAlive(normalizedPid) ? 'kill-failed' : 'stopped',
2590
- pid: normalizedPid,
2591
- };
2592
- }
2593
-
2594
- function spawnDetachedAgentProcess({ command, args, cwd, logPath }) {
2595
- fs.mkdirSync(path.dirname(logPath), { recursive: true });
2596
- const logHandle = fs.openSync(logPath, 'a');
2597
- fs.writeSync(
2598
- logHandle,
2599
- `[${new Date().toISOString()}] spawn: ${command} ${args.join(' ')}\n`,
2600
- );
2601
- const child = cp.spawn(command, args, {
2602
- cwd,
2603
- detached: true,
2604
- stdio: ['ignore', logHandle, logHandle],
2605
- env: process.env,
2606
- });
2607
- fs.closeSync(logHandle);
2608
- if (child.error) {
2609
- throw child.error;
2610
- }
2611
- child.unref();
2612
- const pid = Number.parseInt(String(child.pid || ''), 10);
2613
- if (!Number.isInteger(pid) || pid <= 0) {
2614
- throw new Error(`Failed to spawn detached process for ${command}`);
2615
- }
2616
- return pid;
2617
- }
2618
-
2619
- function agents(rawArgs) {
2620
- const options = parseAgentsArgs(rawArgs);
2621
- if (['files', 'diff', 'locks'].includes(options.subcommand)) {
2622
- process.stdout.write(agentInspect.runInspectCommand(options));
2623
- process.exitCode = 0;
2624
- return;
2625
- }
2626
-
2627
- const repoRoot = resolveRepoRoot(options.target);
2628
- const statePath = agentsStatePathForRepo(repoRoot);
2629
-
2630
- if (options.subcommand === 'finish') {
2631
- const result = finishAgentSession(repoRoot, options);
2632
- if (options.json) {
2633
- process.stdout.write(`${JSON.stringify(result.evidence, null, 2)}\n`);
2634
- }
2635
- process.exitCode = 0;
2636
- return;
2637
- }
2638
-
2639
- if (options.subcommand === 'cleanup-sessions') {
2640
- process.stdout.write(agentCleanupSessions.runCleanupSessionsCommand(repoRoot, options));
2641
- process.exitCode = 0;
2642
- return;
2643
- }
2644
-
2645
- if (options.subcommand === 'start') {
2646
- if (agentsStart.shouldUseInteractivePanel(options, process.stdin, process.stdout)) {
2647
- agentsStart.startInteractiveAgentPanel(repoRoot, options, {
2648
- onDone(result) {
2649
- process.exitCode = result.status;
2650
- },
2651
- });
2652
- return;
2653
- }
2654
- if (options.dryRun) {
2655
- const output = agentsStart.dryRunStart(options, repoRoot);
2656
- process.stdout.write(output.endsWith('\n') ? output : `${output}\n`);
2657
- process.exitCode = 0;
2658
- return;
2659
- }
2660
- if (options.panel && !options.task) {
2661
- process.stderr.write('[gitguardex] gx agents start --panel requires an interactive terminal when no task is provided.\n');
2662
- process.exitCode = 1;
2663
- return;
2664
- }
2665
- if (options.task) {
2666
- const result = agentsStart.startAgentLane(repoRoot, options);
2667
- if (result.stdout) process.stdout.write(result.stdout);
2668
- if (result.stderr) process.stderr.write(result.stderr);
2669
- process.exitCode = result.status;
2670
- return;
2671
- }
2672
-
2673
- const existingState = readAgentsState(repoRoot);
2674
- const existingReviewPid = Number.parseInt(String(existingState?.review?.pid || ''), 10);
2675
- const existingCleanupPid = Number.parseInt(String(existingState?.cleanup?.pid || ''), 10);
2676
- const reviewRunning = processAlive(existingReviewPid);
2677
- const cleanupRunning = processAlive(existingCleanupPid);
2678
-
2679
- if (reviewRunning && cleanupRunning) {
2680
- console.log(
2681
- `[${TOOL_NAME}] Repo agents already running (review pid=${existingReviewPid}, cleanup pid=${existingCleanupPid}).`,
2682
- );
2683
- process.exitCode = 0;
2684
- return;
2685
- }
2686
-
2687
- const reviewLogPath = path.join(repoRoot, '.omx', 'logs', 'agent-review.log');
2688
- const cleanupLogPath = path.join(repoRoot, '.omx', 'logs', 'agent-cleanup.log');
2689
-
2690
- let reviewPid = existingReviewPid;
2691
- let cleanupPid = existingCleanupPid;
2692
- let startedAny = false;
2693
- let reusedAny = false;
2694
-
2695
- if (!reviewRunning) {
2696
- reviewPid = spawnDetachedAgentProcess({
2697
- command: process.execPath,
2698
- args: [
2699
- path.resolve(__filename),
2700
- 'internal',
2701
- 'run-shell',
2702
- 'reviewBot',
2703
- '--target',
2704
- repoRoot,
2705
- '--interval',
2706
- String(options.reviewIntervalSeconds),
2707
- ],
2708
- cwd: repoRoot,
2709
- logPath: reviewLogPath,
2710
- });
2711
- startedAny = true;
2712
- } else {
2713
- reusedAny = true;
2714
- }
2715
-
2716
- if (!cleanupRunning) {
2717
- cleanupPid = spawnDetachedAgentProcess({
2718
- command: process.execPath,
2719
- args: [
2720
- path.resolve(__filename),
2721
- 'cleanup',
2722
- '--target',
2723
- repoRoot,
2724
- '--watch',
2725
- '--interval',
2726
- String(options.cleanupIntervalSeconds),
2727
- '--idle-minutes',
2728
- String(options.idleMinutes),
2729
- ],
2730
- cwd: repoRoot,
2731
- logPath: cleanupLogPath,
2732
- });
2733
- startedAny = true;
2734
- } else {
2735
- reusedAny = true;
2736
- }
2737
-
2738
- const priorReviewInterval = Number.parseInt(String(existingState?.review?.intervalSeconds || ''), 10);
2739
- const priorCleanupInterval = Number.parseInt(String(existingState?.cleanup?.intervalSeconds || ''), 10);
2740
- const priorIdleMinutes = Number.parseInt(String(existingState?.cleanup?.idleMinutes || ''), 10);
2741
- const reviewIntervalSeconds = reviewRunning && Number.isInteger(priorReviewInterval) && priorReviewInterval >= 5
2742
- ? priorReviewInterval
2743
- : options.reviewIntervalSeconds;
2744
- const cleanupIntervalSeconds = cleanupRunning && Number.isInteger(priorCleanupInterval) && priorCleanupInterval >= 5
2745
- ? priorCleanupInterval
2746
- : options.cleanupIntervalSeconds;
2747
- const idleMinutes = cleanupRunning && Number.isInteger(priorIdleMinutes) && priorIdleMinutes >= 1
2748
- ? priorIdleMinutes
2749
- : options.idleMinutes;
2750
-
2751
- writeAgentsState(repoRoot, {
2752
- schemaVersion: 1,
2753
- repoRoot,
2754
- startedAt: new Date().toISOString(),
2755
- review: {
2756
- pid: reviewPid,
2757
- intervalSeconds: reviewIntervalSeconds,
2758
- script: path.resolve(__filename),
2759
- logPath: reviewLogPath,
2760
- },
2761
- cleanup: {
2762
- pid: cleanupPid,
2763
- intervalSeconds: cleanupIntervalSeconds,
2764
- idleMinutes,
2765
- script: path.resolve(__filename),
2766
- logPath: cleanupLogPath,
2767
- },
2768
- });
2769
-
2770
- console.log(
2771
- `[${TOOL_NAME}] Started repo agents in ${repoRoot} (review pid=${reviewPid}, cleanup pid=${cleanupPid}).`,
2772
- );
2773
- if (reusedAny && startedAny) {
2774
- console.log(`[${TOOL_NAME}] Reused healthy bot process(es) and started only missing ones.`);
2775
- }
2776
- console.log(`[${TOOL_NAME}] Logs: ${reviewLogPath}, ${cleanupLogPath}`);
2777
- process.exitCode = 0;
2778
- return;
2779
- }
2780
-
2781
- if (options.subcommand === 'stop') {
2782
- if (options.pid) {
2783
- const stopResult = stopAgentProcessByPid(options.pid);
2784
- const success = ['stopped', 'not-running'].includes(stopResult.status);
2785
- console.log(
2786
- `[${TOOL_NAME}] Stopped agent pid ${options.pid} (${stopResult.status}).`,
2787
- );
2788
- process.exitCode = success ? 0 : 1;
2789
- return;
2790
- }
2791
-
2792
- const existingState = readAgentsState(repoRoot);
2793
- if (!existingState) {
2794
- console.log(`[${TOOL_NAME}] Repo agents are not running for ${repoRoot}.`);
2795
- process.exitCode = 0;
2796
- return;
2797
- }
2798
-
2799
- const reviewStop = stopAgentProcessByPid(existingState?.review?.pid, 'internal run-shell reviewBot');
2800
- const cleanupStop = stopAgentProcessByPid(existingState?.cleanup?.pid, `${path.basename(__filename)} cleanup`);
2801
-
2802
- if (fs.existsSync(statePath)) {
2803
- fs.unlinkSync(statePath);
2804
- }
2805
-
2806
- console.log(
2807
- `[${TOOL_NAME}] Stopped repo agents in ${repoRoot} (review=${reviewStop.status}, cleanup=${cleanupStop.status}).`,
2808
- );
2809
- process.exitCode = 0;
2810
- return;
2811
- }
2812
-
2813
- process.stdout.write(agentStatus.runStatusCommand(repoRoot, options));
2814
- process.exitCode = 0;
2815
- }
2816
-
2817
- function report(rawArgs) {
2818
- const options = parseReportArgs(rawArgs);
2819
- const subcommand = options.subcommand || 'help';
2820
- if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
2821
- const sessionSeverityHelpDetails = sessionSeverityReport.renderSessionSeverityHelpDetails()
2822
- .split('\n')
2823
- .map((line) => ` ${line}`)
2824
- .join('\n');
2825
- console.log(
2826
- `${TOOL_NAME} report commands:\n` +
2827
- ` ${TOOL_NAME} report scorecard [--target <path>] [--repo github.com/<owner>/<repo>] [--scorecard-json <file>] [--output-dir <path>] [--date YYYY-MM-DD] [--dry-run] [--json]\n` +
2828
- ` ${sessionSeverityReport.renderSessionSeverityCommand(TOOL_NAME)}\n` +
2829
- `${sessionSeverityHelpDetails}\n` +
2830
- `\n` +
2831
- `Examples:\n` +
2832
- ` ${TOOL_NAME} report scorecard --repo github.com/recodeecom/multiagent-safety\n` +
2833
- ` ${TOOL_NAME} report scorecard --scorecard-json ./scorecard.json --date 2026-04-10\n` +
2834
- ` ${sessionSeverityReport.renderSessionSeverityExample(TOOL_NAME)}`,
2835
- );
2836
- process.exitCode = 0;
2837
- return;
2838
- }
2839
-
2840
- if (subcommand === 'session-severity') {
2841
- const payload = sessionSeverityReport.buildSessionSeverityReport(options);
2842
- if (options.json) {
2843
- process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
2844
- process.exitCode = 0;
2845
- return;
2846
- }
2847
- console.log(sessionSeverityReport.renderSessionSeverityReport(payload));
2848
- process.exitCode = 0;
2849
- return;
2850
- }
2851
-
2852
- if (subcommand !== 'scorecard') {
2853
- throw new Error(`Unknown report subcommand: ${subcommand}`);
2854
- }
2855
-
2856
- const repoRoot = resolveRepoRoot(options.target);
2857
- const repo = resolveScorecardRepo(repoRoot, options.repo);
2858
- const payload = options.scorecardJson
2859
- ? readScorecardJsonFile(options.scorecardJson)
2860
- : runScorecardJson(repo);
2861
-
2862
- const reportDate = options.date || todayDateStamp();
2863
- const outputDir = path.resolve(options.outputDir || path.join(repoRoot, 'docs', 'reports'));
2864
- const baselinePath = path.join(outputDir, `openssf-scorecard-baseline-${reportDate}.md`);
2865
- const remediationPath = path.join(outputDir, `openssf-scorecard-remediation-plan-${reportDate}.md`);
2866
-
2867
- const checks = normalizeScorecardChecks(payload);
2868
- const rawScore = Number(payload?.score);
2869
- const score = Number.isFinite(rawScore) ? rawScore : 0;
2870
- const capturedAt = String(payload?.date || new Date().toISOString());
2871
- const scorecardVersion = String(payload?.scorecard?.version || payload?.version || 'unknown');
2872
-
2873
- const baselineMarkdown = renderScorecardBaselineMarkdown({
2874
- repo,
2875
- score,
2876
- checks,
2877
- capturedAt,
2878
- scorecardVersion,
2879
- reportDate,
2880
- });
2881
-
2882
- const remediationMarkdown = renderScorecardRemediationPlanMarkdown({
2883
- baselineRelativePath: path.relative(repoRoot, baselinePath) || path.basename(baselinePath),
2884
- checks,
2885
- });
2886
-
2887
- if (!options.dryRun) {
2888
- fs.mkdirSync(outputDir, { recursive: true });
2889
- fs.writeFileSync(baselinePath, baselineMarkdown, 'utf8');
2890
- fs.writeFileSync(remediationPath, remediationMarkdown, 'utf8');
2891
- }
2892
-
2893
- if (options.json) {
2894
- process.stdout.write(
2895
- JSON.stringify(
2896
- {
2897
- repoRoot,
2898
- repo,
2899
- score,
2900
- checks: checks.length,
2901
- outputDir,
2902
- baselinePath,
2903
- remediationPath,
2904
- dryRun: Boolean(options.dryRun),
2905
- },
2906
- null,
2907
- 2,
2908
- ) + '\n',
2909
- );
2910
- process.exitCode = 0;
2911
- return;
2912
- }
2913
-
2914
- console.log(`[${TOOL_NAME}] Report target: ${repoRoot}`);
2915
- console.log(`[${TOOL_NAME}] Scorecard repo: ${repo}`);
2916
- console.log(`[${TOOL_NAME}] Score: ${score}/10`);
2917
- if (options.dryRun) {
2918
- console.log(`[${TOOL_NAME}] Dry run report paths:`);
2919
- } else {
2920
- console.log(`[${TOOL_NAME}] Generated reports:`);
2921
- }
2922
- console.log(` - ${baselinePath}`);
2923
- console.log(` - ${remediationPath}`);
2924
- process.exitCode = 0;
2925
- }
2926
-
2927
- function setup(rawArgs) {
2928
- const options = parseSetupArgs(rawArgs, {
2929
- target: process.cwd(),
2930
- force: false,
2931
- skipAgents: false,
2932
- skipPackageJson: false,
2933
- skipGitignore: false,
2934
- dryRun: false,
2935
- yesGlobalInstall: false,
2936
- noGlobalInstall: false,
2937
- allowProtectedBaseWrite: false,
2938
- });
2939
-
2940
- const globalInstallStatus = toolchainModule.installGlobalToolchain(options);
2941
- if (globalInstallStatus.status === 'installed') {
2942
- console.log(
2943
- `[${TOOL_NAME}] ✅ Companion tools installed (${(globalInstallStatus.packages || []).join(', ')}).`,
2944
- );
2945
- } else if (globalInstallStatus.status === 'already-installed') {
2946
- console.log(`[${TOOL_NAME}] ✅ Companion tools already installed. Skipping.`);
2947
- } else if (globalInstallStatus.status === 'failed') {
2948
- const installCommands = toolchainModule.describeCompanionInstallCommands(
2949
- GLOBAL_TOOLCHAIN_PACKAGES,
2950
- OPTIONAL_LOCAL_COMPANION_TOOLS,
2951
- );
2952
- console.log(
2953
- `[${TOOL_NAME}] ⚠️ Global install failed: ${globalInstallStatus.reason}\n` +
2954
- `[${TOOL_NAME}] Continue with local safety setup. You can retry later with:\n` +
2955
- installCommands.map((command) => ` ${command}`).join('\n'),
2956
- );
2957
- } else if (globalInstallStatus.status === 'skipped' && globalInstallStatus.reason === 'non-interactive-default') {
2958
- console.log(
2959
- `[${TOOL_NAME}] Skipping companion installs (non-interactive mode). ` +
2960
- `Use --yes-global-install to force or run interactively for Y/N prompt.`,
2961
- );
2962
- } else if (globalInstallStatus.status === 'skipped') {
2963
- console.log(`[${TOOL_NAME}] ⚠️ Companion installs skipped by user choice.`);
2964
- for (const warning of toolchainModule.describeMissingGlobalDependencyWarnings(
2965
- globalInstallStatus.missingPackages || [],
2966
- )) {
2967
- console.log(`[${TOOL_NAME}] ⚠️ ${warning}`);
2968
- }
2969
- }
2970
-
2971
- printRequiredSystemToolStatus();
2972
-
2973
- const topRepoRoot = resolveRepoRoot(options.target);
2974
- const discoveredRepos = options.recursive
2975
- ? discoverNestedGitRepos(topRepoRoot, {
2976
- maxDepth: options.nestedMaxDepth,
2977
- extraSkip: options.nestedSkipDirs,
2978
- includeSubmodules: options.includeSubmodules,
2979
- skipRelativeDirs: AGENT_WORKTREE_RELATIVE_DIRS,
2980
- })
2981
- : [topRepoRoot];
2982
-
2983
- if (discoveredRepos.length > 1) {
2984
- console.log(
2985
- `[${TOOL_NAME}] Detected ${discoveredRepos.length} git repos under ${topRepoRoot}. Installing into each (use --no-recursive or --current to limit to the top-level).`,
2986
- );
2987
- for (const repoPath of discoveredRepos) {
2988
- const marker = repoPath === topRepoRoot ? ' (top-level)' : '';
2989
- console.log(`[${TOOL_NAME}] - ${repoPath}${marker}`);
2990
- }
2991
- }
2992
-
2993
- let aggregateErrors = 0;
2994
- let aggregateWarnings = 0;
2995
- let lastScanResult = null;
2996
-
2997
- for (const repoPath of discoveredRepos) {
2998
- const perRepoOptions = { ...options, target: repoPath };
2999
- const repoLabel = discoveredRepos.length > 1 ? ` [${path.relative(topRepoRoot, repoPath) || '.'}]` : '';
3000
-
3001
- if (discoveredRepos.length > 1) {
3002
- console.log(`[${TOOL_NAME}] ── Setup target: ${repoPath} ──`);
3003
- }
3004
-
3005
- const blocked = protectedBaseWriteBlock(perRepoOptions);
3006
- if (blocked) {
3007
- const sandboxResult = runSetupInSandbox(perRepoOptions, blocked, repoLabel);
3008
- aggregateErrors += sandboxResult.scanResult.errors;
3009
- aggregateWarnings += sandboxResult.scanResult.warnings;
3010
- lastScanResult = sandboxResult.scanResult;
3011
- const primaryBaseBranch = currentBranchName(blocked.repoRoot);
3012
- const prunePayload = doctorModule.pruneStaleAgentWorktrees(blocked.repoRoot, {
3013
- baseBranch: primaryBaseBranch,
3014
- dryRun: perRepoOptions.dryRun,
3015
- });
3016
- printWorktreePruneSummary(prunePayload, { baseBranch: primaryBaseBranch });
3017
- continue;
3018
- }
3019
-
3020
- const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(perRepoOptions);
3021
- printOperations(`Setup/install${repoLabel}`, installPayload, perRepoOptions.dryRun);
3022
- printOperations(`Setup/fix${repoLabel}`, fixPayload, perRepoOptions.dryRun);
3023
-
3024
- if (perRepoOptions.dryRun) {
3025
- continue;
3026
- }
3027
-
3028
- if (parentWorkspace) {
3029
- console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
3030
- }
3031
-
3032
- const scanResult = runScanInternal({ target: repoPath, json: false });
3033
- const currentBaseBranch = currentBranchName(scanResult.repoRoot);
3034
- const autoFinishSummary = doctorModule.autoFinishReadyAgentBranches(scanResult.repoRoot, {
3035
- baseBranch: currentBaseBranch,
3036
- dryRun: perRepoOptions.dryRun,
3037
- });
3038
- printScanResult(scanResult, false);
3039
- printAutoFinishSummary(autoFinishSummary, {
3040
- baseBranch: currentBaseBranch,
3041
- });
3042
- const prunePayload = scanResult.guardexEnabled === false
3043
- ? { enabled: false, ran: false, status: 'skipped', details: ['Guardex disabled for this repo.'] }
3044
- : doctorModule.pruneStaleAgentWorktrees(scanResult.repoRoot, {
3045
- baseBranch: currentBaseBranch,
3046
- dryRun: perRepoOptions.dryRun,
3047
- });
3048
- printWorktreePruneSummary(prunePayload, { baseBranch: currentBaseBranch });
3049
- printSetupRepoHints(scanResult.repoRoot, currentBaseBranch, repoLabel);
3050
-
3051
- aggregateErrors += scanResult.errors;
3052
- aggregateWarnings += scanResult.warnings;
3053
- lastScanResult = scanResult;
3054
- }
3055
-
3056
- if (options.dryRun) {
3057
- console.log(`[${TOOL_NAME}] Dry run setup done.`);
3058
- process.exitCode = 0;
3059
- return;
3060
- }
3061
-
3062
- if (aggregateErrors === 0 && aggregateWarnings === 0) {
3063
- const repoCount = discoveredRepos.length;
3064
- const suffix = repoCount > 1 ? ` (${repoCount} repos)` : '';
3065
- console.log(`[${TOOL_NAME}] ✅ Setup complete.${suffix}`);
3066
- console.log(`[${TOOL_NAME}] Copy AI setup prompt with: ${SHORT_TOOL_NAME} prompt`);
3067
- console.log(
3068
- `[${TOOL_NAME}] OpenSpec core workflow: /opsx:propose -> /opsx:apply -> /opsx:archive`,
3069
- );
3070
- console.log(
3071
- `[${TOOL_NAME}] Optional expanded OpenSpec profile: openspec config profile <profile-name> && openspec update`,
3072
- );
3073
- console.log(`[${TOOL_NAME}] OpenSpec guide: docs/openspec-getting-started.md`);
3074
- }
3075
-
3076
- if (lastScanResult) {
3077
- setExitCodeFromScan({
3078
- ...lastScanResult,
3079
- errors: aggregateErrors,
3080
- warnings: aggregateWarnings,
3081
- });
3082
- }
3083
- }
3084
-
3085
- function printRequiredSystemToolStatus() {
3086
- const requiredSystemTools = toolchainModule.detectRequiredSystemTools();
3087
- const missingSystemTools = requiredSystemTools.filter((tool) => tool.status !== 'active');
3088
- if (missingSystemTools.length === 0) {
3089
- console.log(`[${TOOL_NAME}] ✅ Required system tools available (${requiredSystemTools.map((tool) => tool.name).join(', ')}).`);
3090
- return;
3091
- }
3092
-
3093
- const names = missingSystemTools.map((tool) => tool.name).join(', ');
3094
- console.log(`[${TOOL_NAME}] ⚠️ Missing required system tool(s): ${names}`);
3095
- for (const tool of missingSystemTools) {
3096
- const reasonText = tool.reason ? ` (${tool.reason})` : '';
3097
- console.log(`[${TOOL_NAME}] Install ${tool.name}: ${tool.installHint}${reasonText}`);
3098
- }
3099
- }
3100
-
3101
- function ensureMainBranch(repoRoot) {
3102
- const branchResult = gitRun(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFailure: true });
3103
- if (branchResult.status !== 0) {
3104
- throw new Error(`Unable to detect current branch in ${repoRoot}`);
3105
- }
3106
-
3107
- const branch = branchResult.stdout.trim();
3108
- if (branch !== 'main') {
3109
- throw new Error(`Release blocked: current branch is '${branch}' (required: 'main')`);
3110
- }
3111
- }
3112
-
3113
- function ensureCleanWorkingTree(repoRoot) {
3114
- const statusResult = gitRun(repoRoot, ['status', '--porcelain'], { allowFailure: true });
3115
- if (statusResult.status !== 0) {
3116
- throw new Error(`Unable to read git status in ${repoRoot}`);
3117
- }
3118
-
3119
- const dirty = statusResult.stdout.trim();
3120
- if (dirty.length > 0) {
3121
- throw new Error('Release blocked: working tree is not clean');
3122
- }
3123
- }
3124
-
3125
- function readReleaseRepoPackageJson(repoRoot) {
3126
- const manifestPath = path.join(repoRoot, 'package.json');
3127
- if (!fs.existsSync(manifestPath)) {
3128
- throw new Error(`Release blocked: package.json missing in ${repoRoot}`);
3129
- }
3130
-
3131
- try {
3132
- return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
3133
- } catch (error) {
3134
- throw new Error(`Release blocked: unable to parse package.json in ${repoRoot}: ${error.message}`);
3135
- }
3136
- }
3137
-
3138
- function resolveReleaseGithubRepo(repoRoot) {
3139
- const releasePackageJson = readReleaseRepoPackageJson(repoRoot);
3140
- const fromManifest = inferGithubRepoSlug(
3141
- releasePackageJson.repository &&
3142
- (releasePackageJson.repository.url || releasePackageJson.repository),
3143
- );
3144
- if (fromManifest) {
3145
- return fromManifest;
3146
- }
3147
-
3148
- const fromOrigin = inferGithubRepoSlug(readGitConfig(repoRoot, 'remote.origin.url'));
3149
- if (fromOrigin) {
3150
- return fromOrigin;
3151
- }
3152
-
3153
- throw new Error(
3154
- 'Release blocked: unable to resolve GitHub repo from package.json repository URL or origin remote.',
3155
- );
3156
- }
3157
-
3158
- function readRepoReadme(repoRoot) {
3159
- const readmePath = path.join(repoRoot, 'README.md');
3160
- if (!fs.existsSync(readmePath)) {
3161
- throw new Error(`Release blocked: README.md missing in ${repoRoot}`);
3162
- }
3163
- return fs.readFileSync(readmePath, 'utf8');
3164
- }
3165
-
3166
- function parseReadmeReleaseEntries(readmeContent) {
3167
- const releaseNotesIndex = String(readmeContent || '').indexOf('## Release notes');
3168
- if (releaseNotesIndex < 0) {
3169
- throw new Error('Release blocked: README.md is missing the "## Release notes" section');
3170
- }
3171
-
3172
- const releaseNotesContent = String(readmeContent || '').slice(releaseNotesIndex);
3173
- const entries = [];
3174
- const lines = releaseNotesContent.split(/\r?\n/);
3175
- let currentTag = '';
3176
- let currentLines = [];
3177
-
3178
- function flushEntry() {
3179
- if (!currentTag) {
3180
- return;
3181
- }
3182
- const body = currentLines.join('\n').trim();
3183
- if (body) {
3184
- entries.push({ tag: currentTag, body, version: parseVersionString(currentTag) });
3185
- }
3186
- currentTag = '';
3187
- currentLines = [];
3188
- }
3189
-
3190
- for (const line of lines) {
3191
- const headingMatch = line.match(/^###\s+(v\d+\.\d+\.\d+)\s*$/);
3192
- if (headingMatch) {
3193
- flushEntry();
3194
- currentTag = headingMatch[1];
3195
- continue;
3196
- }
3197
-
3198
- if (!currentTag) {
3199
- continue;
3200
- }
3201
-
3202
- if (/^<\/details>\s*$/.test(line) || /^##\s+/.test(line)) {
3203
- flushEntry();
3204
- continue;
3205
- }
3206
-
3207
- currentLines.push(line);
3208
- }
3209
-
3210
- flushEntry();
3211
-
3212
- if (entries.length === 0) {
3213
- throw new Error('Release blocked: README.md did not yield any versioned release-note sections');
3214
- }
3215
-
3216
- return entries;
3217
- }
3218
-
3219
- function resolvePreviousPublishedReleaseTag(repoSlug, currentTag) {
3220
- const result = run(GH_BIN, ['release', 'list', '--repo', repoSlug, '--limit', '20'], {
3221
- timeout: 20_000,
3222
- });
3223
- if (result.error) {
3224
- throw new Error(`Release blocked: unable to run '${GH_BIN} release list': ${result.error.message}`);
3225
- }
3226
- if (result.status !== 0) {
3227
- const details = (result.stderr || result.stdout || '').trim();
3228
- throw new Error(`Release blocked: unable to list GitHub releases.${details ? `\n${details}` : ''}`);
3229
- }
3230
-
3231
- const tags = String(result.stdout || '')
3232
- .split('\n')
3233
- .map((line) => line.split('\t')[0].trim())
3234
- .filter(Boolean);
3235
-
3236
- return tags.find((tag) => tag !== currentTag) || '';
3237
- }
3238
-
3239
- function selectReleaseEntriesForWindow(entries, currentTag, previousTag) {
3240
- const currentVersion = parseVersionString(currentTag);
3241
- if (!currentVersion) {
3242
- throw new Error(`Release blocked: invalid current version tag '${currentTag}'`);
3243
- }
3244
- const previousVersion = previousTag ? parseVersionString(previousTag) : null;
3245
-
3246
- const selected = entries.filter((entry) => {
3247
- if (!entry.version) return false;
3248
- if (compareParsedVersions(entry.version, currentVersion) > 0) return false;
3249
- if (!previousVersion) return entry.tag === currentTag;
3250
- return compareParsedVersions(entry.version, previousVersion) > 0;
3251
- });
3252
-
3253
- if (!selected.some((entry) => entry.tag === currentTag)) {
3254
- throw new Error(`Release blocked: README.md is missing release notes for ${currentTag}`);
3255
- }
3256
-
3257
- return selected;
3258
- }
3259
-
3260
- function renderGeneratedReleaseNotes(entries, currentTag, previousTag) {
3261
- const intro = previousTag ? `Changes since ${previousTag}.` : `Changes in ${currentTag}.`;
3262
- const sections = entries
3263
- .map((entry) => `### ${entry.tag}\n${entry.body}`)
3264
- .join('\n\n');
3265
- return `GitGuardex ${currentTag}\n\n${intro}\n\n${sections}`;
3266
- }
3267
-
3268
- function describeGhAuthFailure(ghBin, authStatus) {
3269
- if (authStatus.error) {
3270
- return `unable to run '${ghBin} auth status': ${authStatus.error.message}`;
3271
- }
3272
-
3273
- const authDetails = (authStatus.stderr || authStatus.stdout || '').trim();
3274
- const apiProbe = run(ghBin, ['api', 'user', '--jq', '.login'], { timeout: 20_000 });
3275
- if (apiProbe.status === 0) {
3276
- return '';
3277
- }
3278
-
3279
- const apiDetails = (apiProbe.stderr || apiProbe.stdout || apiProbe.error?.message || '').trim();
3280
- if (/error connecting to api\.github\.com|could not resolve host|failed to connect|network is unreachable|connection timed out|temporary failure in name resolution/i.test(apiDetails)) {
3281
- return `GitHub API is unreachable, so '${ghBin} auth status' cannot validate the stored token. This is a network or sandbox connectivity problem, not proof that the token is invalid.${apiDetails ? `\n${apiDetails}` : ''}`;
3282
- }
3283
-
3284
- return `'${ghBin}' auth is unavailable.${authDetails ? `\n${authDetails}` : ''}`;
3285
- }
3286
-
3287
- function buildReleaseNotesFromReadme(repoRoot, currentTag, previousTag) {
3288
- const readme = readRepoReadme(repoRoot);
3289
- const entries = parseReadmeReleaseEntries(readme);
3290
- const selected = selectReleaseEntriesForWindow(entries, currentTag, previousTag);
3291
- return renderGeneratedReleaseNotes(selected, currentTag, previousTag);
3292
- }
3293
-
3294
- function release(rawArgs) {
3295
- if (rawArgs.length > 0) {
3296
- throw new Error(`Unknown option: ${rawArgs[0]}`);
3297
- }
3298
-
3299
- const repoRoot = resolveRepoRoot(process.cwd());
3300
- if (path.resolve(repoRoot) !== MAINTAINER_RELEASE_REPO) {
3301
- throw new Error(
3302
- `Release blocked: command only allowed in ${MAINTAINER_RELEASE_REPO} (current: ${repoRoot})`,
3303
- );
3304
- }
3305
-
3306
- ensureMainBranch(repoRoot);
3307
- ensureCleanWorkingTree(repoRoot);
3308
-
3309
- if (!isCommandAvailable(GH_BIN)) {
3310
- throw new Error(`Release blocked: '${GH_BIN}' is not available`);
3311
- }
3312
-
3313
- const ghAuthStatus = run(GH_BIN, ['auth', 'status'], { timeout: 20_000 });
3314
- if (ghAuthStatus.status !== 0) {
3315
- const ghAuthFailure = describeGhAuthFailure(GH_BIN, ghAuthStatus);
3316
- if (ghAuthFailure) {
3317
- throw new Error(`Release blocked: ${ghAuthFailure}`);
3318
- }
3319
- }
3320
-
3321
- const releasePackageJson = readReleaseRepoPackageJson(repoRoot);
3322
- const repoSlug = resolveReleaseGithubRepo(repoRoot);
3323
- const currentTag = `v${releasePackageJson.version}`;
3324
- const previousTag = resolvePreviousPublishedReleaseTag(repoSlug, currentTag);
3325
- const notes = buildReleaseNotesFromReadme(repoRoot, currentTag, previousTag);
3326
- const headCommit = gitRun(repoRoot, ['rev-parse', 'HEAD']).stdout.trim();
3327
-
3328
- const existingRelease = run(GH_BIN, ['release', 'view', currentTag, '--repo', repoSlug], {
3329
- timeout: 20_000,
3330
- });
3331
- if (existingRelease.error) {
3332
- throw new Error(`Release blocked: unable to run '${GH_BIN} release view': ${existingRelease.error.message}`);
3333
- }
3334
-
3335
- const releaseArgs =
3336
- existingRelease.status === 0
3337
- ? ['release', 'edit', currentTag, '--repo', repoSlug, '--title', currentTag, '--notes', notes]
3338
- : [
3339
- 'release',
3340
- 'create',
3341
- currentTag,
3342
- '--repo',
3343
- repoSlug,
3344
- '--target',
3345
- headCommit,
3346
- '--title',
3347
- currentTag,
3348
- '--notes',
3349
- notes,
3350
- ];
3351
-
3352
- console.log(
3353
- `[${TOOL_NAME}] ${existingRelease.status === 0 ? 'Updating' : 'Creating'} GitHub release ${currentTag} on ${repoSlug}`,
3354
- );
3355
- if (previousTag) {
3356
- console.log(`[${TOOL_NAME}] Aggregating README release notes newer than ${previousTag}.`);
3357
- } else {
3358
- console.log(`[${TOOL_NAME}] No earlier published GitHub release found; using only ${currentTag}.`);
3359
- }
3360
-
3361
- const releaseResult = run(GH_BIN, releaseArgs, { cwd: repoRoot, timeout: 60_000 });
3362
- if (releaseResult.error) {
3363
- throw new Error(`Release blocked: unable to run '${GH_BIN} release': ${releaseResult.error.message}`);
3364
- }
3365
- if (releaseResult.status !== 0) {
3366
- const details = (releaseResult.stderr || releaseResult.stdout || '').trim();
3367
- throw new Error(`GitHub release command failed.${details ? `\n${details}` : ''}`);
3368
- }
3369
-
3370
- const releaseUrl = String(releaseResult.stdout || '').trim();
3371
- if (releaseUrl) {
3372
- console.log(releaseUrl);
3373
- }
3374
-
3375
- console.log(`[${TOOL_NAME}] ✅ GitHub release ${currentTag} is synced to the README history.`);
3376
- process.exitCode = 0;
3377
- }
3378
-
3379
- function printAgentsSnippet() {
3380
- const snippetPath = path.join(TEMPLATE_ROOT, 'AGENTS.multiagent-safety.md');
3381
- process.stdout.write(fs.readFileSync(snippetPath, 'utf8'));
3382
- }
3383
-
3384
- function copyPrompt() {
3385
- process.stdout.write(AI_SETUP_PROMPT);
3386
- process.exitCode = 0;
3387
- }
3388
-
3389
- function copyCommands() {
3390
- process.stdout.write(AI_SETUP_COMMANDS);
3391
- process.exitCode = 0;
3392
- }
3393
-
3394
- function prompt(rawArgs) {
3395
- const args = Array.isArray(rawArgs) ? rawArgs : [];
3396
- let variant = 'prompt';
3397
- let listParts = false;
3398
- const selectedParts = [];
3399
- for (let index = 0; index < args.length; index += 1) {
3400
- const arg = args[index];
3401
- if (arg === '--exec' || arg === '--commands') variant = 'exec';
3402
- else if (arg === '--snippet' || arg === '--agents') variant = 'snippet';
3403
- else if (arg === '--prompt' || arg === '--full') variant = 'prompt';
3404
- else if (arg === '--list-parts') listParts = true;
3405
- else if (arg === '--part' || arg === '--parts') {
3406
- const rawValue = args[index + 1];
3407
- if (!rawValue || rawValue.startsWith('--')) {
3408
- throw new Error(`${arg} requires a value`);
3409
- }
3410
- selectedParts.push(...parseAiSetupPartNames(rawValue));
3411
- index += 1;
3412
- } else if (arg.startsWith('--part=')) {
3413
- selectedParts.push(...parseAiSetupPartNames(arg.slice('--part='.length)));
3414
- } else if (arg.startsWith('--parts=')) {
3415
- selectedParts.push(...parseAiSetupPartNames(arg.slice('--parts='.length)));
3416
- }
3417
- else if (arg === '-h' || arg === '--help') variant = 'help';
3418
- else throw new Error(`Unknown option: ${arg}`);
3419
- }
3420
- if (variant === 'help') {
3421
- console.log(
3422
- `${SHORT_TOOL_NAME} prompt commands:\n` +
3423
- ` ${SHORT_TOOL_NAME} prompt Print AI setup checklist\n` +
3424
- ` ${SHORT_TOOL_NAME} prompt --exec Print setup commands only (shell-ready)\n` +
3425
- ` ${SHORT_TOOL_NAME} prompt --part <name> Print only the named checklist slice(s)\n` +
3426
- ` ${SHORT_TOOL_NAME} prompt --exec --part <name> Print only the named exec-capable slice(s)\n` +
3427
- ` ${SHORT_TOOL_NAME} prompt --list-parts List prompt part names\n` +
3428
- ` ${SHORT_TOOL_NAME} prompt --exec --list-parts List exec-capable prompt part names\n` +
3429
- ` ${SHORT_TOOL_NAME} prompt --snippet Print the AGENTS.md managed-block template`,
3430
- );
3431
- process.exitCode = 0;
3432
- return;
3433
- }
3434
- if (variant === 'snippet') {
3435
- if (listParts || selectedParts.length > 0) {
3436
- throw new Error('--snippet does not support --list-parts or --part');
3437
- }
3438
- return printAgentsSnippet();
3439
- }
3440
- if (listParts) {
3441
- if (selectedParts.length > 0) {
3442
- throw new Error('--list-parts does not support --part');
3443
- }
3444
- process.stdout.write(`${listAiSetupPartNames({ execOnly: variant === 'exec' }).join('\n')}\n`);
3445
- process.exitCode = 0;
3446
- return;
3447
- }
3448
- process.stdout.write(renderAiSetupPrompt({ exec: variant === 'exec', parts: selectedParts }));
3449
- process.exitCode = 0;
3450
- }
3451
-
3452
- function branch(rawArgs) {
3453
- const activeCwd = process.cwd();
3454
- const [subcommand, ...rest] = rawArgs;
3455
- if (subcommand === 'start') {
3456
- const { target, passthrough } = extractTargetedArgs(rest);
3457
- invokePackageAsset('branchStart', passthrough, { cwd: resolveRepoRoot(target) });
3458
- return;
3459
- }
3460
- if (subcommand === 'finish') {
3461
- const { target, passthrough } = extractTargetedArgs(rest);
3462
- invokePackageAsset('branchFinish', passthrough, {
3463
- cwd: resolveRepoRoot(target),
3464
- env: { GUARDEX_FINISH_ACTIVE_CWD: activeCwd },
3465
- });
3466
- return;
3467
- }
3468
- if (subcommand === 'merge') return merge(rest);
3469
- throw new Error(
3470
- `Usage: ${SHORT_TOOL_NAME} branch <start|finish|merge> [options] ` +
3471
- `(examples: '${SHORT_TOOL_NAME} branch start "<task>" "<agent>"', '${SHORT_TOOL_NAME} branch finish --branch <agent/...>')`,
3472
- );
3473
- }
3474
-
3475
- // `gx pivot` — single-tool-call escape from a protected branch into an isolated
3476
- // agent worktree. AI agents (Claude Code / Codex) cannot set the bypass env
3477
- // vars from inside a tool call, so they need a whitelisted command that does
3478
- // the whole hop: branch+worktree creation, dirty-tree migration, and a clean
3479
- // trailer (`WORKTREE_PATH=...`, `BRANCH=...`, `NEXT_STEP=cd ...`) the agent can
3480
- // parse to know exactly where to `cd`.
3481
- //
3482
- // On an existing agent/* branch, `gx pivot` short-circuits and just prints the
3483
- // current worktree path — safe to call as a no-op.
3484
- function pivot(rawArgs) {
3485
- const { target, passthrough } = extractTargetedArgs(rawArgs);
3486
- const repoRoot = resolveRepoRoot(target);
3487
- const headProc = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoRoot });
3488
- const currentBranch = String(headProc.stdout || '').trim();
3489
- if (currentBranch.startsWith('agent/')) {
3490
- const wtProc = run('git', ['rev-parse', '--show-toplevel'], { cwd: repoRoot });
3491
- const wtPath = String(wtProc.stdout || '').trim() || repoRoot;
3492
- process.stdout.write(`[${TOOL_NAME} pivot] Already on agent branch '${currentBranch}'.\n`);
3493
- process.stdout.write(`WORKTREE_PATH=${wtPath}\n`);
3494
- process.stdout.write(`BRANCH=${currentBranch}\n`);
3495
- process.stdout.write(`NEXT_STEP=cd "${wtPath}"\n`);
3496
- process.exitCode = 0;
3497
- return;
3498
- }
3499
- const result = runPackageAsset('branchStart', passthrough, { cwd: repoRoot });
3500
- if (result.stdout) process.stdout.write(result.stdout);
3501
- if (result.stderr) process.stderr.write(result.stderr);
3502
- if (result.status !== 0) {
3503
- process.exitCode = result.status || 1;
3504
- return;
3505
- }
3506
- const stdoutText = String(result.stdout || '');
3507
- const wtMatch = stdoutText.match(/^\[agent-branch-start\] Worktree:\s+(.+)$/m);
3508
- const branchMatch = stdoutText.match(/^\[agent-branch-start\] (?:Created branch|Reusing existing branch):\s+(.+)$/m);
3509
- if (wtMatch) {
3510
- const wtPath = wtMatch[1].trim();
3511
- process.stdout.write('\n');
3512
- process.stdout.write(`WORKTREE_PATH=${wtPath}\n`);
3513
- if (branchMatch) process.stdout.write(`BRANCH=${branchMatch[1].trim()}\n`);
3514
- process.stdout.write(`NEXT_STEP=cd "${wtPath}"\n`);
3515
- }
3516
- process.exitCode = 0;
3517
- }
3518
-
3519
- // `gx ship` — alias for the canonical "I am done" command. Defaults to
3520
- // `finish --via-pr --wait-for-merge --cleanup` so AI agents don't strand
3521
- // commits or worktrees by accident. Any explicit user-supplied flags survive.
3522
- function ship(rawArgs) {
3523
- const args = Array.isArray(rawArgs) ? rawArgs.slice() : [];
3524
- const ensureFlag = (flag) => {
3525
- if (!args.includes(flag)) args.push(flag);
3526
- };
3527
- ensureFlag('--via-pr');
3528
- ensureFlag('--wait-for-merge');
3529
- ensureFlag('--cleanup');
3530
- return finish(args);
3531
- }
3532
-
3533
- function locks(rawArgs) {
3534
- const { target, passthrough } = extractTargetedArgs(rawArgs);
3535
- const result = runPackageAsset('lockTool', passthrough, { cwd: resolveRepoRoot(target) });
3536
- if (result.stdout) process.stdout.write(result.stdout);
3537
- if (result.stderr) process.stderr.write(result.stderr);
3538
- process.exitCode = result.status;
3539
- }
3540
-
3541
- function worktree(rawArgs) {
3542
- const activeCwd = process.cwd();
3543
- const [subcommand, ...rest] = rawArgs;
3544
- if (subcommand === 'prune') {
3545
- const { target, passthrough } = extractTargetedArgs(rest);
3546
- invokePackageAsset('worktreePrune', passthrough, {
3547
- cwd: resolveRepoRoot(target),
3548
- env: { GUARDEX_PRUNE_ACTIVE_CWD: process.env.GUARDEX_PRUNE_ACTIVE_CWD || activeCwd },
3549
- });
3550
- return;
3551
- }
3552
- throw new Error(`Usage: ${SHORT_TOOL_NAME} worktree prune [cleanup-options]`);
3553
- }
3554
-
3555
- function hook(rawArgs) {
3556
- return hooksModule.hook(rawArgs, {
3557
- extractTargetedArgs,
3558
- run,
3559
- resolveRepoRoot,
3560
- packageAssetEnv,
3561
- configureHooks,
3562
- TEMPLATE_ROOT,
3563
- HOOK_NAMES,
3564
- TOOL_NAME,
3565
- SHORT_TOOL_NAME,
3566
- });
3567
- }
3568
-
3569
- function internal(rawArgs) {
3570
- return hooksModule.internal(rawArgs, {
3571
- extractTargetedArgs,
3572
- resolveRepoRoot,
3573
- runReviewBotCommand,
3574
- runPackageAsset,
3575
- });
3576
- }
3577
-
3578
- function installAgentSkills(rawArgs) {
3579
- let dryRun = false;
3580
- let force = false;
3581
- for (const arg of rawArgs) {
3582
- if (arg === '--dry-run') {
3583
- dryRun = true;
3584
- continue;
3585
- }
3586
- if (arg === '--force') {
3587
- force = true;
3588
- continue;
3589
- }
3590
- throw new Error(`Unknown option: ${arg}`);
3591
- }
3592
-
3593
- const operations = USER_LEVEL_SKILL_ASSETS.map((asset) => installUserLevelAsset(asset, { dryRun, force }));
3594
- printStandaloneOperations('User-level Guardex skills', GUARDEX_HOME_DIR, operations, dryRun);
3595
- process.exitCode = 0;
3596
- }
3597
-
3598
- function migrate(rawArgs) {
3599
- const { target, passthrough } = extractTargetedArgs(rawArgs);
3600
- let dryRun = false;
3601
- let force = false;
3602
- let installSkills = false;
3603
- for (const arg of passthrough) {
3604
- if (arg === '--dry-run') {
3605
- dryRun = true;
3606
- continue;
3607
- }
3608
- if (arg === '--force') {
3609
- force = true;
3610
- continue;
3611
- }
3612
- if (arg === '--install-agent-skills') {
3613
- installSkills = true;
3614
- continue;
3615
- }
3616
- throw new Error(`Unknown option: ${arg}`);
3617
- }
3618
-
3619
- const repoRoot = resolveRepoRoot(target);
3620
- const fixPayload = runFixInternal({
3621
- target: repoRoot,
3622
- dryRun,
3623
- force,
3624
- skipAgents: false,
3625
- skipPackageJson: true,
3626
- skipGitignore: false,
3627
- dropStaleLocks: true,
3628
- });
3629
- printOperations('Migrate/fix', fixPayload, dryRun);
3630
-
3631
- if (installSkills) {
3632
- const skillOps = USER_LEVEL_SKILL_ASSETS.map((asset) => installUserLevelAsset(asset, { dryRun, force }));
3633
- printStandaloneOperations('Migrate/install-agent-skills', GUARDEX_HOME_DIR, skillOps, dryRun);
3634
- }
3635
-
3636
- const removableLegacyFiles = LEGACY_MANAGED_REPO_FILES.filter(
3637
- (relativePath) => !REQUIRED_MANAGED_REPO_FILES.includes(relativePath),
3638
- );
3639
- const removalOps = removableLegacyFiles.map((relativePath) => removeLegacyManagedRepoFile(repoRoot, relativePath, { dryRun, force }));
3640
- removalOps.push(removeLegacyPackageScripts(repoRoot, dryRun));
3641
- printStandaloneOperations('Migrate/cleanup', repoRoot, removalOps, dryRun);
3642
- process.exitCode = 0;
3643
- }
3644
-
3645
- function cleanup(rawArgs) {
3646
- return finishCommands.cleanup(rawArgs);
3647
- }
3648
-
3649
- function merge(rawArgs) {
3650
- return finishCommands.merge(rawArgs);
3651
- }
3652
-
3653
- function finish(rawArgs, defaults = {}) {
3654
- return finishCommands.finish(rawArgs, defaults);
3655
- }
3656
-
3657
- function sync(rawArgs) {
3658
- return finishCommands.sync(rawArgs);
3659
- }
3660
-
3661
- function submodule(rawArgs) {
3662
- const parsed = parseTargetFlag(rawArgs || [], process.cwd());
3663
- const [subcommand, ...rest] = parsed.args;
3664
-
3665
- if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
3666
- console.log(
3667
- `${TOOL_NAME} submodule commands:\n` +
3668
- ` ${TOOL_NAME} submodule advance [<path>] [--push] [--dry-run] [--branch <ref>] [--no-commit] [--target <path>]\n\n` +
3669
- ` advance — for each submodule listed in .gitmodules, fetch the tracked branch's\n` +
3670
- ` remote tip, advance the parent pointer, and (when on a non-protected\n` +
3671
- ` branch) commit the bump. Use --push to publish in one step.`,
3672
- );
3673
- return;
3674
- }
3675
-
3676
- if (subcommand !== 'advance') {
3677
- throw new Error(`Unknown submodule subcommand: ${subcommand}. Try '${SHORT_TOOL_NAME} submodule help'.`);
3678
- }
3679
-
3680
- let push = false;
3681
- let dryRun = false;
3682
- let commit = true;
3683
- let branchOverride = '';
3684
- let pathArg = '';
3685
- for (let i = 0; i < rest.length; i += 1) {
3686
- const arg = rest[i];
3687
- if (arg === '--push') {
3688
- push = true;
3689
- continue;
3690
- }
3691
- if (arg === '--dry-run' || arg === '-n') {
3692
- dryRun = true;
3693
- continue;
3694
- }
3695
- if (arg === '--no-commit') {
3696
- commit = false;
3697
- continue;
3698
- }
3699
- if (arg === '--branch' || arg === '-b') {
3700
- branchOverride = rest[i + 1] || '';
3701
- i += 1;
3702
- continue;
3703
- }
3704
- if (arg.startsWith('--branch=')) {
3705
- branchOverride = arg.slice('--branch='.length);
3706
- continue;
3707
- }
3708
- if (arg.startsWith('--')) {
3709
- throw new Error(`Unknown option for '${SHORT_TOOL_NAME} submodule advance': ${arg}`);
3710
- }
3711
- if (pathArg) {
3712
- throw new Error(`'${SHORT_TOOL_NAME} submodule advance' accepts at most one submodule path (got '${pathArg}' and '${arg}')`);
3713
- }
3714
- pathArg = arg;
3715
- }
3716
-
3717
- const result = submoduleModule.advance({
3718
- target: parsed.target,
3719
- path: pathArg,
3720
- push,
3721
- dryRun,
3722
- commit,
3723
- branch: branchOverride,
3724
- });
3725
- submoduleModule.printAdvanceResult(result);
3726
- }
3727
-
3728
- function cockpit(rawArgs) {
3729
- cockpitModule.openCockpit(rawArgs, {
3730
- resolveRepoRoot,
3731
- toolName: TOOL_NAME,
3732
- });
3733
- process.exitCode = 0;
3734
- }
3735
-
3736
- function protect(rawArgs) {
3737
- const parsed = parseTargetFlag(rawArgs, process.cwd());
3738
- const [subcommand, ...rest] = parsed.args;
3739
- const repoRoot = resolveRepoRoot(parsed.target);
3740
-
3741
- if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
3742
- console.log(
3743
- `${TOOL_NAME} protect commands:\n` +
3744
- ` ${TOOL_NAME} protect list [--target <path>]\n` +
3745
- ` ${TOOL_NAME} protect add <branch...> [--target <path>]\n` +
3746
- ` ${TOOL_NAME} protect remove <branch...> [--target <path>]\n` +
3747
- ` ${TOOL_NAME} protect set <branch...> [--target <path>]\n` +
3748
- ` ${TOOL_NAME} protect reset [--target <path>]`,
3749
- );
3750
- process.exitCode = 0;
3751
- return;
3752
- }
3753
-
3754
- const requestedBranches = uniquePreserveOrder(parseBranchList(rest.join(' ')));
3755
-
3756
- if (subcommand === 'list') {
3757
- const branches = readProtectedBranches(repoRoot);
3758
- console.log(`[${TOOL_NAME}] Protected branches (${branches.length}): ${branches.join(', ')}`);
3759
- process.exitCode = 0;
3760
- return;
3761
- }
3762
-
3763
- if (subcommand === 'add') {
3764
- if (requestedBranches.length === 0) {
3765
- throw new Error('protect add requires one or more branch names');
3766
- }
3767
- const current = readProtectedBranches(repoRoot);
3768
- const next = uniquePreserveOrder([...current, ...requestedBranches]);
3769
- writeProtectedBranches(repoRoot, next);
3770
- console.log(`[${TOOL_NAME}] Protected branches updated: ${next.join(', ')}`);
3771
- process.exitCode = 0;
3772
- return;
3773
- }
3774
-
3775
- if (subcommand === 'remove') {
3776
- if (requestedBranches.length === 0) {
3777
- throw new Error('protect remove requires one or more branch names');
3778
- }
3779
- const current = readProtectedBranches(repoRoot);
3780
- const removals = new Set(requestedBranches);
3781
- const next = current.filter((branch) => !removals.has(branch));
3782
- writeProtectedBranches(repoRoot, next);
3783
- console.log(
3784
- `[${TOOL_NAME}] Protected branches updated: ` +
3785
- `${(next.length > 0 ? next : DEFAULT_PROTECTED_BRANCHES).join(', ')}`,
3786
- );
3787
- if (next.length === 0) {
3788
- console.log(`[${TOOL_NAME}] Reset to defaults (${DEFAULT_PROTECTED_BRANCHES.join(', ')}) because list was empty.`);
3789
- }
3790
- process.exitCode = 0;
3791
- return;
3792
- }
3793
-
3794
- if (subcommand === 'set') {
3795
- if (requestedBranches.length === 0) {
3796
- throw new Error('protect set requires one or more branch names');
3797
- }
3798
- writeProtectedBranches(repoRoot, requestedBranches);
3799
- console.log(`[${TOOL_NAME}] Protected branches set: ${requestedBranches.join(', ')}`);
3800
- process.exitCode = 0;
3801
- return;
3802
- }
3803
-
3804
- if (subcommand === 'reset') {
3805
- writeProtectedBranches(repoRoot, []);
3806
- console.log(`[${TOOL_NAME}] Protected branches reset to defaults: ${DEFAULT_PROTECTED_BRANCHES.join(', ')}`);
3807
- process.exitCode = 0;
3808
- return;
3809
- }
3810
-
3811
- throw new Error(`Unknown protect subcommand: ${subcommand}`);
3812
- }
3813
-
3814
149
  async function main() {
3815
150
  const args = process.argv.slice(2);
3816
151
 
3817
152
  if (args.length === 0) {
3818
153
  if (isInteractiveTerminal() && !legacyDefaultStatusEnabled() && !defaultCockpitDisabled()) {
154
+ // Lazy-require: cockpit pulls ~32 modules; load only when actually rendering it.
155
+ const cockpitModule = require('../cockpit');
3819
156
  cockpitModule.openDefaultCockpit({
3820
157
  resolveRepoRoot,
3821
158
  toolName: TOOL_NAME,
@@ -3873,6 +210,8 @@ async function main() {
3873
210
 
3874
211
  if (command === 'prompt') return prompt(rest);
3875
212
  if (command === 'pr-review') return prReview(rest);
213
+ if (command === 'pr') return prCommand(rest);
214
+ if (command === 'claude') return claudeCommand(rest);
3876
215
  if (command === 'doctor') return doctor(rest);
3877
216
  if (command === 'branch') return branch(rest);
3878
217
  if (command === 'pivot') return pivot(rest);
@@ -3884,6 +223,7 @@ async function main() {
3884
223
  if (command === 'install-agent-skills') return installAgentSkills(rest);
3885
224
  if (command === 'internal') return internal(rest);
3886
225
  if (command === 'agents') return agents(rest);
226
+ if (command === 'mcp') return mcp(rest);
3887
227
  if (command === 'cockpit') return cockpit(rest);
3888
228
  if (command === 'merge') return merge(rest);
3889
229
  if (command === 'finish') return finish(rest);
@@ -3893,8 +233,10 @@ async function main() {
3893
233
  if (command === 'submodule') return submodule(rest);
3894
234
  if (command === 'cleanup') return cleanup(rest);
3895
235
  if (command === 'release') return release(rest);
236
+ if (command === 'watch') return watch(rest);
3896
237
  if (command === 'budget') return budgetModule.runBudgetCommand(rest);
3897
238
  if (command === 'ci-init') return ciInitModule.runCiInitCommand(rest);
239
+ if (command === 'speckit') return speckitModule.runSpeckitCommand(rest);
3898
240
 
3899
241
  const suggestion = maybeSuggestCommand(command);
3900
242
  if (suggestion) {