@bookedsolid/rea 0.11.0 → 0.13.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.
@@ -31,7 +31,27 @@ export interface BaseResolution {
31
31
  */
32
32
  ref: string;
33
33
  /** Where the ref came from — surfaces in audit records and stderr. */
34
- source: 'explicit' | 'upstream' | 'origin-head' | 'origin-main' | 'origin-master' | 'local-main' | 'local-master' | 'empty-tree';
34
+ source: 'explicit' | 'last-n-commits' | 'upstream' | 'origin-head' | 'origin-main' | 'origin-master' | 'local-main' | 'local-master' | 'empty-tree';
35
+ /**
36
+ * Set when `last-n-commits` was requested but `<headRef>~N` did not
37
+ * resolve at the requested depth (shallower-than-N clone, or N larger
38
+ * than the branch history). The resolver clamps to the deepest
39
+ * reachable commit (`<headRef>~K` for the largest `K <= N` that does
40
+ * resolve) and surfaces both numbers so the caller can emit a stderr
41
+ * warning ("requested N=50; clamped to K=12 (oldest reachable)").
42
+ * Present on both `last-n-commits` results (when clamped) and
43
+ * `empty-tree` results (when even `~1` was unreachable — orphan or
44
+ * single-commit branch).
45
+ */
46
+ lastNCommitsRequested?: number;
47
+ /**
48
+ * The N value actually used. When source is `last-n-commits`, this is
49
+ * the depth that resolved (equals `lastNCommitsRequested` on full
50
+ * resolution; smaller when clamped to a shallow clone). Surfaces in
51
+ * audit metadata so operators can grep their audit log for narrowed
52
+ * reviews.
53
+ */
54
+ lastNCommits?: number;
35
55
  }
36
56
  /**
37
57
  * Well-known empty-tree SHA: `git hash-object -t tree /dev/null`. Every git
@@ -48,6 +68,33 @@ export interface ResolveBaseOptions {
48
68
  * that's Codex's job (it'll error clearly if the ref is bad).
49
69
  */
50
70
  explicit?: string;
71
+ /**
72
+ * Resolve the base to `HEAD~N` instead of running the upstream ladder.
73
+ * `--last-n-commits N` flag and `policy.review.last_n_commits` map
74
+ * here. Ignored when `explicit` is set (explicit ref wins). When
75
+ * `<headRef>~N` does not resolve (shallower-than-N clone, or branch
76
+ * history shorter than N), the resolver CLAMPS to the deepest
77
+ * reachable commit (`<headRef>~K` for the largest `K <= N` that does
78
+ * resolve) — i.e. it diffs against the oldest commit on the branch.
79
+ * It does NOT fall back to the empty-tree sentinel; that would
80
+ * silently expand "the last N commits" to "the entire repository
81
+ * snapshot" on a normal repo with a short feature branch, flooding
82
+ * Codex with unchanged base-branch files. The resolver only emits
83
+ * `source: 'empty-tree'` when even `<headRef>~1` cannot be resolved
84
+ * (orphan branch, single-commit history); in that case
85
+ * `lastNCommitsRequested: N` is set so the caller can warn.
86
+ */
87
+ lastNCommits?: number;
88
+ /**
89
+ * The head ref the gate is reviewing. Defaults to literal "HEAD" — i.e.
90
+ * the local checkout's tip. When the gate is invoked via pre-push and
91
+ * the pushed ref is not the current branch (e.g.
92
+ * `git push origin some-other-branch`), the caller passes the pushed
93
+ * `<sha>` here so `last-n-commits` resolves `<sha>~N` rather than
94
+ * `HEAD~N`. Without this thread-through the review walks back N commits
95
+ * from the local checkout, which can be a different branch entirely.
96
+ */
97
+ headRef?: string;
51
98
  }
