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