@imdeadpool/guardex 7.0.43 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +26 -0
  2. package/package.json +2 -1
  3. package/skills/gx-act/SKILL.md +82 -0
  4. package/src/agents/inspect.js +17 -4
  5. package/src/agents/launch.js +10 -1
  6. package/src/agents/status.js +9 -6
  7. package/src/budget/index.js +2 -1
  8. package/src/cli/args.js +52 -2
  9. package/src/cli/commands/agents.js +364 -0
  10. package/src/cli/commands/bootstrap.js +92 -0
  11. package/src/cli/commands/branch.js +127 -0
  12. package/src/cli/commands/claude.js +674 -0
  13. package/src/cli/commands/doctor.js +268 -0
  14. package/src/cli/commands/finish.js +26 -0
  15. package/src/cli/commands/mcp.js +122 -0
  16. package/src/cli/commands/misc.js +304 -0
  17. package/src/cli/commands/pr.js +439 -0
  18. package/src/cli/commands/prompt.js +92 -0
  19. package/src/cli/commands/release.js +305 -0
  20. package/src/cli/commands/report.js +244 -0
  21. package/src/cli/commands/review.js +32 -0
  22. package/src/cli/commands/setup.js +242 -0
  23. package/src/cli/commands/status.js +338 -0
  24. package/src/cli/commands/watch.js +234 -0
  25. package/src/cli/main.js +68 -3726
  26. package/src/cli/shared/repo-env.js +161 -0
  27. package/src/cli/shared/sandbox.js +417 -0
  28. package/src/cli/shared/scaffolding.js +535 -0
  29. package/src/cli/shared/toolchain-shims.js +420 -0
  30. package/src/context.js +229 -11
  31. package/src/core/runtime.js +6 -1
  32. package/src/doctor/index.js +42 -13
  33. package/src/finish/index.js +147 -5
  34. package/src/finish/preflight.js +177 -0
  35. package/src/finish/review-gate.js +182 -0
  36. package/src/git/index.js +446 -4
  37. package/src/hooks/index.js +0 -64
  38. package/src/mcp/collect.js +370 -0
  39. package/src/mcp/server.js +157 -0
  40. package/src/output/index.js +67 -1
  41. package/src/pr-review.js +23 -0
  42. package/src/pr.js +381 -0
  43. package/src/sandbox/index.js +13 -2
  44. package/src/scaffold/agent-worktree-prep.js +213 -0
  45. package/src/scaffold/index.js +108 -10
  46. package/src/speckit/index.js +226 -0
  47. package/src/terminal/index.js +1 -76
  48. package/src/terminal/tmux.js +0 -1
  49. package/src/toolchain/index.js +20 -0
  50. package/templates/AGENTS.monorepo-apps.md +26 -0
  51. package/templates/AGENTS.multiagent-safety.md +61 -347
  52. package/templates/AGENTS.multiagent-safety.min.md +11 -0
  53. package/templates/codex/skills/gx-act/SKILL.md +82 -0
  54. package/templates/githooks/pre-commit +22 -19
  55. package/templates/scripts/agent-branch-finish.sh +8 -30
  56. package/templates/scripts/agent-branch-merge.sh +4 -1
  57. package/templates/scripts/agent-branch-start.sh +88 -3
  58. package/templates/scripts/agent-preflight.sh +31 -5
  59. package/templates/scripts/agent-worktree-prune.sh +1 -1
  60. package/templates/scripts/codex-agent.sh +0 -91
  61. package/src/agents/detect.js +0 -160
  62. package/src/cockpit/keybindings.js +0 -224
  63. package/src/cockpit/layout.js +0 -224
@@ -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,
@@ -27,7 +29,51 @@ const {
27
29
  parseSyncArgs,
28
30
  } = require('../cli/args');
29
31
  const submoduleModule = require('../submodule');
30
-
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
+ */
31
77
  function claimLocksForAutoCommit(repoRoot, worktreePath, branch) {
32
78
  const changedFiles = uniquePreserveOrder([
33
79
  ...gitOutputLines(worktreePath, ['diff', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
@@ -84,6 +130,17 @@ function claimLocksForAutoCommit(repoRoot, worktreePath, branch) {
84
130
  }
85
131
  }
86
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
+ */
87
144
  function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) {
88
145
  const hasChanges = worktreeHasLocalChanges(worktreePath);
89
146
  if (!hasChanges) {
@@ -134,6 +191,15 @@ function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) {
134
191
  return { changed: true, committed: true, message: commitMessage };
135
192
  }
136
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
+ */
137
203
  function cleanup(rawArgs) {
138
204
  const activeCwd = process.cwd();
139
205
  const options = parseCleanupArgs(rawArgs);
@@ -204,6 +270,14 @@ function cleanup(rawArgs) {
204
270
  process.exitCode = 0;
205
271
  }
206
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
+ */
207
281
  function merge(rawArgs) {
208
282
  const options = parseMergeArgs(rawArgs);
209
283
  const repoRoot = resolveRepoRoot(options.target);
@@ -239,6 +313,17 @@ function merge(rawArgs) {
239
313
  process.exitCode = 0;
240
314
  }
241
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
+ */
242
327
  function finish(rawArgs, defaults = {}) {
243
328
  const activeCwd = process.cwd();
244
329
  const options = parseFinishArgs(rawArgs, defaults);
@@ -286,12 +371,19 @@ function finish(rawArgs, defaults = {}) {
286
371
  let succeeded = 0;
287
372
  let failed = 0;
288
373
  let autoCommitted = 0;
374
+ const terse = isTerseMode();
289
375
 
290
376
  for (const candidate of candidates) {
291
377
  const { branch, baseBranch, worktreePath } = candidate;
292
- console.log(
293
- `[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}...`,
294
- );
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
+ }
295
387
 
296
388
  try {
297
389
  let commitState = { changed: false, committed: false };
@@ -299,7 +391,17 @@ function finish(rawArgs, defaults = {}) {
299
391
  commitState = autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options);
300
392
  }
301
393
 
302
- 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) {
303
405
  autoCommitted += 1;
304
406
  console.log(`[${TOOL_NAME}] Auto-committed '${branch}' before finish.`);
305
407
  } else if (commitState.changed && commitState.dryRun) {
@@ -358,6 +460,35 @@ function finish(rawArgs, defaults = {}) {
358
460
  continue;
359
461
  }
360
462
 
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
+
361
492
  const finishResult = runPackageAsset('branchFinish', finishArgs, {
362
493
  cwd: repoRoot,
363
494
  stdio: 'pipe',
@@ -394,6 +525,17 @@ function finish(rawArgs, defaults = {}) {
394
525
  process.exitCode = 0;
395
526
  }
396
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
+ */
397
539
  function sync(rawArgs) {
398
540
  const options = parseSyncArgs(rawArgs);
399
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
+ };