52
99
  /**
53
100
  * Resolve the base ref using the configured priority order. Never throws —
@@ -39,6 +39,127 @@ export function resolveBaseRef(git, options = {}) {
39
39
  if (options.explicit !== undefined && options.explicit.length > 0) {
40
40
  return { ref: options.explicit, source: 'explicit' };
41
41
  }
42
+ // 0. Last-N-commits override. Caller (CLI flag or policy key) requested
43
+ // diffing against `HEAD~N` directly. Resolves to a 40-char SHA via
44
+ // `git rev-parse HEAD~N`; on failure (shallower-than-N clone, or N
45
+ // larger than the branch's history depth) we fall back to the
46
+ // empty-tree sentinel and surface `lastNCommitsRequested` so the
47
+ // caller can emit a stderr warning. We deliberately resolve to a
48
+ // SHA rather than passing `HEAD~N` through to Codex — Codex shells
49
+ // out to `git diff` itself, but a SHA is unambiguous regardless of
50
+ // intermediate ref churn.
51
+ if (options.lastNCommits !== undefined && options.lastNCommits > 0) {
52
+ // Walk back N commits from the actual head being reviewed (defaults to
53
+ // local HEAD when the caller didn't thread a pushed ref through). Using
54
+ // a literal "HEAD" here would be wrong for `git push origin
55
+ // some-other-branch` invocations, where the local checkout's HEAD is a
56
+ // different branch entirely and the resulting diff would compare the
57
+ // wrong commits.
58
+ const headRef = options.headRef !== undefined && options.headRef.length > 0
59
+ ? options.headRef
60
+ : 'HEAD';
61
+ const requested = options.lastNCommits;
62
+ const tryDepth = (k) => git.tryRevParse(['--verify', '--quiet', `${headRef}~${k}^{commit}`]).trim();
63
+ // Fast path: requested depth resolves directly.
64
+ const direct = tryDepth(requested);
65
+ if (direct.length > 0) {
66
+ return {
67
+ ref: direct,
68
+ source: 'last-n-commits',
69
+ lastNCommits: requested,
70
+ };
71
+ }
72
+ // Clamp: `<headRef>~N` did not resolve. Two distinct causes need
73
+ // different handling — and the difference matters because the wrong
74
+ // choice silently inflates the review:
75
+ //
76
+ // (i) Branch is genuinely shorter than N (full clone). The
77
+ // deepest resolvable ancestor `<headRef>~K` IS the root
78
+ // commit (parent-less). Diffing against `<headRef>~K` would
79
+ // EXCLUDE the root commit's changes (`git diff base..head`
80
+ // excludes `base`), so we diff against EMPTY_TREE_SHA to
81
+ // include them. Report lastNCommits = K + 1 (every commit on
82
+ // the branch was reviewed).
83
+ //
84
+ // (ii) Repo is a shallow clone — `<headRef>~K` resolves but
85
+ // `<headRef>~K`'s parent simply isn't fetched locally. The
86
+ // commit isn't actually the root; older history exists on
87
+ // the remote. Diffing against EMPTY_TREE_SHA would balloon
88
+ // the review to "every tracked file in the checkout"
89
+ // (including all unchanged base-branch files), defeating
90
+ // the entire point of last-n-commits. So in the shallow
91
+ // case we diff against `<headRef>~K` itself, accepting that
92
+ // the K-th commit's changes are excluded — the operator
93
+ // chose a shallow clone and the deepest reachable commit is
94
+ // the best base we have. Report lastNCommits = K (the K
95
+ // ancestors we DID reach).
96
+ //
97
+ // `git rev-parse --is-shallow-repository` distinguishes the two
98
+ // cases (returns "true" / "false"). On unknown / errored output we
99
+ // assume FULL (the safer default for case (i): we'd rather review
100
+ // the root commit and risk a slightly larger diff than silently
101
+ // drop changes).
102
+ //
103
+ // Both Codex [P1] findings 2026-04-29 (initial empty-tree-on-clamp
104
+ // dropping root commit, then shallow-clone empty-tree expanding to
105
+ // full repo) drove this two-branch design.
106
+ const oneSha = tryDepth(1);
107
+ if (oneSha.length === 0) {
108
+ // Even `<headRef>~1` does not resolve — single-commit history
109
+ // (full clone with one commit) OR a shallow clone fetched at
110
+ // depth=1. In both cases the only locally-resolvable commit is
111
+ // headRef itself; there's no useful intermediate base. Fall back
112
+ // to empty-tree (matches case (i) of single commit review) and
113
+ // report lastNCommits = 1.
114
+ return {
115
+ ref: EMPTY_TREE_SHA,
116
+ source: 'empty-tree',
117
+ lastNCommits: 1,
118
+ lastNCommitsRequested: requested,
119
+ };
120
+ }
121
+ // Binary search for the deepest K < N where `<headRef>~K` resolves.
122
+ // Invariant: tryDepth(lo) resolves; tryDepth(hi+1) does not. We
123
+ // narrow until lo > hi; bestDepth carries the highest K seen.
124
+ let lo = 1;
125
+ let hi = requested - 1;
126
+ let bestDepth = 1;
127
+ while (lo <= hi) {
128
+ const mid = lo + Math.floor((hi - lo) / 2);
129
+ const sha = tryDepth(mid);
130
+ if (sha.length > 0) {
131
+ bestDepth = mid;
132
+ lo = mid + 1;
133
+ }
134
+ else {
135
+ hi = mid - 1;
136
+ }
137
+ }
138
+ const shallowFlag = git.tryRevParse(['--is-shallow-repository']).trim();
139
+ if (shallowFlag === 'true') {
140
+ // Case (ii): shallow clone. Diff against the deepest reachable
141
+ // ancestor SHA — its parent exists on the remote but isn't
142
+ // locally available, so empty-tree would over-review. Accept that
143
+ // the K-th commit's content is excluded; that's the cost of the
144
+ // shallow clone the operator chose.
145
+ const bestSha = tryDepth(bestDepth);
146
+ return {
147
+ ref: bestSha,
148
+ source: 'last-n-commits',
149
+ lastNCommits: bestDepth,
150
+ lastNCommitsRequested: requested,
151
+ };
152
+ }
153
+ // Case (i): full clone, branch genuinely shorter than N. The
154
+ // deepest resolvable ancestor IS the root. Diff against empty-tree
155
+ // to include the root commit's changes; reviewed count = K + 1.
156
+ return {
157
+ ref: EMPTY_TREE_SHA,
158
+ source: 'last-n-commits',
159
+ lastNCommits: bestDepth + 1,
160
+ lastNCommitsRequested: requested,
161
+ };
162
+ }
42
163
  // 1. Upstream of current branch. `@{upstream}` resolves to the configured
43
164
  // tracking ref (typically `refs/remotes/origin/<branch>`). Returns
44
165
  // empty on branches without an upstream — which is normal for a brand
@@ -51,6 +51,14 @@ export interface GitExecutor {
51
51
  headSha(): string;
52
52
  /** `git diff --name-only <base> <head>`. Returns path list (possibly empty). */
