@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
@@ -1,4 +1,6 @@
1
+ // @ts-check
1
2
  const { TOOL_NAME, LOCK_FILE_RELATIVE, path, fs } = require('../context');
3
+ const { isTerseMode } = require('../output');
2
4
  const { run, runPackageAsset } = require('../core/runtime');
3
5
  const {
4
6
  resolveRepoRoot,
@@ -26,7 +28,52 @@ const {
26
28
  parseFinishArgs,
27
29
  parseSyncArgs,
28
30
  } = require('../cli/args');
29
-
31
+ const submoduleModule = require('../submodule');
32
+ const { runPreflight, summarizePreflight } = require('./preflight');
33
+ const { runReviewGate } = require('./review-gate');
34
+
35
+ /**
36
+ * Options recognized by {@link autoCommitWorktreeForFinish} and the public
37
+ * {@link finish} entry. Mirrors the relevant subset of `parseFinishArgs`'s
38
+ * output.
39
+ *
40
+ * @typedef {Object} FinishOptions
41
+ * @property {boolean} [noAutoCommit] When true, refuse to auto-commit dirty worktrees.
42
+ * @property {boolean} [dryRun] When true, surface intended actions without performing them.
43
+ * @property {string} [commitMessage] Override for the auto-commit message.
44
+ * @property {boolean} [advanceSubmodules] Run `submoduleModule.advance` against the worktree before finish.
45
+ * @property {boolean} [waitForMerge] Forward `--wait-for-merge` to `agent-branch-finish`.
46
+ * @property {boolean} [cleanup] Forward `--cleanup` (vs `--no-cleanup`) to `agent-branch-finish`.
47
+ * @property {'pr'|'direct'|'auto'} [mergeMode] Merge selection forwarded to `agent-branch-finish`.
48
+ * @property {boolean} [keepRemote] Forward `--keep-remote-branch` to `agent-branch-finish`.
49
+ * @property {boolean} [parentGitlinkCommit] Toggle for `--parent-gitlink-commit`.
50
+ * @property {boolean} [failFast] Stop the loop after the first failing branch.
51
+ * @property {boolean} [all] Include already-merged branches in the candidate list.
52
+ * @property {string} [branch] Single branch to finish (skips discovery).
53
+ * @property {string} [base] Explicit base branch override.
54
+ * @property {string} [target] Path inside the target repo (defaults to cwd).
55
+ */
56
+
57
+ /**
58
+ * Outcome of an auto-commit attempt for a single branch.
59
+ *
60
+ * @typedef {Object} AutoCommitResult
61
+ * @property {boolean} changed True when the worktree had local changes.
62
+ * @property {boolean} committed True only when a commit was created.
63
+ * @property {boolean} [dryRun] True when `--dry-run` short-circuited the commit.
64
+ * @property {string} [message] Commit message used (when `committed` is true).
65
+ */
66
+
67
+ /**
68
+ * Claim agent file locks for every file the worktree is about to commit,
69
+ * including pending deletions. No-op when there is nothing to claim.
70
+ *
71
+ * @param {string} repoRoot Repo root for the lock tool to operate on.
72
+ * @param {string} worktreePath Worktree whose changes are being committed.
73
+ * @param {string} branch Agent branch that should own the new claims.
74
+ * @returns {void}
75
+ * @throws {Error} When the lock-claim or allow-delete subprocess exits non-zero.
76
+ */
30
77
  function claimLocksForAutoCommit(repoRoot, worktreePath, branch) {
31
78
  const changedFiles = uniquePreserveOrder([
32
79
  ...gitOutputLines(worktreePath, ['diff', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
@@ -83,6 +130,17 @@ function claimLocksForAutoCommit(repoRoot, worktreePath, branch) {
83
130
  }
84
131
  }
85
132
 
133
+ /**
134
+ * Stage and commit pending work in `worktreePath` (if any) before the
135
+ * finish flow takes over. Honors `--no-auto-commit` and `--dry-run`.
136
+ *
137
+ * @param {string} repoRoot Repo root for lock-tool dispatch.
138
+ * @param {string} worktreePath Worktree to commit in.
139
+ * @param {string} branch Agent branch the worktree is checked out on.
140
+ * @param {FinishOptions} options Finish options (only auto-commit fields are read).
141
+ * @returns {AutoCommitResult} What happened (or would happen, in dry-run).
142
+ * @throws {Error} When `--no-auto-commit` is set with dirty state, or when git add/commit fails.
143
+ */
86
144
  function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) {
87
145
  const hasChanges = worktreeHasLocalChanges(worktreePath);
88
146
  if (!hasChanges) {
@@ -133,7 +191,17 @@ function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) {
133
191
  return { changed: true, committed: true, message: commitMessage };
134
192
  }
135
193
 
194
+ /**
195
+ * Run the worktree-prune sweep with options parsed from `rawArgs`. In watch
196
+ * mode loops forever (or until `--once`), printing the cycle header and
197
+ * delegating each cycle to the `worktreePrune` package asset.
198
+ *
199
+ * @param {ReadonlyArray<string>} rawArgs CLI argv slice for the cleanup command.
200
+ * @returns {void}
201
+ * @throws {Error} When the underlying prune subprocess exits non-zero or the watch sleep fails.
202
+ */
136
203
  function cleanup(rawArgs) {
204
+ const activeCwd = process.cwd();
137
205
  const options = parseCleanupArgs(rawArgs);
138
206
  const repoRoot = resolveRepoRoot(options.target);
139
207
 
@@ -168,7 +236,11 @@ function cleanup(rawArgs) {
168
236
  }
169
237
 
170
238
  const runCleanupCycle = () => {
171
- const runResult = runPackageAsset('worktreePrune', args, { cwd: repoRoot, stdio: 'inherit' });
239
+ const runResult = runPackageAsset('worktreePrune', args, {
240
+ cwd: repoRoot,
241
+ stdio: 'inherit',
242
+ env: { GUARDEX_PRUNE_ACTIVE_CWD: activeCwd },
243
+ });
172
244
  if (runResult.status !== 0) {
173
245
  throw new Error('Cleanup command failed');
174
246
  }
@@ -198,6 +270,14 @@ function cleanup(rawArgs) {
198
270
  process.exitCode = 0;
199
271
  }
200
272
 
273
+ /**
274
+ * Dispatch to the `branchMerge` package asset with options parsed from
275
+ * `rawArgs`. Pipes stdout/stderr through to the parent process.
276
+ *
277
+ * @param {ReadonlyArray<string>} rawArgs CLI argv slice for the merge command.
278
+ * @returns {void}
279
+ * @throws {Error} When the merge subprocess exits non-zero.
280
+ */
201
281
  function merge(rawArgs) {
202
282
  const options = parseMergeArgs(rawArgs);
203
283
  const repoRoot = resolveRepoRoot(options.target);
@@ -233,7 +313,19 @@ function merge(rawArgs) {
233
313
  process.exitCode = 0;
234
314
  }
235
315
 
316
+ /**
317
+ * Drive the finish flow across one or many agent branches: discover
318
+ * candidates, auto-commit dirty worktrees, optionally advance submodules,
319
+ * and invoke `agent-branch-finish` for each branch. Aggregates per-branch
320
+ * outcomes and prints a single summary line at the end.
321
+ *
322
+ * @param {ReadonlyArray<string>} rawArgs CLI argv slice for the finish command.
323
+ * @param {Partial<FinishOptions>} [defaults] Defaults merged before CLI overrides.
324
+ * @returns {void}
325
+ * @throws {Error} When `--branch` references an unknown ref, or when any branch fails to finish (after the loop completes).
326
+ */
236
327
  function finish(rawArgs, defaults = {}) {
328
+ const activeCwd = process.cwd();
237
329
  const options = parseFinishArgs(rawArgs, defaults);
238
330
  const repoRoot = resolveRepoRoot(options.target);
239
331
 
@@ -279,12 +371,19 @@ function finish(rawArgs, defaults = {}) {
279
371
  let succeeded = 0;
280
372
  let failed = 0;
281
373
  let autoCommitted = 0;
374
+ const terse = isTerseMode();
282
375
 
283
376
  for (const candidate of candidates) {
284
377
  const { branch, baseBranch, worktreePath } = candidate;
285
- console.log(
286
- `[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}...`,
287
- );
378
+ // In terse mode, defer the "Finishing X -> Y" line until we know whether
379
+ // we also need to announce an auto-commit, then emit a single combined
380
+ // line per branch. Keep branch + base + worktree path so agents still see
381
+ // the load-bearing literals.
382
+ if (!terse) {
383
+ console.log(
384
+ `[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}...`,
385
+ );
386
+ }
288
387
 
289
388
  try {
290
389
  let commitState = { changed: false, committed: false };
@@ -292,13 +391,49 @@ function finish(rawArgs, defaults = {}) {
292
391
  commitState = autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options);
293
392
  }
294
393
 
295
- if (commitState.committed) {
394
+ if (terse) {
395
+ const suffix = commitState.committed
396
+ ? ' [auto-committed]'
397
+ : (commitState.changed && commitState.dryRun ? ' [dry-run: would auto-commit]' : '');
398
+ console.log(
399
+ `[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}${suffix}`,
400
+ );
401
+ if (commitState.committed) {
402
+ autoCommitted += 1;
403
+ }
404
+ } else if (commitState.committed) {
296
405
  autoCommitted += 1;
297
406
  console.log(`[${TOOL_NAME}] Auto-committed '${branch}' before finish.`);
298
407
  } else if (commitState.changed && commitState.dryRun) {
299
408
  console.log(`[${TOOL_NAME}] [dry-run] Would auto-commit pending changes on '${branch}'.`);
300
409
  }
301
410
 
411
+ if (options.advanceSubmodules && worktreePath) {
412
+ const gitmodulesPath = path.join(worktreePath, '.gitmodules');
413
+ if (fs.existsSync(gitmodulesPath)) {
414
+ if (options.dryRun) {
415
+ const preview = submoduleModule.advance({
416
+ target: worktreePath,
417
+ push: false,
418
+ commit: false,
419
+ dryRun: true,
420
+ });
421
+ console.log(`[${TOOL_NAME}] [dry-run] Would advance submodules for '${branch}':`);
422
+ submoduleModule.printAdvanceResult(preview);
423
+ } else {
424
+ const advanceResult = submoduleModule.advance({
425
+ target: worktreePath,
426
+ push: false,
427
+ commit: true,
428
+ dryRun: false,
429
+ });
430
+ submoduleModule.printAdvanceResult(advanceResult);
431
+ }
432
+ } else {
433
+ console.log(`[${TOOL_NAME}] --advance-submodules ignored: '${branch}' has no .gitmodules.`);
434
+ }
435
+ }
436
+
302
437
  const finishArgs = [
303
438
  '--branch',
304
439
  branch,
@@ -325,7 +460,40 @@ function finish(rawArgs, defaults = {}) {
325
460
  continue;
326
461
  }
327
462
 
328
- const finishResult = runPackageAsset('branchFinish', finishArgs, { cwd: repoRoot, stdio: 'pipe' });
463
+ // Preflight: typecheck + lint touched workspace packages before opening
464
+ // a PR. Only enforced for PR-mode finishes; bypass with --skip-preflight.
465
+ if (options.mergeMode === 'pr' && !options.skipPreflight) {
466
+ const preflight = runPreflight(repoRoot, worktreePath, branch, baseBranch, {
467
+ verbose: !terse,
468
+ });
469
+ console.log(`[${TOOL_NAME}] ${summarizePreflight(preflight)}`);
470
+ if (preflight.status === 'failed') {
471
+ for (const f of preflight.failures) {
472
+ console.error(`[${TOOL_NAME}] preflight failure: ${f.label} (exit ${f.status})`);
473
+ if (f.stderr && f.stderr.trim()) {
474
+ console.error(f.stderr.trim());
475
+ } else if (f.stdout && f.stdout.trim()) {
476
+ console.error(f.stdout.trim());
477
+ }
478
+ }
479
+ throw new Error(
480
+ `preflight failed for ${preflight.failures.length} script(s). Fix the failures or rerun with --skip-preflight to bypass.`,
481
+ );
482
+ }
483
+ }
484
+
485
+ // Opt-in merge gate (--gate-review / gx ship): enforce a clean AI review +
486
+ // green CI + GitHub-mergeable verdict BEFORE the shell merge runs. Throws on
487
+ // failure, which the catch below turns into a finish failure (no merge).
488
+ if (options.mergeMode === 'pr' && options.gateReview) {
489
+ runReviewGate({ repoRoot, branch, baseBranch, options });
490
+ }
491
+
492
+ const finishResult = runPackageAsset('branchFinish', finishArgs, {
493
+ cwd: repoRoot,
494
+ stdio: 'pipe',
495
+ env: { GUARDEX_FINISH_ACTIVE_CWD: activeCwd },
496
+ });
329
497
  if (finishResult.stdout) {
330
498
  process.stdout.write(finishResult.stdout);
331
499
  }
@@ -357,6 +525,17 @@ function finish(rawArgs, defaults = {}) {
357
525
  process.exitCode = 0;
358
526
  }
359
527
 
528
+ /**
529
+ * Sync the current (or all) agent branches against the configured base
530
+ * branch using either rebase or merge. Supports `--check`, `--dry-run`,
531
+ * `--json`, and `--all-agent-branches` modes. Temporarily resets the lock
532
+ * registry around the sync operation when it is dirty so it does not block
533
+ * the rebase/merge, then restores the saved contents.
534
+ *
535
+ * @param {ReadonlyArray<string>} rawArgs CLI argv slice for the sync command.
536
+ * @returns {void}
537
+ * @throws {Error} When the working tree is dirty (without `--allow-dirty`), the lock reset fails, or the underlying rebase/merge fails.
538
+ */
360
539
  function sync(rawArgs) {
361
540
  const options = parseSyncArgs(rawArgs);
362
541
  const repoRoot = resolveRepoRoot(options.target);
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ // Pre-ship preflight: before `gx finish --via-pr` opens a PR, walk the diff
4
+ // against the base branch, find which `apps/<pkg>/` workspace packages were
5
+ // touched, and run their `typecheck` + `lint` scripts. If any fail, abort the
6
+ // PR creation — keeps main green so the user's root-worktree dev server (the
7
+ // one they're "visualizing" against) never breaks.
8
+ //
9
+ // Bypass with `--skip-preflight`. Non-monorepo repos (no `apps/<pkg>/package.json`)
10
+ // are silently no-ops.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { spawnSync } = require('child_process');
15
+
16
+ const PREFLIGHT_SCRIPTS = ['typecheck', 'lint'];
17
+
18
+ function readJson(file) {
19
+ try {
20
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function detectAppPackages(repoRoot) {
27
+ const appsRoot = path.join(repoRoot, 'apps');
28
+ let stat;
29
+ try {
30
+ stat = fs.statSync(appsRoot);
31
+ } catch {
32
+ return [];
33
+ }
34
+ if (!stat.isDirectory()) return [];
35
+ let entries;
36
+ try {
37
+ entries = fs.readdirSync(appsRoot, { withFileTypes: true });
38
+ } catch {
39
+ return [];
40
+ }
41
+ const packages = [];
42
+ for (const e of entries) {
43
+ if (!e.isDirectory()) continue;
44
+ const pkgPath = path.join(appsRoot, e.name, 'package.json');
45
+ const pkg = readJson(pkgPath);
46
+ if (!pkg) continue;
47
+ packages.push({
48
+ dir: `apps/${e.name}`,
49
+ name: pkg.name || e.name,
50
+ scripts: pkg.scripts || {},
51
+ });
52
+ }
53
+ return packages;
54
+ }
55
+
56
+ function detectTouchedDirs(workingDir, baseBranch, branch) {
57
+ const ref = baseBranch
58
+ ? `${baseBranch}...${branch || 'HEAD'}`
59
+ : (branch || 'HEAD');
60
+ const diff = spawnSync('git', ['-C', workingDir, 'diff', '--name-only', ref], {
61
+ stdio: ['ignore', 'pipe', 'pipe'],
62
+ timeout: 15_000,
63
+ });
64
+ if (diff.status !== 0) {
65
+ // Try a fallback range with the local base.
66
+ const fallback = spawnSync(
67
+ 'git',
68
+ ['-C', workingDir, 'diff', '--name-only', 'HEAD'],
69
+ { stdio: ['ignore', 'pipe', 'pipe'], timeout: 15_000 },
70
+ );
71
+ if (fallback.status !== 0) return null;
72
+ return (fallback.stdout || '').toString().split('\n').filter(Boolean);
73
+ }
74
+ return (diff.stdout || '').toString().split('\n').filter(Boolean);
75
+ }
76
+
77
+ function pickPackageManager(repoRoot) {
78
+ // Prefer pnpm if a lockfile exists; fall back to npm.
79
+ if (fs.existsSync(path.join(repoRoot, 'pnpm-lock.yaml'))) return 'pnpm';
80
+ if (fs.existsSync(path.join(repoRoot, 'yarn.lock'))) return 'yarn';
81
+ return 'npm';
82
+ }
83
+
84
+ function buildScriptInvocation(pm, pkgName, script) {
85
+ if (pm === 'pnpm') return { cmd: 'pnpm', args: ['--filter', pkgName, script] };
86
+ if (pm === 'yarn') return { cmd: 'yarn', args: ['workspace', pkgName, 'run', script] };
87
+ return { cmd: 'npm', args: ['--workspace', pkgName, 'run', script] };
88
+ }
89
+
90
+ function runPreflight(repoRoot, worktreePath, branch, baseBranch, options = {}) {
91
+ const workingDir = worktreePath || repoRoot;
92
+ const packages = detectAppPackages(repoRoot);
93
+ if (packages.length === 0) {
94
+ return { status: 'skipped', reason: 'no-monorepo', failures: [], ran: [] };
95
+ }
96
+
97
+ const touchedFiles = detectTouchedDirs(workingDir, baseBranch, branch);
98
+ if (touchedFiles === null) {
99
+ return {
100
+ status: 'skipped',
101
+ reason: 'diff-unavailable',
102
+ failures: [],
103
+ ran: [],
104
+ };
105
+ }
106
+
107
+ const touchedPkgs = packages.filter((pkg) =>
108
+ touchedFiles.some((file) => file.startsWith(pkg.dir + '/')),
109
+ );
110
+ if (touchedPkgs.length === 0) {
111
+ return {
112
+ status: 'skipped',
113
+ reason: 'no-app-changes',
114
+ failures: [],
115
+ ran: [],
116
+ };
117
+ }
118
+
119
+ const pm = pickPackageManager(repoRoot);
120
+ const ran = [];
121
+ const failures = [];
122
+ for (const pkg of touchedPkgs) {
123
+ for (const script of PREFLIGHT_SCRIPTS) {
124
+ if (!pkg.scripts[script]) continue;
125
+ const { cmd, args } = buildScriptInvocation(pm, pkg.name, script);
126
+ const label = `${pkg.name}:${script}`;
127
+ if (options.verbose) {
128
+ process.stdout.write(`[preflight] running ${label}…\n`);
129
+ }
130
+ const result = spawnSync(cmd, args, {
131
+ cwd: repoRoot,
132
+ stdio: ['ignore', 'pipe', 'pipe'],
133
+ timeout: 5 * 60_000,
134
+ });
135
+ const stdout = (result.stdout || '').toString();
136
+ const stderr = (result.stderr || '').toString();
137
+ const ok = !result.error && result.status === 0;
138
+ ran.push({ label, ok, status: result.status, cmd, args });
139
+ if (!ok) {
140
+ failures.push({
141
+ label,
142
+ status: result.status,
143
+ stdout: stdout.slice(-2000),
144
+ stderr: stderr.slice(-2000),
145
+ error: result.error ? result.error.message : null,
146
+ });
147
+ }
148
+ }
149
+ }
150
+
151
+ return {
152
+ status: failures.length === 0 ? 'ok' : 'failed',
153
+ reason: failures.length === 0 ? 'all-passed' : 'script-failures',
154
+ packageManager: pm,
155
+ touched: touchedPkgs.map((p) => p.name),
156
+ ran,
157
+ failures,
158
+ };
159
+ }
160
+
161
+ function summarizePreflight(result) {
162
+ if (result.status === 'skipped') {
163
+ return `[preflight] skipped (${result.reason})`;
164
+ }
165
+ const tail = result.ran
166
+ .map((r) => `${r.ok ? '✓' : '✗'} ${r.label}`)
167
+ .join(', ');
168
+ return `[preflight] ${result.status} — ${tail}`;
169
+ }
170
+
171
+ module.exports = {
172
+ detectAppPackages,
173
+ detectTouchedDirs,
174
+ pickPackageManager,
175
+ runPreflight,
176
+ summarizePreflight,
177
+ };
@@ -0,0 +1,182 @@
1
+ // Opt-in merge gate for `gx branch finish --gate-review` / `gx ship`.
2
+ //
3
+ // `gx branch finish` trusts server-side branch protection for the actual merge,
4
+ // which can fail open (it merged PR #610 to main with red preflight tests). When
5
+ // `--gate-review` is set, this module enforces a REAL local gate BEFORE the merge
6
+ // runs: a clean AI review (fail-closed) AND green CI AND GitHub reporting the PR
7
+ // mergeable under branch protection. It throws to block; the finish() catch then
8
+ // skips the merge for that branch. Synchronous, to match finish().
9
+
10
+ const { run } = require('../core/runtime');
11
+ const { TOOL_NAME } = require('../context');
12
+ const pr = require('../pr');
13
+ const prReview = require('../pr-review');
14
+
15
+ const DEFAULT_GATE_TIMEOUT_SECONDS = 1800; // 30 min — CI can be slow.
16
+ const DEFAULT_GATE_POLL_SECONDS = 15;
17
+ const DEFAULT_NO_CHECKS_GRACE_SECONDS = 60; // let CI register check runs after promote.
18
+ // GitHub mergeStateStatus values that mean "mergeable under current protection".
19
+ const MERGEABLE_STATES = new Set(['CLEAN', 'HAS_HOOKS']);
20
+ // mergeStateStatus values that mean "GitHub will not allow this merge as-is".
21
+ // UNSTABLE = a non-required check is failing/pending; BLOCKED = required review/
22
+ // check unmet; DIRTY = conflicts; BEHIND = base moved. All fail closed.
23
+ const BLOCKED_STATES = new Set(['DIRTY', 'BLOCKED', 'BEHIND', 'UNSTABLE']);
24
+
25
+ function gateLog(message) {
26
+ console.log(`[${TOOL_NAME}] [gate] ${message}`);
27
+ }
28
+
29
+ /**
30
+ * Poll the PR's CI until it settles. Fail-closed: red checks, timeout, or a
31
+ * check-less PR (after a grace window) all return a non-green status the caller
32
+ * turns into a block. A just-promoted PR whose checks have not registered yet
33
+ * keeps polling rather than being misread as check-less.
34
+ *
35
+ * Fail-closed semantics:
36
+ * - any failed OR cancelled check blocks (a cancelled required check is NOT a pass);
37
+ * - GitHub's mergeStateStatus is authoritative — BLOCKED/DIRTY/BEHIND/UNSTABLE block;
38
+ * - when GitHub gives no verdict (mss absent/UNKNOWN) we require EVERY check to be an
39
+ * explicit success (no `other` states like ACTION_REQUIRED slipping through);
40
+ * - a check-less PR is only declared `no-checks` after a grace window, so a freshly
41
+ * promoted PR whose checks have not registered yet is not misread.
42
+ *
43
+ * @returns {{status: 'green'|'checks-failed'|'merge-blocked'|'no-checks'|'timeout'|'no-pr', pr?: object}}
44
+ */
45
+ function waitForGreenCi(repoRoot, branch, options = {}) {
46
+ const timeoutSeconds = options.timeoutSeconds || DEFAULT_GATE_TIMEOUT_SECONDS;
47
+ const pollSeconds = options.pollSeconds || DEFAULT_GATE_POLL_SECONDS;
48
+ const requireChecks = options.requireChecks !== false;
49
+ const graceSeconds = options.noChecksGraceSeconds || DEFAULT_NO_CHECKS_GRACE_SECONDS;
50
+ const sleep = options.sleep || ((seconds) => run('sleep', [String(seconds)], { cwd: repoRoot }));
51
+ const now = options.now || (() => Date.now());
52
+ const getStatus = options.getStatus || ((r, b) => pr.getPullRequestStatus(r, b));
53
+
54
+ const start = now();
55
+ const deadline = start + timeoutSeconds * 1000;
56
+ const graceDeadline = start + graceSeconds * 1000;
57
+
58
+ // eslint-disable-next-line no-constant-condition
59
+ while (true) {
60
+ const snap = getStatus(repoRoot, branch);
61
+ if (!snap) return { status: 'no-pr' };
62
+ const c = snap.checks;
63
+ // A failed or cancelled check is terminal and never a pass.
64
+ if (c.failed > 0 || c.cancelled > 0) return { status: 'checks-failed', pr: snap };
65
+
66
+ const mss = snap.mergeStateStatus;
67
+ // GitHub says this can't merge as-is and won't self-resolve within a finish run.
68
+ if (mss && BLOCKED_STATES.has(mss)) return { status: 'merge-blocked', pr: snap };
69
+
70
+ const settled = c.pending === 0;
71
+ const mergeable = !snap.isDraft && snap.mergeable === 'MERGEABLE';
72
+ const hasChecks = c.total > 0;
73
+ // Trust GitHub's CLEAN/HAS_HOOKS verdict; with no verdict, demand all-success
74
+ // (every check SUCCESS, zero `other`/ambiguous states).
75
+ const trusted = mss
76
+ ? MERGEABLE_STATES.has(mss)
77
+ : (c.other === 0 && c.success === c.total);
78
+
79
+ if (settled && mergeable && hasChecks && trusted) return { status: 'green', pr: snap };
80
+ if (settled && mergeable && !hasChecks && (mss ? MERGEABLE_STATES.has(mss) : true)) {
81
+ if (!requireChecks) return { status: 'green', pr: snap };
82
+ // No checks yet — give CI a grace window to create check runs before
83
+ // concluding the PR is genuinely check-less (avoids the promote->merge race).
84
+ if (now() >= graceDeadline) return { status: 'no-checks', pr: snap };
85
+ }
86
+ if (now() >= deadline) return { status: 'timeout', pr: snap };
87
+ sleep(pollSeconds);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Enforce the merge gate for `branch`. Throws (blocking the merge) unless the PR
93
+ * passes a clean AI review AND green CI AND GitHub reports it mergeable. Returns
94
+ * `{ prNumber }` on pass; the caller then proceeds to the real merge.
95
+ */
96
+ function runReviewGate({ repoRoot, branch, baseBranch, options = {} }, deps = {}) {
97
+ const openPullRequest = deps.openPullRequest || pr.openPullRequest;
98
+ const runPrReview = deps.runPrReview || prReview.runPrReview;
99
+ const markReady = deps.markPullRequestReady || pr.markPullRequestReady;
100
+ const evaluate = deps.evaluateReviewGate || prReview.evaluateReviewGate;
101
+ const waitGreen = deps.waitForGreenCi || waitForGreenCi;
102
+
103
+ const provider = options.reviewProvider || 'codex';
104
+ const requireChecks = !options.allowNoChecks;
105
+
106
+ // 1. Ensure a PR exists (push + open as draft so CI is deferred until the
107
+ // review passes and we explicitly promote).
108
+ const opened = openPullRequest({ repoRoot, branch, base: baseBranch, push: true });
109
+ const prNumber = opened.pr.number;
110
+ gateLog(`PR #${prNumber}: enforcing review + CI gate before merge`);
111
+
112
+ // 2. AI review — FAIL CLOSED. A provider error / timeout / unparseable output
113
+ // throws here; convert it to a block, never a silent pass.
114
+ let review;
115
+ try {
116
+ review = runPrReview({ target: repoRoot, pr: prNumber, provider, post: true });
117
+ } catch (err) {
118
+ throw new Error(
119
+ `review gate: AI review did not complete (${err.message}). Refusing to merge. `
120
+ + 'Fix the provider/auth issue or rerun with --skip-review-gate.',
121
+ );
122
+ }
123
+ const verdict = evaluate(review.findings);
124
+ if (!verdict.clean) {
125
+ const detail = verdict.blocking
126
+ .map((f) => ` - ${String(f.severity).toUpperCase()} ${f.path}:${f.line} ${f.message}`)
127
+ .join('\n');
128
+ throw new Error(
129
+ `review gate: ${verdict.blocking.length} blocking finding(s). Refusing to merge.\n${detail}\n`
130
+ + 'Fix the findings or rerun with --skip-review-gate.',
131
+ );
132
+ }
133
+ gateLog(
134
+ `PR #${prNumber}: review clean (${review.findings.length} non-blocking finding(s))`
135
+ + (review.reason === 'github-auth-unavailable' ? ' [not posted: github-auth-unavailable]' : ''),
136
+ );
137
+
138
+ // 3. Promote draft -> ready so required CI checks fire.
139
+ markReady(repoRoot, prNumber);
140
+
141
+ // 4. Wait for CI to settle green + GitHub to report mergeable. waitForGreenCi
142
+ // is fully fail-closed (failed/cancelled checks, blocked mergeStateStatus,
143
+ // timeout, and check-less PRs all return non-green statuses).
144
+ const ci = waitGreen(repoRoot, branch, {
145
+ timeoutSeconds: options.gateTimeoutSeconds,
146
+ pollSeconds: options.gatePollSeconds,
147
+ requireChecks,
148
+ });
149
+ if (ci.status === 'checks-failed') {
150
+ throw new Error(`review gate: CI checks failed/cancelled on PR #${prNumber}. Refusing to merge.`);
151
+ }
152
+ if (ci.status === 'merge-blocked') {
153
+ const mss = (ci.pr && ci.pr.mergeStateStatus) || 'BLOCKED';
154
+ throw new Error(
155
+ `review gate: GitHub reports mergeStateStatus=${mss} for PR #${prNumber} `
156
+ + '(not mergeable under branch protection). Refusing to merge.',
157
+ );
158
+ }
159
+ if (ci.status === 'no-checks') {
160
+ throw new Error(
161
+ `review gate: PR #${prNumber} has no CI checks configured. Refusing to merge an `
162
+ + 'unverified PR. Pass --allow-no-checks to override.',
163
+ );
164
+ }
165
+ if (ci.status === 'timeout') {
166
+ throw new Error(`review gate: timed out waiting for CI to go green on PR #${prNumber}.`);
167
+ }
168
+ if (ci.status !== 'green') {
169
+ throw new Error(`review gate: PR #${prNumber} not in a mergeable state (${ci.status}).`);
170
+ }
171
+
172
+ const mss = ci.pr && ci.pr.mergeStateStatus;
173
+ gateLog(`PR #${prNumber}: review clean + CI green${mss ? ` + mergeStateStatus=${mss}` : ''} — proceeding to merge`);
174
+ return { prNumber };
175
+ }
176
+
177
+ module.exports = {
178
+ runReviewGate,
179
+ waitForGreenCi,
180
+ DEFAULT_GATE_TIMEOUT_SECONDS,
181
+ MERGEABLE_STATES,
182
+ };