53
53
  diffNames(base: string, head: string): string[];
54
+ /**
55
+ * `git rev-list --count <base>..<head>`. Returns the integer commit count
56
+ * or `null` when the range cannot be resolved (unreachable base, shallow
57
+ * clone, etc.) — null lets the caller treat divergence-counting as
58
+ * best-effort without breaking the gate. Used by the auto-narrow probe
59
+ * (J / 0.13.0).
60
+ */
61
+ revListCount(base: string, head: string): number | null;
54
62
  }
55
63
  /**
56
64
  * Real git implementation using `spawnSync`. Each call is independent (no
@@ -95,6 +95,19 @@ export function createRealGitExecutor(cwd) {
95
95
  return [];
96
96
  return r.stdout.split(/\r?\n/).filter((l) => l.length > 0);
97
97
  },
98
+ revListCount(base, head) {
99
+ // `git rev-list --count base..head` — number of commits reachable
100
+ // from head but not base. Returns null on any failure so the caller
101
+ // can treat divergence-counting as best-effort (auto-narrow probe).
102
+ const r = run(['rev-list', '--count', `${base}..${head}`]);
103
+ if (r.code !== 0)
104
+ return null;
105
+ const trimmed = r.stdout.trim();
106
+ if (trimmed.length === 0)
107
+ return null;
108
+ const n = Number(trimmed);
109
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
110
+ },
98
111
  };
99
112
  }
100
113
  /**
@@ -63,6 +63,14 @@ export interface PushGateDeps {
63
63
  stderr: (line: string) => void;
64
64
  /** Override via `--base <ref>`. Absent → auto-resolve. */
65
65
  explicitBase?: string;
66
+ /**
67
+ * Override from the `--last-n-commits N` CLI flag. When set, the gate
68
+ * diffs against `HEAD~N` instead of running the upstream ladder. Wins
69
+ * over `policy.review.last_n_commits` but loses to `explicitBase`. When
70
+ * both `explicitBase` and this are set, `explicitBase` is used and a
71
+ * stderr warning is emitted noting the conflict.
72
+ */
73
+ lastNCommits?: number;
66
74
  /**
67
75
  * Pre-push refspecs from git's stdin. Empty when invoked outside a
68
76
  * pre-push context (manual `rea hook push-gate` from the CLI). When
@@ -24,7 +24,7 @@
24
24
  import path from 'node:path';
25
25
  import { appendAuditRecord } from '../../audit/append.js';
26
26
  import { Tier, InvocationStatus } from '../../policy/types.js';
27
- import { resolvePushGatePolicy, } from './policy.js';
27
+ import { resolvePushGatePolicy, PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK, } from './policy.js';
28
28
  import { readHalt } from './halt.js';
29
29
  import { resolveBaseRef } from './base.js';
30
30
  import { createRealGitExecutor, runCodexReview, CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, } from './codex-runner.js';
@@ -124,38 +124,109 @@ export async function runPushGate(deps) {
124
124
  summary: 'review.codex_required is false — push-gate skipped',
125
125
  };
126
126
  }
127
- // 3. REA_SKIP_PUSH_GATE — value-carrying waiver. HALT-wins ordering means
128
- // this is checked AFTER halt (step 1) and AFTER codex_required=false
127
+ // 3. Value-carrying skip waivers. HALT-wins ordering means these are
128
+ // checked AFTER halt (step 1) and AFTER codex_required=false
129
129
  // short-circuit (step 2). Both of those should hold anyway; this is
130
- // for the case where codex is required but the operator wants to
130
+ // for the case where codex IS required but the operator wants to
131
131
  // skip for a narrow, documented reason.
132
- const skipReason = (env.REA_SKIP_PUSH_GATE ?? '').trim();
133
- if (skipReason.length > 0) {
134
- stderr(`rea: REA_SKIP_PUSH_GATE=${skipReason} push-gate skipped (audited).\n`);
132
+ //
133
+ // Two equivalent env vars are honored — gate behavior is identical;
134
+ // only the audit metadata's `skip_var` differs so operators can grep
135
+ // their audit log to see which variant agents used:
136
+ //
137
+ // - REA_SKIP_PUSH_GATE — the original 0.11.0 var
138
+ // - REA_SKIP_CODEX_REVIEW — added in 0.12.0 to match the variant
139
+ // documented elsewhere in the codebase
140
+ // (gateway/reviewers, codex-probe). Prior
141
+ // to 0.12.0 this string only worked at
142
+ // the gateway tier; agents who set it on
143
+ // a `git push` got no skip and codex still
144
+ // ran. The mismatch surfaced during the
145
+ // helixir migration session 2026-04-26.
146
+ //
147
+ // Precedence on simultaneous set: REA_SKIP_PUSH_GATE wins (it was the
148
+ // canonical name) and REA_SKIP_CODEX_REVIEW is logged but not used.
149
+ // Either var alone with non-empty reason short-circuits.
150
+ const skipPush = (env.REA_SKIP_PUSH_GATE ?? '').trim();
151
+ const skipCodex = (env.REA_SKIP_CODEX_REVIEW ?? '').trim();
152
+ if (skipPush.length > 0 || skipCodex.length > 0) {
153
+ const skipVar = skipPush.length > 0 ? 'REA_SKIP_PUSH_GATE' : 'REA_SKIP_CODEX_REVIEW';
154
+ const skipReason = skipVar === 'REA_SKIP_PUSH_GATE' ? skipPush : skipCodex;
155
+ stderr(`rea: ${skipVar}=${skipReason} — push-gate skipped (audited).\n`);
135
156
  await safeAppend(appendAuditFn, deps.baseDir, EVT_SKIPPED, {
136
157
  reason: skipReason,
158
+ skip_var: skipVar,
137
159
  });
138
160
  return {
139
161
  status: 'skipped',
140
162
  exitCode: 0,
141
- summary: `REA_SKIP_PUSH_GATE waiver: ${skipReason}`,
163
+ summary: `${skipVar} waiver: ${skipReason}`,
142
164
  };
143
165
  }
144
166
  // 4. Resolve (base_ref, head_sha) for the actual review.
145
167
  //
146
- // When pre-push stdin yielded at least one refspec (`git push` path),
147
- // diff against the first NON-DELETION refspec's (remote_sha..local_sha).
148
- // This matches what git itself is about to push — critical when the
149
- // operator uses `git push origin HEAD:release/1.0` and the branch's
150
- // tracking ref is a different branch entirely (the 0.10.x gate
151
- // silently reviewed against the wrong base in that case).
168
+ // Precedence (highest first):
169
+ // a) `--base <ref>` CLI flag (deps.explicitBase) — explicit ref the
170
+ // operator named; we trust it.
171
+ // b) `--last-n-commits N` CLI flag (deps.lastNCommits) diff
172
+ // against HEAD~N. Wins over the policy key.
173
+ // c) `policy.review.last_n_commits` same effect as (b), but
174
+ // configured in `.rea/policy.yaml`. Persistent narrow-window.
175
+ // d) Active refspec from pre-push stdin — what git is about to
176
+ // push. Critical for `git push origin HEAD:release/1.0`.
177
+ // e) Upstream → origin/HEAD → main/master ladder.
152
178
  //
153
- // When stdin was empty (manual invocation, test), fall back to the
154
- // upstream origin/HEAD → main/master ladder.
179
+ // When (a) collides with (b) or (c), (a) wins and we warn — explicit
180
+ // ref beats relative count.
181
+ const policyLastN = policy.last_n_commits;
182
+ const explicitBaseSet = deps.explicitBase !== undefined && deps.explicitBase.length > 0;
183
+ const lastNFromFlag = deps.lastNCommits;
184
+ const effectiveLastN = lastNFromFlag !== undefined ? lastNFromFlag : policyLastN;
185
+ if (explicitBaseSet && effectiveLastN !== undefined) {
186
+ const source = lastNFromFlag !== undefined ? '--last-n-commits' : 'policy.review.last_n_commits';
187
+ stderr(`rea: --base ${deps.explicitBase} overrides ${source}=${effectiveLastN}; using explicit ref.\n`);
188
+ }
155
189
  const activeRefspec = (deps.refspecs ?? []).find((r) => r.localSha !== NULL_SHA && r.localSha.length > 0);
156
190
  let base;
157
191
  let headSha;
158
- if (activeRefspec !== undefined && (deps.explicitBase === undefined || deps.explicitBase.length === 0)) {
192
+ // Tracks whether the base was resolved from the active refspec's
193
+ // remoteSha — i.e. "the tip of this branch as the remote currently sees
194
+ // it". Only that case represents commits Codex has already reviewed in
195
+ // a prior push; auto-narrow is only safe there (J / 0.13.0). Initial
196
+ // pushes against `origin/main`-shaped bases must NOT auto-narrow,
197
+ // because earlier commits on the branch may never have been reviewed.
198
+ let baseFromPushedRemoteTip = false;
199
+ if (explicitBaseSet) {
200
+ // (a) explicit base wins absolutely.
201
+ base = resolveBaseRef(git, { explicit: deps.explicitBase });
202
+ headSha = activeRefspec !== undefined ? activeRefspec.localSha : git.headSha();
203
+ }
204
+ else if (effectiveLastN !== undefined && effectiveLastN > 0) {
205
+ // (b) / (c) last-n-commits. Resolves to a SHA via `git rev-parse
206
+ // <headRef>~N`. Compute headSha FIRST so the resolver walks back N
207
+ // commits from the pushed ref rather than the local HEAD — critical
208
+ // for `git push origin some-other-branch` where the active refspec's
209
+ // localSha is a different branch entirely from the checkout's HEAD.
210
+ headSha = activeRefspec !== undefined ? activeRefspec.localSha : git.headSha();
211
+ base = resolveBaseRef(git, {
212
+ lastNCommits: effectiveLastN,
213
+ headRef: headSha,
214
+ });
215
+ if (base.lastNCommitsRequested !== undefined &&
216
+ base.lastNCommits !== undefined &&
217
+ base.lastNCommits < base.lastNCommitsRequested) {
218
+ // Clamp warning: the resolver couldn't go back N commits, so it
219
+ // clamped to the entire branch history (diff vs empty-tree, K+1
220
+ // commits reviewed) — `base.lastNCommits` carries the actual K+1.
221
+ // This warning fires both when source is 'last-n-commits' (clamped
222
+ // mid-branch, root commit included via empty-tree) and when source
223
+ // is 'empty-tree' (single-commit branch). The user-facing message
224
+ // is identical: we wanted N, got K, here's what we reviewed.
225
+ stderr(`rea: ${headSha.slice(0, 12)}~${base.lastNCommitsRequested} not reachable; reviewing all ${base.lastNCommits} commits on this branch instead.\n`);
226
+ }
227
+ }
228
+ else if (activeRefspec !== undefined) {
229
+ // (d) refspec-aware base — use what git is about to push.
159
230
  headSha = activeRefspec.localSha;
160
231
  if (activeRefspec.remoteSha === NULL_SHA || activeRefspec.remoteSha.length === 0) {
161
232
  // New remote ref — no existing commits to diff against. Fall back to
@@ -165,14 +236,14 @@ export async function runPushGate(deps) {
165
236
  }
166
237
  else {
167
238
  base = { ref: activeRefspec.remoteSha, source: 'explicit' };
239
+ // ONLY this path produces a base that represents the previously-
240
+ // reviewed remote tip of THIS branch. Auto-narrow is safe here.
241
+ baseFromPushedRemoteTip = true;
168
242
  }
169
243
  }
170
244
  else {
171
- base = resolveBaseRef(git, {
172
- ...(deps.explicitBase !== undefined && deps.explicitBase.length > 0
173
- ? { explicit: deps.explicitBase }
174
- : {}),
175
- });
245
+ // (e) upstream ladder.
246
+ base = resolveBaseRef(git);
176
247
  headSha = git.headSha();
177
248
  }
178
249
  if (headSha.length === 0) {
@@ -180,6 +251,67 @@ export async function runPushGate(deps) {
180
251
  await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, { kind: 'head-sha-missing' });
181
252
  return { status: 'error', exitCode: 2, summary: 'head-sha-missing' };
182
253
  }
254
+ // 4b. Auto-narrow probe (J / 0.13.0). When the resolved base is far
255
+ // behind HEAD AND the operator has not already pinned an explicit
256
+ // window, scope the review down to the recent commits and warn.
257
+ //
258
+ // CRITICAL safety rule: auto-narrow ONLY fires when the base was
259
+ // resolved from the active refspec's remoteSha — i.e. "the tip of
260
+ // this branch as the remote currently sees it". Only that case
261
+ // represents commits Codex already reviewed in a prior push, so
262
+ // skipping older commits on the branch is safe.
263
+ //
264
+ // For initial pushes (or any base resolved via the upstream /
265
+ // origin-head / origin-main ladder), the diff target is a trunk-
266
+ // like ref where commits earlier in the branch may never have been
267
+ // reviewed. Auto-narrowing past them would silently bypass the
268
+ // advertised pre-push review for a hook/policy/security change
269
+ // made early in the branch (codex-review 0.13.0 [P1]).
270
+ //
271
+ // Suppression rules (any one prevents auto-narrow from firing):
272
+ //
273
+ // - `--base` flag set (operator picked an explicit ref)
274
+ // - `--last-n-commits` flag set (operator picked an explicit
275
+ // window)
276
+ // - `policy.review.last_n_commits` set (persistent narrow window)
277
+ // - `policy.review.auto_narrow_threshold: 0` (disabled)
278
+ // - resolver already produced a `last-n-commits` source (we got
279
+ // here via the policyLastN branch above)
280
+ // - resolver fell back to `empty-tree` (single-commit branch /
281
+ // orphan; no usable upstream — narrowing would be silly)
282
+ // - base was NOT derived from the active refspec's remoteSha
283
+ // (initial push, no upstream, fallback to origin/main, etc.)
284
+ //
285
+ // The probe uses `git rev-list --count base..HEAD` rather than
286
+ // `diffNames().length` — line-counting commits is far cheaper than
287
+ // listing every changed path on a 50+ commit branch. A null result
288
+ // (range unresolvable) suppresses auto-narrow entirely; we'd
289
+ // rather err on the side of reviewing more than tripping a
290
+ // half-baked auto-narrow on a degenerate ref.
291
+ let autoNarrowed = false;
292
+ let originalCommitCount = null;
293
+ const autoNarrowEligible = !explicitBaseSet &&
294
+ lastNFromFlag === undefined &&
295
+ policyLastN === undefined &&
296
+ policy.auto_narrow_threshold > 0 &&
297
+ base.source !== 'last-n-commits' &&
298
+ base.source !== 'empty-tree' &&
299
+ baseFromPushedRemoteTip;
300
+ if (autoNarrowEligible) {
301
+ originalCommitCount = git.revListCount(base.ref, headSha);
302
+ if (originalCommitCount !== null &&
303
+ originalCommitCount > policy.auto_narrow_threshold) {
304
+ const fallbackWindow = PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK;
305
+ const narrowed = resolveBaseRef(git, {
306
+ lastNCommits: fallbackWindow,
307
+ headRef: headSha,
308
+ });
309
+ stderr(`rea: auto-narrow — ${originalCommitCount} commits behind ${base.ref} (threshold ${policy.auto_narrow_threshold}); reviewing the last ${fallbackWindow} commits instead.\n` +
310
+ ` Override: pass \`--last-n-commits N\` or \`--base <ref>\`, set \`review.last_n_commits\` in .rea/policy.yaml, or disable with \`review.auto_narrow_threshold: 0\`.\n`);
311
+ base = narrowed;
312
+ autoNarrowed = true;
313
+ }
314
+ }
183
315
  // 5. Empty-diff short-circuit. An initial push against the empty-tree
184
316
  // sentinel ALWAYS has a non-empty diff (HEAD vs empty tree); this
185
317
  // short-circuit only fires when the feature branch really is a
@@ -190,6 +322,10 @@ export async function runPushGate(deps) {
190
322
  base_ref: base.ref,
191
323
  base_source: base.source,
192
324
  head_sha: headSha,
325
+ last_n_commits: base.lastNCommits,
326
+ last_n_commits_requested: base.lastNCommitsRequested,
327
+ auto_narrowed: autoNarrowed ? true : undefined,
328
+ original_commit_count: originalCommitCount !== null ? originalCommitCount : undefined,
193
329
  });
194
330
  return {
195
331
  status: 'empty-diff',
@@ -238,6 +374,10 @@ export async function runPushGate(deps) {
238
374
  duration_seconds: codexResult.durationSeconds,
239
375
  event_count: codexResult.eventCount,
240
376
  concerns_override: summary.verdict === 'concerns' && isConcernsOverrideSet(env) ? true : undefined,
377
+ last_n_commits: base.lastNCommits,
378
+ last_n_commits_requested: base.lastNCommitsRequested,
379
+ auto_narrowed: autoNarrowed ? true : undefined,
380
+ original_commit_count: originalCommitCount !== null ? originalCommitCount : undefined,
241
381
  });
242
382
  if (blocked) {
243
383
  return {
@@ -9,9 +9,16 @@
9
9
  * skip". This module is pure policy.
10
10
  *
11
11
  * Defaults (when a field is absent or `review:` is missing entirely):
12
- * - `codex_required` → `true` (safe-by-default: run Codex)
13
- * - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
14
- * - `timeout_ms` → 600_000 (10 minutes)
12
+ * - `codex_required` → `true` (safe-by-default: run Codex)
13
+ * - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
14
+ * - `timeout_ms` → 1_800_000 (30 minutes — raised in 0.12.0 from the
15
+ * previous 10-minute default after the
16
+ * helixir migration session 2026-04-26
17
+ * showed realistic feature-branch
18
+ * reviews routinely exceeded 10 minutes
19
+ * on large diffs. Operators who pin
20
+ * `timeout_ms:` in policy.yaml are
21
+ * unaffected by this change.)
15
22
  *
16
23
  * A missing `.rea/policy.yaml` is treated as "defaults apply" — the
17
24
  * operator may not have run `rea init` yet, and the gate's behavior
@@ -22,12 +29,40 @@ export interface ResolvedReviewPolicy {
22
29
  codex_required: boolean;
23
30
  concerns_blocks: boolean;
24
31
  timeout_ms: number;
32
+ /**
33
+ * When set, the gate resolves the diff base to `HEAD~N` (see Fix D in
34
+ * 0.12.0). The CLI flag `--last-n-commits N` overrides this; the
35
+ * policy key surfaces here as a runtime knob with the same effect.
36
+ * `undefined` when unset (default-untouched behavior).
37
+ */
38
+ last_n_commits: number | undefined;
39
+ /**
40
+ * Auto-narrow threshold (J / 0.13.0). When the resolved diff base is more
41
+ * than N commits behind HEAD AND no explicit narrowing was pinned, the
42
+ * gate scopes to `PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK` (10) and
43
+ * emits a stderr warning. Defaults to 30 when unset; 0 disables.
44
+ */
45
+ auto_narrow_threshold: number;
25
46
  /** `true` when `.rea/policy.yaml` was absent; defaults apply. */
26
47
  policyMissing: boolean;
27
48
  }
28
- export declare const PUSH_GATE_DEFAULT_TIMEOUT_MS = 600000;
49
+ export declare const PUSH_GATE_DEFAULT_TIMEOUT_MS = 1800000;
29
50
  export declare const PUSH_GATE_DEFAULT_CODEX_REQUIRED = true;
30
51
  export declare const PUSH_GATE_DEFAULT_CONCERNS_BLOCKS = true;
52
+ /**
53
+ * Default auto-narrow threshold (J / 0.13.0). When the divergence between
54
+ * the resolved base and HEAD exceeds this count and the operator has not
55
+ * pinned an explicit window, the gate auto-narrows to
56
+ * `PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK` commits.
57
+ */
58
+ export declare const PUSH_GATE_DEFAULT_AUTO_NARROW_THRESHOLD = 30;
59
+ /**
60
+ * Window the gate auto-narrows to when the threshold trips and the operator
61
+ * has not pinned `policy.review.last_n_commits`. Conservative — small
62
+ * enough that Codex review stays fast, large enough to capture meaningful
63
+ * recent work.
64
+ */
65
+ export declare const PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK = 10;
31
66
  /**
32
67
  * Resolve the push-gate policy for `baseDir`. Never throws — a malformed
33
68
  * policy file surfaces as a typed error via the underlying zod validator,
@@ -9,9 +9,16 @@
9
9
  * skip". This module is pure policy.
10
10
  *
11
11
  * Defaults (when a field is absent or `review:` is missing entirely):
12
- * - `codex_required` → `true` (safe-by-default: run Codex)
13
- * - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
14
- * - `timeout_ms` → 600_000 (10 minutes)
12
+ * - `codex_required` → `true` (safe-by-default: run Codex)
13
+ * - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
14
+ * - `timeout_ms` → 1_800_000 (30 minutes — raised in 0.12.0 from the
15
+ * previous 10-minute default after the
16
+ * helixir migration session 2026-04-26
17
+ * showed realistic feature-branch
18
+ * reviews routinely exceeded 10 minutes
19
+ * on large diffs. Operators who pin
20
+ * `timeout_ms:` in policy.yaml are
21
+ * unaffected by this change.)
15
22
  *
16
23
  * A missing `.rea/policy.yaml` is treated as "defaults apply" — the
17
24
  * operator may not have run `rea init` yet, and the gate's behavior
@@ -21,9 +28,23 @@
21
28
  import fs from 'node:fs';
22
29
  import path from 'node:path';
23
30
  import { loadPolicyAsync } from '../../policy/loader.js';
24
- export const PUSH_GATE_DEFAULT_TIMEOUT_MS = 600_000;
31
+ export const PUSH_GATE_DEFAULT_TIMEOUT_MS = 1_800_000;
25
32
  export const PUSH_GATE_DEFAULT_CODEX_REQUIRED = true;
26
33
  export const PUSH_GATE_DEFAULT_CONCERNS_BLOCKS = true;
34
+ /**
35
+ * Default auto-narrow threshold (J / 0.13.0). When the divergence between
36
+ * the resolved base and HEAD exceeds this count and the operator has not
37
+ * pinned an explicit window, the gate auto-narrows to
38
+ * `PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK` commits.
39
+ */
40
+ export const PUSH_GATE_DEFAULT_AUTO_NARROW_THRESHOLD = 30;
41
+ /**
42
+ * Window the gate auto-narrows to when the threshold trips and the operator
43
+ * has not pinned `policy.review.last_n_commits`. Conservative — small
44
+ * enough that Codex review stays fast, large enough to capture meaningful
45
+ * recent work.
46
+ */
47
+ export const PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK = 10;
27
48
  /**
28
49
  * Resolve the push-gate policy for `baseDir`. Never throws — a malformed
29
50
  * policy file surfaces as a typed error via the underlying zod validator,
@@ -41,6 +62,8 @@ export async function resolvePushGatePolicy(baseDir) {
41
62
  codex_required: PUSH_GATE_DEFAULT_CODEX_REQUIRED,
42
63
  concerns_blocks: PUSH_GATE_DEFAULT_CONCERNS_BLOCKS,
43
64
  timeout_ms: PUSH_GATE_DEFAULT_TIMEOUT_MS,
65
+ last_n_commits: undefined,
66
+ auto_narrow_threshold: PUSH_GATE_DEFAULT_AUTO_NARROW_THRESHOLD,
44
67
  policyMissing: true,
45
68
  };
46
69
  }
@@ -50,6 +73,8 @@ export async function resolvePushGatePolicy(baseDir) {
50
73
  codex_required: review.codex_required ?? PUSH_GATE_DEFAULT_CODEX_REQUIRED,
51
74
  concerns_blocks: review.concerns_blocks ?? PUSH_GATE_DEFAULT_CONCERNS_BLOCKS,
52
75
  timeout_ms: review.timeout_ms ?? PUSH_GATE_DEFAULT_TIMEOUT_MS,
76
+ last_n_commits: review.last_n_commits,
77
+ auto_narrow_threshold: review.auto_narrow_threshold ?? PUSH_GATE_DEFAULT_AUTO_NARROW_THRESHOLD,
53
78
  policyMissing: false,
54
79
  };
55
80
  }