@bookedsolid/rea 0.12.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.
package/.husky/commit-msg CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/bin/sh
2
+ # rea:commit-msg v1
2
3
  # .husky/commit-msg — optionally BLOCKS commits that contain AI attribution
3
4
  #
4
5
  # OPT-IN: Only enforces when .rea/policy.yaml contains:
@@ -40,13 +41,34 @@ fi
40
41
  # Look for block_ai_attribution: true in .rea/policy.yaml
41
42
  # If not found or not true, exit 0 (normal commit behavior)
42
43
 
44
+ # Helper: chain extension fragments under .husky/commit-msg.d/ in lex
45
+ # order. Defined once and called from every exit path so consumers get
46
+ # consistent behavior regardless of whether attribution blocking ran.
47
+ chain_commit_msg_fragments() {
48
+ REA_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
49
+ ext_dir="${REA_ROOT}/.husky/commit-msg.d"
50
+ if [ -d "$ext_dir" ]; then
51
+ for frag in "$ext_dir"/*; do
52
+ [ -e "$frag" ] || continue
53
+ [ -f "$frag" ] || continue
54
+ [ -x "$frag" ] || continue
55
+ "$frag" "$COMMIT_MSG_FILE"
56
+ done
57
+ fi
58
+ }
59
+
43
60
  POLICY_FILE=".rea/policy.yaml"
44
61
  if [ ! -f "$POLICY_FILE" ]; then
62
+ chain_commit_msg_fragments
45
63
  exit 0
46
64
  fi
47
65
 
48
66
  # Simple grep — no YAML parser dependency needed for a boolean flag
49
67
  if ! grep -qE '^block_ai_attribution:[[:space:]]*true' "$POLICY_FILE" 2>/dev/null; then
68
+ # Attribution blocking is disabled — still chain extension fragments
69
+ # (consumers may want commitlint/conventional-commits checks regardless
70
+ # of rea's attribution stance).
71
+ chain_commit_msg_fragments
50
72
  exit 0
51
73
  fi
52
74
 
@@ -127,4 +149,14 @@ fi
127
149
  # Normalize trailing newlines (cosmetic, non-fatal)
128
150
  perl -i -0777 -pe 's/\n+$/\n/' "$COMMIT_MSG_FILE" 2>/dev/null || true
129
151
 
152
+ # ── Extension-hook chaining ────────────────────────────────────────────────────
153
+ # Source every executable file under `.husky/commit-msg.d/` in lexical order.
154
+ # Missing directory = no-op (backward compatible). Each fragment receives the
155
+ # commit-msg file path as $1. Non-zero exit fails the commit (set -e above).
156
+ #
157
+ # Fragments run AFTER rea's attribution check so HALT/governance enforcement
158
+ # happens first; consumers can layer on commitlint, conventional-commits
159
+ # linters, or branch-policy checks without losing rea coverage.
160
+ chain_commit_msg_fragments
161
+
130
162
  exit 0
package/.husky/pre-push CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/bin/sh
2
- # rea:husky-pre-push-gate v3
3
- # rea:gate-body-v3
2
+ # rea:husky-pre-push-gate v4
3
+ # rea:gate-body-v4
4
4
  #
5
5
  # Husky pre-push hook installed by `rea init` / `rea upgrade`. Do NOT
6
6
  # edit by hand — the file is refreshed on every rea upgrade.
@@ -63,4 +63,48 @@ else
63
63
  exit 2
64
64
  fi
65
65
 
66
- exec "$@"
66
+ # Run the rea push-gate FIRST. We capture its exit and explicitly propagate
67
+ # instead of `exec`-ing — extension fragments must only run after rea's own
68
+ # governance work succeeds. The fragments are user code; surfacing them
69
+ # AFTER rea's body (HALT check, Codex review, audit write) preserves the
70
+ # governance contract while letting consumers chain their own checks (e.g.
71
+ # commitlint, branch-policy linters) without losing rea coverage.
72
+ "$@"
73
+ rea_status=$?
74
+ if [ "$rea_status" -ne 0 ]; then
75
+ exit "$rea_status"
76
+ fi
77
+
78
+ # Extension-hook chaining: source every executable file under
79
+ # `.husky/pre-push.d/` in lexical order. Missing directory = no-op
80
+ # (backward compatible). Each fragment receives the original positional
81
+ # args from git's `<remote-name> <remote-url>` invocation. Non-zero exit
82
+ # from any fragment fails the push; this matches husky's normal hook
83
+ # chaining semantics.
84
+ #
85
+ # The git pre-push contract delivers refspecs on stdin. Once rea's body has
86
+ # consumed it (`exec rea hook push-gate "$@"` reads stdin during `runPushGate`),
87
+ # subsequent fragments cannot replay it — that's by design: agents that need
88
+ # refspec data should run before rea, not after. Fragments that need ambient
89
+ # repo state can call `git rev-parse` themselves.
90
+ ext_dir="${REA_ROOT}/.husky/pre-push.d"
91
+ if [ -d "$ext_dir" ]; then
92
+ # Collect fragments deterministically. POSIX sort orders the same way on
93
+ # macOS (BSD sort) and Linux (GNU sort) for ASCII filenames; consumers who
94
+ # name their fragments `10-foo` / `20-bar` get predictable ordering.
95
+ for frag in "$ext_dir"/*; do
96
+ # Glob expands to itself when no matches — guard with -e.
97
+ [ -e "$frag" ] || continue
98
+ # Skip non-files (directories, sockets) and non-executables. The
99
+ # executable bit is the consumer's opt-in: dropping a non-executable
100
+ # README into the dir does not become a hook.
101
+ [ -f "$frag" ] || continue
102
+ [ -x "$frag" ] || continue
103
+ # Each fragment runs in its own subprocess with the original git args.
104
+ # Failures propagate via `set -eu` above (loop body inherits, so any
105
+ # non-zero exit blocks the push immediately).
106
+ "$frag" "$@"
107
+ done
108
+ fi
109
+
110
+ exit 0
package/README.md CHANGED
@@ -348,6 +348,8 @@ review:
348
348
  # 600000 in 0.12.0 — see CHANGELOG)
349
349
  last_n_commits: 10 # OPTIONAL — narrow review to the last N commits
350
350
  # (diff vs HEAD~N). Defaults unset.
351
+ auto_narrow_threshold: 30 # OPTIONAL — auto-narrow when commit count
352
+ # behind base > N (0 disables, default 30)
351
353
  ```
352
354
 
353
355
  | Key | Type | Default | Purpose |
@@ -356,6 +358,84 @@ review:
356
358
  | `review.concerns_blocks` | boolean | `true` | When `true`, `[P2]` verdicts return exit 2. Flip to `false` for a looser posture where only `[P1]` halts the push. |
357
359
  | `review.timeout_ms` | number | `1800000` | Hard cap on the `codex exec review` subprocess in milliseconds. Exceeding it kills the subprocess and returns exit 2 with a `timeout` kind. Positive integer; zero/negative is rejected at load. **Raised from 600000 (10 min) to 1800000 (30 min) in 0.12.0** after operator data showed realistic feature-branch reviews routinely exceeded 10 minutes; pin `timeout_ms: 600000` explicitly to retain the old default. |
358
360
  | `review.last_n_commits` | number | unset | When set, the gate diffs against `HEAD~N` instead of running the upstream → origin/HEAD → main/master ladder. Useful when a feature branch has accumulated many commits and the full base diff overwhelms the reviewer. Positive integer. CLI `--last-n-commits N` overrides this; `--base <ref>` overrides both. When `HEAD~N` is unreachable the resolver clamps based on whether the repo is a shallow clone: **(full clone, branch shorter than N)** clamps to the empty-tree sentinel so the root commit's changes are included (reviewing all `K+1` commits on the branch); **(shallow clone)** clamps to `HEAD~K` SHA — the deepest locally resolvable ancestor — so the review does not balloon to every tracked file (older history exists on the remote but isn't fetched). A stderr warning surfaces the requested-vs-clamped numbers in both cases. Audit metadata records `base_source: 'last-n-commits'`, `last_n_commits: <count actually reviewed>`, and `last_n_commits_requested: N` (only present when clamped). |
361
+ | `review.auto_narrow_threshold` | number | `30` | Added in **0.13.0**. When the resolved diff base is more than N commits behind HEAD AND the base was resolved from the active refspec's `remoteSha` (i.e. previously-pushed remote tip of this branch — commits already Codex-reviewed) AND no explicit narrowing was set, the gate auto-scopes to the last 10 commits and emits a stderr warning. Set to `0` to disable. **Suppressed** when any of `--base`, `--last-n-commits`, `policy.review.last_n_commits`, OR the base was resolved via the upstream / origin-head / origin-main ladder (initial push, no upstream, fallback to trunk). Auto-narrow never fires on initial pushes — earlier commits on the branch may not have been reviewed yet, and skipping past them would silently bypass the gate's coverage contract. Audit metadata records `auto_narrowed: true` and `original_commit_count: N` so operators can grep for narrowed reviews. Background: large feature branches (50+ commits relative to a previously-pushed tip) routinely produced non-deterministic Codex verdicts and 30-minute timeouts; J makes the protective default automatic without compromising first-push coverage. |
362
+
363
+ ### Auto-narrow on large divergence (0.13.0)
364
+
365
+ When pushing a long-running branch that has already been pushed before (so
366
+ the remote tip is the previously-reviewed Codex baseline), follow-up
367
+ pushes that pile up many commits since the last push can timeout the
368
+ reviewer or produce inconsistent verdicts. **Auto-narrow** detects this
369
+ case and scopes the review down to recent commits automatically:
370
+
371
+ ```
372
+ $ git push origin feature/big-thing
373
+ rea: auto-narrow — 80 commits behind <previous-remote-tip-sha> (threshold 30);
374
+ reviewing the last 10 commits instead.
375
+ Override: pass `--last-n-commits N` or `--base <ref>`, set
376
+ `review.last_n_commits` in .rea/policy.yaml, or disable with
377
+ `review.auto_narrow_threshold: 0`.
378
+ ```
379
+
380
+ The probe runs `git rev-list --count base..HEAD` after base resolution.
381
+ **Auto-narrow only fires when the base was resolved from the active
382
+ refspec's `remoteSha`** — i.e. the previously-pushed tip of this branch,
383
+ where the older commits have already been Codex-reviewed in a prior push.
384
+ Initial pushes (where the resolver falls back to `origin/main` or the
385
+ upstream ladder) are NEVER auto-narrowed: skipping past those earlier
386
+ commits would silently bypass the advertised pre-push review for any
387
+ hook/policy/security change made early in the branch.
388
+
389
+ When eligible and the count exceeds `review.auto_narrow_threshold`
390
+ (default 30) and no narrowing override is in effect, the gate re-resolves
391
+ to `HEAD~10` and proceeds with the smaller diff. Every reviewed event
392
+ includes `auto_narrowed: true` + `original_commit_count: <N>` in audit
393
+ metadata.
394
+
395
+ To opt out for one push: pass `--last-n-commits N` or `--base <ref>`. To
396
+ opt out persistently: set `review.last_n_commits` (any value), or set
397
+ `review.auto_narrow_threshold: 0`.
398
+
399
+ ### Extension-hook chaining (0.13.0)
400
+
401
+ Drop executable scripts into `.husky/commit-msg.d/` or `.husky/pre-push.d/`
402
+ and rea will run them after its own governance work, in lexical order, with
403
+ the same positional args. Useful for layering commitlint, conventional-
404
+ commits linters, branch-policy checks, or any other per-commit / per-push
405
+ work without losing rea coverage.
406
+
407
+ ```bash
408
+ mkdir -p .husky/pre-push.d
409
+ cat > .husky/pre-push.d/10-commitlint <<'EOF'
410
+ #!/bin/sh
411
+ # Verify every new commit on the pushed range has a conventional message.
412
+ exec npx --no-install commitlint --from "origin/main" --to "HEAD"
413
+ EOF
414
+ chmod +x .husky/pre-push.d/10-commitlint
415
+ ```
416
+
417
+ Rules:
418
+
419
+ - **Sourced AFTER rea's body** — HALT, attribution blocking, and Codex
420
+ review run first; fragments only fire when rea succeeds. A non-zero exit
421
+ from rea short-circuits before any fragment runs.
422
+ - **Lexical order** — `10-foo` runs before `20-bar` runs before `90-baz`.
423
+ The standard convention is to prefix with a two-digit ordering number.
424
+ - **Executable bit gates** — only files with `chmod +x` are run. A README
425
+ or `.disabled` file in the directory is silently skipped.
426
+ - **Non-zero exit fails the hook** — the next fragment does not run, the
427
+ push / commit is blocked. This matches husky's normal hook chaining
428
+ semantics.
429
+ - **Missing directory is a no-op** — backward compatible with consumers
430
+ who never opt into fragments.
431
+ - **Fragments cannot replay pre-push stdin** — git delivers refspec data
432
+ on stdin which rea consumes during its own review. Fragments that need
433
+ refspec data should run before rea (use a custom hook in
434
+ `core.hooksPath` instead). Fragments that need ambient repo state can
435
+ call `git rev-parse` themselves.
436
+
437
+ `rea doctor` lists every fragment it sees and warns when a non-executable
438
+ file is sitting in either directory (silently skipped at hook-fire time).
359
439
 
360
440
  ### Codex CLI dependency
361
441
 
@@ -595,6 +675,8 @@ review:
595
675
  concerns_blocks: true
596
676
  timeout_ms: 1800000
597
677
  # last_n_commits: 10 # optional — narrow review to HEAD~N
678
+ # auto_narrow_threshold: 30 # optional — auto-narrow when commits
679
+ # behind base > N (0 disables, default 30)
598
680
  redact:
599
681
  match_timeout_ms: 100
600
682
  patterns:
@@ -403,6 +403,67 @@ function checkPrePushHook(state) {
403
403
  'Run `rea init` to install the fallback.',
404
404
  };
405
405
  }
406
+ /**
407
+ * Detect and list extension-hook fragments under `.husky/commit-msg.d/` and
408
+ * `.husky/pre-push.d/`. Informational only — fragments are an opt-in feature
409
+ * (added in 0.13.0); their presence is something operators should know about
410
+ * but never a hard fail. Non-executable files in the directories are
411
+ * surfaced as a warning since they are silently skipped at hook-fire time
412
+ * (executable bit is the consumer's opt-in).
413
+ */
414
+ function checkExtensionFragments(baseDir) {
415
+ const dirs = [
416
+ { name: 'commit-msg.d', path: path.join(baseDir, '.husky', 'commit-msg.d') },
417
+ { name: 'pre-push.d', path: path.join(baseDir, '.husky', 'pre-push.d') },
418
+ ];
419
+ const found = [];
420
+ const inert = [];
421
+ for (const d of dirs) {
422
+ if (!fs.existsSync(d.path))
423
+ continue;
424
+ let entries;
425
+ try {
426
+ entries = fs.readdirSync(d.path, { withFileTypes: true });
427
+ }
428
+ catch {
429
+ continue;
430
+ }
431
+ for (const e of entries) {
432
+ if (!e.isFile())
433
+ continue;
434
+ const abs = path.join(d.path, e.name);
435
+ try {
436
+ const st = fs.statSync(abs);
437
+ if ((st.mode & 0o111) !== 0) {
438
+ found.push(`${d.name}/${e.name}`);
439
+ }
440
+ else {
441
+ inert.push(`${d.name}/${e.name}`);
442
+ }
443
+ }
444
+ catch {
445
+ // unreadable — skip, will be surfaced at hook-fire time
446
+ }
447
+ }
448
+ }
449
+ if (found.length === 0 && inert.length === 0) {
450
+ return {
451
+ label: 'extension hook fragments',
452
+ status: 'info',
453
+ detail: 'none — drop executables into .husky/{commit-msg,pre-push}.d/ to chain custom checks',
454
+ };
455
+ }
456
+ if (inert.length > 0) {
457
+ const detail = `executable: ${found.length === 0 ? 'none' : found.join(', ')}; ` +
458
+ `non-executable (silently skipped): ${inert.join(', ')} — chmod +x to enable`;
459
+ return { label: 'extension hook fragments', status: 'warn', detail };
460
+ }
461
+ return {
462
+ label: 'extension hook fragments',
463
+ status: 'info',
464
+ detail: `${found.length} executable fragment(s): ${found.join(', ')}`,
465
+ };
466
+ }
406
467
  function checkCodexAgent(baseDir) {
407
468
  const agentPath = path.join(baseDir, '.claude', 'agents', 'codex-adversarial.md');
408
469
  if (fs.existsSync(agentPath))
@@ -601,6 +662,7 @@ export function collectChecks(baseDir, codexProbeState, prePushState) {
601
662
  if (prePushState !== undefined) {
602
663
  checks.push(checkPrePushHook(prePushState));
603
664
  }
665
+ checks.push(checkExtensionFragments(baseDir));
604
666
  }
605
667
  else {
606
668
  checks.push({
@@ -17,6 +17,40 @@
17
17
  * `.rea/policy.yaml`, so it is safe to install unconditionally — see the
18
18
  * header of `.husky/commit-msg` for the opt-in check.
19
19
  */
20
+ /**
21
+ * Marker baked into every rea-installed commit-msg hook. Anchored on line 2
22
+ * of the file (immediately after the shebang) for classification. Bump the
23
+ * version suffix whenever the body semantics change so upgrades can migrate
24
+ * old installs cleanly.
25
+ *
26
+ * v1 — 0.13.0: first version of the commit-msg marker. Pre-0.13 installs
27
+ * shipped the same file content but without a marker line — those
28
+ * classify as `unmarked` and are upgraded in place.
29
+ */
30
+ export declare const COMMIT_MSG_MARKER = "# rea:commit-msg v1";
31
+ /**
32
+ * Classify an existing `commit-msg` file. The classifier is permissive on
33
+ * upgrades (treat `unmarked` as legacy rea body) and conservative on
34
+ * foreign hooks (do not stomp).
35
+ */
36
+ export type CommitMsgClassification = {
37
+ kind: 'absent';
38
+ } | {
39
+ kind: 'rea-managed';
40
+ version: string;
41
+ } | {
42
+ kind: 'unmarked';
43
+ } | {
44
+ kind: 'foreign';
45
+ reason: string;
46
+ };
47
+ /**
48
+ * Inspect `hookPath` and decide whether it is rea-authored, a pre-marker
49
+ * legacy rea body, or a foreign user hook. The pre-0.13 commit-msg shipped
50
+ * with no marker line; we detect that shape via the literal "block_ai_attribution"
51
+ * grep — every rea body has consulted that policy field since 0.1.0.
52
+ */
53
+ export declare function classifyCommitMsgHook(hookPath: string): Promise<CommitMsgClassification>;
20
54
  export interface CommitMsgInstallResult {
21
55
  gitHook?: string;
22
56
  huskyHook?: string;
@@ -24,6 +24,66 @@ import path from 'node:path';
24
24
  import { promisify } from 'node:util';
25
25
  import { PKG_ROOT, warn } from '../utils.js';
26
26
  const execFileAsync = promisify(execFile);
27
+ /**
28
+ * Marker baked into every rea-installed commit-msg hook. Anchored on line 2
29
+ * of the file (immediately after the shebang) for classification. Bump the
30
+ * version suffix whenever the body semantics change so upgrades can migrate
31
+ * old installs cleanly.
32
+ *
33
+ * v1 — 0.13.0: first version of the commit-msg marker. Pre-0.13 installs
34
+ * shipped the same file content but without a marker line — those
35
+ * classify as `unmarked` and are upgraded in place.
36
+ */
37
+ export const COMMIT_MSG_MARKER = '# rea:commit-msg v1';
38
+ /**
39
+ * Inspect `hookPath` and decide whether it is rea-authored, a pre-marker
40
+ * legacy rea body, or a foreign user hook. The pre-0.13 commit-msg shipped
41
+ * with no marker line; we detect that shape via the literal "block_ai_attribution"
42
+ * grep — every rea body has consulted that policy field since 0.1.0.
43
+ */
44
+ export async function classifyCommitMsgHook(hookPath) {
45
+ let stat;
46
+ try {
47
+ stat = await fsPromises.lstat(hookPath);
48
+ }
49
+ catch {
50
+ return { kind: 'absent' };
51
+ }
52
+ if (stat.isDirectory())
53
+ return { kind: 'foreign', reason: 'is-directory' };
54
+ if (stat.isSymbolicLink())
55
+ return { kind: 'foreign', reason: 'is-symlink' };
56
+ if (!stat.isFile())
57
+ return { kind: 'foreign', reason: 'not-regular-file' };
58
+ let content;
59
+ try {
60
+ content = await fsPromises.readFile(hookPath, 'utf8');
61
+ }
62
+ catch (e) {
63
+ return {
64
+ kind: 'foreign',
65
+ reason: `read-error: ${e instanceof Error ? e.message : String(e)}`,
66
+ };
67
+ }
68
+ // Anchor the marker on the second line — substring match would be tricked
69
+ // by a foreign hook that mentions the marker in a comment.
70
+ if (content.startsWith('#!/bin/sh\n')) {
71
+ const secondLineEnd = content.indexOf('\n', 10);
72
+ if (secondLineEnd > 0) {
73
+ const secondLine = content.slice(10, secondLineEnd);
74
+ if (secondLine === COMMIT_MSG_MARKER) {
75
+ return { kind: 'rea-managed', version: 'v1' };
76
+ }
77
+ }
78
+ }
79
+ // Pre-0.13 rea body had no marker but always contained the attribution
80
+ // grep. Treat that shape as upgradeable rather than foreign.
81
+ if (content.includes('block_ai_attribution') &&
82
+ content.includes('AI attribution detected')) {
83
+ return { kind: 'unmarked' };
84
+ }
85
+ return { kind: 'foreign', reason: 'no-marker' };
86
+ }
27
87
  /**
28
88
  * Read `core.hooksPath` via `git config --get`. This is the only correct way
29
89
  * to consult git config: regex-matching `.git/config` (finding #9) is
@@ -53,13 +53,18 @@
53
53
  * classification. Bump the version suffix whenever the body semantics
54
54
  * change so upgrades can migrate old installs cleanly.
55
55
  *
56
+ * v4 — 0.13.0 extension-hook chaining: rea body sources `.husky/pre-push.d/*`
57
+ * fragments after its own work and before the final `exec`, in lex
58
+ * order. Non-zero fragment exit fails the hook.
56
59
  * v3 — 0.12.0 BODY_TEMPLATE rewrite: positional-args dispatch via `case`
57
60
  * arms with `set --`, removing the `exec $REA_BIN` word-splitting bug
58
61
  * that broke pushes from repo paths containing spaces.
59
62
  * v2 — 0.11.0 stateless push-gate body (no bash core, no audit grep).
60
63
  * v1 — 0.10.x and prior, delegated to `.claude/hooks/push-review-gate.sh`.
61
64
  */
62
- export declare const FALLBACK_MARKER = "# rea:pre-push-fallback v3";
65
+ export declare const FALLBACK_MARKER = "# rea:pre-push-fallback v4";
66
+ /** Legacy v3 marker (0.12.x bodies). Refresh-on-upgrade. */
67
+ export declare const LEGACY_FALLBACK_MARKER_V3 = "# rea:pre-push-fallback v3";
63
68
  /** Legacy v2 marker (0.11.x bodies). Refresh-on-upgrade. */
64
69
  export declare const LEGACY_FALLBACK_MARKER_V2 = "# rea:pre-push-fallback v2";
65
70
  /** Legacy v1 marker — used by upgrade migration to detect old installs. */
@@ -68,9 +73,11 @@ export declare const LEGACY_FALLBACK_MARKER_V1 = "# rea:pre-push-fallback v1";
68
73
  * Marker present in the shipped `.husky/pre-push` governance gate. The
69
74
  * second line of the shipped husky hook is this marker — rea upgrade
70
75
  * detects it to refresh in-place. Bump the suffix whenever the body
71
- * changes; pre-0.12 markers live in `LEGACY_HUSKY_GATE_MARKER_V{1,2}`.
76
+ * changes; pre-0.13 markers live in `LEGACY_HUSKY_GATE_MARKER_V{1,2,3}`.
72
77
  */
73
- export declare const HUSKY_GATE_MARKER = "# rea:husky-pre-push-gate v3";
78
+ export declare const HUSKY_GATE_MARKER = "# rea:husky-pre-push-gate v4";
79
+ /** Legacy v3 husky marker (0.12.x bodies). Refresh-on-upgrade. */
80
+ export declare const LEGACY_HUSKY_GATE_MARKER_V3 = "# rea:husky-pre-push-gate v3";
74
81
  /** Legacy v2 husky marker (0.11.x bodies). Refresh-on-upgrade. */
75
82
  export declare const LEGACY_HUSKY_GATE_MARKER_V2 = "# rea:husky-pre-push-gate v2";
76
83
  /** Legacy v1 husky marker for migration. */
@@ -80,7 +87,9 @@ export declare const LEGACY_HUSKY_GATE_MARKER_V1 = "# rea:husky-pre-push-gate v1
80
87
  * empty body (stubbed out by a consumer) is NOT classified as rea-managed.
81
88
  * A real rea hook always carries both markers.
82
89
  */
83
- export declare const HUSKY_GATE_BODY_MARKER = "# rea:gate-body-v3";
90
+ export declare const HUSKY_GATE_BODY_MARKER = "# rea:gate-body-v4";
91
+ /** Legacy v3 body marker (0.12.x bodies). Refresh-on-upgrade. */
92
+ export declare const LEGACY_HUSKY_GATE_BODY_MARKER_V3 = "# rea:gate-body-v3";
84
93
  /** Legacy v2 body marker (0.11.x bodies). Refresh-on-upgrade. */
85
94
  export declare const LEGACY_HUSKY_GATE_BODY_MARKER_V2 = "# rea:gate-body-v2";
86
95
  /** Legacy body marker — used by upgrade migration detection. */
@@ -97,10 +106,11 @@ export declare function huskyHookContent(): string;
97
106
  export declare function isReaManagedFallback(content: string): boolean;
98
107
  /**
99
108
  * True when `content` is a legacy fallback hook authored by an earlier rea
100
- * release: v2 (0.11.x — broken `exec $REA_BIN` body) or v1 (0.10.x —
101
- * delegated to `.claude/hooks/push-review-gate.sh`). Used by `rea upgrade`
102
- * to migrate we overwrite these unconditionally because we control the
103
- * entire body shape.
109
+ * release: v3 (0.12.x — pre-extension body), v2 (0.11.x — broken
110
+ * `exec $REA_BIN` body), or v1 (0.10.x delegated to
111
+ * `.claude/hooks/push-review-gate.sh`). Used by `rea upgrade` to migrate
112
+ * we overwrite these unconditionally because we control the entire
113
+ * body shape.
104
114
  */
105
115
  export declare function isLegacyReaManagedFallback(content: string): boolean;
106
116
  /**
@@ -117,8 +127,9 @@ export declare function isLegacyReaManagedFallback(content: string): boolean;
117
127
  export declare function isReaManagedHuskyGate(content: string): boolean;
118
128
  /**
119
129
  * True when `content` is a legacy Husky gate from an earlier rea release:
120
- * v2 (0.11.x — broken `exec $REA_BIN` body) or v1 (0.10.x — bash core
121
- * delegating). Used to trigger the upgrade migration.
130
+ * v3 (0.12.x — pre-extension body), v2 (0.11.x — broken `exec $REA_BIN`
131
+ * body), or v1 (0.10.x — bash core delegating). Used to trigger the
132
+ * upgrade migration.
122
133
  */
123
134
  export declare function isLegacyReaManagedHuskyGate(content: string): boolean;
124
135
  /**
@@ -65,13 +65,18 @@ const execFileAsync = promisify(execFile);
65
65
  * classification. Bump the version suffix whenever the body semantics
66
66
  * change so upgrades can migrate old installs cleanly.
67
67
  *
68
+ * v4 — 0.13.0 extension-hook chaining: rea body sources `.husky/pre-push.d/*`
69
+ * fragments after its own work and before the final `exec`, in lex
70
+ * order. Non-zero fragment exit fails the hook.
68
71
  * v3 — 0.12.0 BODY_TEMPLATE rewrite: positional-args dispatch via `case`
69
72
  * arms with `set --`, removing the `exec $REA_BIN` word-splitting bug
70
73
  * that broke pushes from repo paths containing spaces.
71
74
  * v2 — 0.11.0 stateless push-gate body (no bash core, no audit grep).
72
75
  * v1 — 0.10.x and prior, delegated to `.claude/hooks/push-review-gate.sh`.
73
76
  */
74
- export const FALLBACK_MARKER = '# rea:pre-push-fallback v3';
77
+ export const FALLBACK_MARKER = '# rea:pre-push-fallback v4';
78
+ /** Legacy v3 marker (0.12.x bodies). Refresh-on-upgrade. */
79
+ export const LEGACY_FALLBACK_MARKER_V3 = '# rea:pre-push-fallback v3';
75
80
  /** Legacy v2 marker (0.11.x bodies). Refresh-on-upgrade. */
76
81
  export const LEGACY_FALLBACK_MARKER_V2 = '# rea:pre-push-fallback v2';
77
82
  /** Legacy v1 marker — used by upgrade migration to detect old installs. */
@@ -80,9 +85,11 @@ export const LEGACY_FALLBACK_MARKER_V1 = '# rea:pre-push-fallback v1';
80
85
  * Marker present in the shipped `.husky/pre-push` governance gate. The
81
86
  * second line of the shipped husky hook is this marker — rea upgrade
82
87
  * detects it to refresh in-place. Bump the suffix whenever the body
83
- * changes; pre-0.12 markers live in `LEGACY_HUSKY_GATE_MARKER_V{1,2}`.
88
+ * changes; pre-0.13 markers live in `LEGACY_HUSKY_GATE_MARKER_V{1,2,3}`.
84
89
  */
85
- export const HUSKY_GATE_MARKER = '# rea:husky-pre-push-gate v3';
90
+ export const HUSKY_GATE_MARKER = '# rea:husky-pre-push-gate v4';
91
+ /** Legacy v3 husky marker (0.12.x bodies). Refresh-on-upgrade. */
92
+ export const LEGACY_HUSKY_GATE_MARKER_V3 = '# rea:husky-pre-push-gate v3';
86
93
  /** Legacy v2 husky marker (0.11.x bodies). Refresh-on-upgrade. */
87
94
  export const LEGACY_HUSKY_GATE_MARKER_V2 = '# rea:husky-pre-push-gate v2';
88
95
  /** Legacy v1 husky marker for migration. */
@@ -92,7 +99,9 @@ export const LEGACY_HUSKY_GATE_MARKER_V1 = '# rea:husky-pre-push-gate v1';
92
99
  * empty body (stubbed out by a consumer) is NOT classified as rea-managed.
93
100
  * A real rea hook always carries both markers.
94
101
  */
95
- export const HUSKY_GATE_BODY_MARKER = '# rea:gate-body-v3';
102
+ export const HUSKY_GATE_BODY_MARKER = '# rea:gate-body-v4';
103
+ /** Legacy v3 body marker (0.12.x bodies). Refresh-on-upgrade. */
104
+ export const LEGACY_HUSKY_GATE_BODY_MARKER_V3 = '# rea:gate-body-v3';
96
105
  /** Legacy v2 body marker (0.11.x bodies). Refresh-on-upgrade. */
97
106
  export const LEGACY_HUSKY_GATE_BODY_MARKER_V2 = '# rea:gate-body-v2';
98
107
  /** Legacy body marker — used by upgrade migration detection. */
@@ -165,7 +174,51 @@ else
165
174
  exit 2
166
175
  fi
167
176
 
168
- exec "\$@"
177
+ # Run the rea push-gate FIRST. We capture its exit and explicitly propagate
178
+ # instead of \`exec\`-ing — extension fragments must only run after rea's own
179
+ # governance work succeeds. The fragments are user code; surfacing them
180
+ # AFTER rea's body (HALT check, Codex review, audit write) preserves the
181
+ # governance contract while letting consumers chain their own checks (e.g.
182
+ # commitlint, branch-policy linters) without losing rea coverage.
183
+ "\$@"
184
+ rea_status=\$?
185
+ if [ "\$rea_status" -ne 0 ]; then
186
+ exit "\$rea_status"
187
+ fi
188
+
189
+ # Extension-hook chaining: source every executable file under
190
+ # \`.husky/pre-push.d/\` in lexical order. Missing directory = no-op
191
+ # (backward compatible). Each fragment receives the original positional
192
+ # args from git's \`<remote-name> <remote-url>\` invocation. Non-zero exit
193
+ # from any fragment fails the push; this matches husky's normal hook
194
+ # chaining semantics.
195
+ #
196
+ # The git pre-push contract delivers refspecs on stdin. Once rea's body has
197
+ # consumed it (\`exec rea hook push-gate "\$@"\` reads stdin during \`runPushGate\`),
198
+ # subsequent fragments cannot replay it — that's by design: agents that need
199
+ # refspec data should run before rea, not after. Fragments that need ambient
200
+ # repo state can call \`git rev-parse\` themselves.
201
+ ext_dir="\${REA_ROOT}/.husky/pre-push.d"
202
+ if [ -d "\$ext_dir" ]; then
203
+ # Collect fragments deterministically. POSIX sort orders the same way on
204
+ # macOS (BSD sort) and Linux (GNU sort) for ASCII filenames; consumers who
205
+ # name their fragments \`10-foo\` / \`20-bar\` get predictable ordering.
206
+ for frag in "\$ext_dir"/*; do
207
+ # Glob expands to itself when no matches — guard with -e.
208
+ [ -e "\$frag" ] || continue
209
+ # Skip non-files (directories, sockets) and non-executables. The
210
+ # executable bit is the consumer's opt-in: dropping a non-executable
211
+ # README into the dir does not become a hook.
212
+ [ -f "\$frag" ] || continue
213
+ [ -x "\$frag" ] || continue
214
+ # Each fragment runs in its own subprocess with the original git args.
215
+ # Failures propagate via \`set -eu\` above (loop body inherits, so any
216
+ # non-zero exit blocks the push immediately).
217
+ "\$frag" "\$@"
218
+ done
219
+ fi
220
+
221
+ exit 0
169
222
  `;
170
223
  /** Fallback hook body — `.git/hooks/pre-push` in vanilla-git installs. */
171
224
  export function fallbackHookContent() {
@@ -217,10 +270,11 @@ export function isReaManagedFallback(content) {
217
270
  }
218
271
  /**
219
272
  * True when `content` is a legacy fallback hook authored by an earlier rea
220
- * release: v2 (0.11.x — broken `exec $REA_BIN` body) or v1 (0.10.x —
221
- * delegated to `.claude/hooks/push-review-gate.sh`). Used by `rea upgrade`
222
- * to migrate we overwrite these unconditionally because we control the
223
- * entire body shape.
273
+ * release: v3 (0.12.x — pre-extension body), v2 (0.11.x — broken
274
+ * `exec $REA_BIN` body), or v1 (0.10.x delegated to
275
+ * `.claude/hooks/push-review-gate.sh`). Used by `rea upgrade` to migrate
276
+ * we overwrite these unconditionally because we control the entire
277
+ * body shape.
224
278
  */
225
279
  export function isLegacyReaManagedFallback(content) {
226
280
  if (!content.startsWith('#!/bin/sh\n'))
@@ -229,7 +283,8 @@ export function isLegacyReaManagedFallback(content) {
229
283
  if (secondLineEnd < 0)
230
284
  return false;
231
285
  const secondLine = content.slice(10, secondLineEnd);
232
- return (secondLine === LEGACY_FALLBACK_MARKER_V2 ||
286
+ return (secondLine === LEGACY_FALLBACK_MARKER_V3 ||
287
+ secondLine === LEGACY_FALLBACK_MARKER_V2 ||
233
288
  secondLine === LEGACY_FALLBACK_MARKER_V1);
234
289
  }
235
290
  /**
@@ -248,11 +303,13 @@ export function isReaManagedHuskyGate(content) {
248
303
  }
249
304
  /**
250
305
  * True when `content` is a legacy Husky gate from an earlier rea release:
251
- * v2 (0.11.x — broken `exec $REA_BIN` body) or v1 (0.10.x — bash core
252
- * delegating). Used to trigger the upgrade migration.
306
+ * v3 (0.12.x — pre-extension body), v2 (0.11.x — broken `exec $REA_BIN`
307
+ * body), or v1 (0.10.x — bash core delegating). Used to trigger the
308
+ * upgrade migration.
253
309
  */
254
310
  export function isLegacyReaManagedHuskyGate(content) {
255
- return (hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V2, LEGACY_HUSKY_GATE_BODY_MARKER_V2) ||
311
+ return (hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V3, LEGACY_HUSKY_GATE_BODY_MARKER_V3) ||
312
+ hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V2, LEGACY_HUSKY_GATE_BODY_MARKER_V2) ||
256
313
  hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V1, LEGACY_HUSKY_GATE_BODY_MARKER_V1));
257
314
  }
258
315
  function hasHeaderMarkers(content, header, body) {
@@ -541,6 +598,8 @@ async function cleanupStaleTempFiles(dst) {
541
598
  return;
542
599
  if (!body.includes(FALLBACK_MARKER) &&
543
600
  !body.includes(HUSKY_GATE_MARKER) &&
601
+ !body.includes(LEGACY_FALLBACK_MARKER_V3) &&
602
+ !body.includes(LEGACY_HUSKY_GATE_MARKER_V3) &&
544
603
  !body.includes(LEGACY_FALLBACK_MARKER_V2) &&
545
604
  !body.includes(LEGACY_HUSKY_GATE_MARKER_V2) &&
546
605
  !body.includes(LEGACY_FALLBACK_MARKER_V1) &&
@@ -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
  /**
@@ -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';
@@ -189,6 +189,13 @@ export async function runPushGate(deps) {
189
189
  const activeRefspec = (deps.refspecs ?? []).find((r) => r.localSha !== NULL_SHA && r.localSha.length > 0);
190
190
  let base;
191
191
  let headSha;
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;
192
199
  if (explicitBaseSet) {
193
200
  // (a) explicit base wins absolutely.
194
201
  base = resolveBaseRef(git, { explicit: deps.explicitBase });
@@ -229,6 +236,9 @@ export async function runPushGate(deps) {
229
236
  }
230
237
  else {
231
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;
232
242
  }
233
243
  }
234
244
  else {
@@ -241,6 +251,67 @@ export async function runPushGate(deps) {
241
251
  await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, { kind: 'head-sha-missing' });
242
252
  return { status: 'error', exitCode: 2, summary: 'head-sha-missing' };
243
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
+ }
244
315
  // 5. Empty-diff short-circuit. An initial push against the empty-tree
245
316
  // sentinel ALWAYS has a non-empty diff (HEAD vs empty tree); this
246
317
  // short-circuit only fires when the feature branch really is a
@@ -253,6 +324,8 @@ export async function runPushGate(deps) {
253
324
  head_sha: headSha,
254
325
  last_n_commits: base.lastNCommits,
255
326
  last_n_commits_requested: base.lastNCommitsRequested,
327
+ auto_narrowed: autoNarrowed ? true : undefined,
328
+ original_commit_count: originalCommitCount !== null ? originalCommitCount : undefined,
256
329
  });
257
330
  return {
258
331
  status: 'empty-diff',
@@ -303,6 +376,8 @@ export async function runPushGate(deps) {
303
376
  concerns_override: summary.verdict === 'concerns' && isConcernsOverrideSet(env) ? true : undefined,
304
377
  last_n_commits: base.lastNCommits,
305
378
  last_n_commits_requested: base.lastNCommitsRequested,
379
+ auto_narrowed: autoNarrowed ? true : undefined,
380
+ original_commit_count: originalCommitCount !== null ? originalCommitCount : undefined,
306
381
  });
307
382
  if (blocked) {
308
383
  return {
@@ -36,12 +36,33 @@ export interface ResolvedReviewPolicy {
36
36
  * `undefined` when unset (default-untouched behavior).
37
37
  */
38
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;
39
46
  /** `true` when `.rea/policy.yaml` was absent; defaults apply. */
40
47
  policyMissing: boolean;
41
48
  }
42
49
  export declare const PUSH_GATE_DEFAULT_TIMEOUT_MS = 1800000;
43
50
  export declare const PUSH_GATE_DEFAULT_CODEX_REQUIRED = true;
44
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;
45
66
  /**
46
67
  * Resolve the push-gate policy for `baseDir`. Never throws — a malformed
47
68
  * policy file surfaces as a typed error via the underlying zod validator,
@@ -31,6 +31,20 @@ import { loadPolicyAsync } from '../../policy/loader.js';
31
31
  export const PUSH_GATE_DEFAULT_TIMEOUT_MS = 1_800_000;
32
32
  export const PUSH_GATE_DEFAULT_CODEX_REQUIRED = true;
33
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;
34
48
  /**
35
49
  * Resolve the push-gate policy for `baseDir`. Never throws — a malformed
36
50
  * policy file surfaces as a typed error via the underlying zod validator,
@@ -49,6 +63,7 @@ export async function resolvePushGatePolicy(baseDir) {
49
63
  concerns_blocks: PUSH_GATE_DEFAULT_CONCERNS_BLOCKS,
50
64
  timeout_ms: PUSH_GATE_DEFAULT_TIMEOUT_MS,
51
65
  last_n_commits: undefined,
66
+ auto_narrow_threshold: PUSH_GATE_DEFAULT_AUTO_NARROW_THRESHOLD,
52
67
  policyMissing: true,
53
68
  };
54
69
  }
@@ -59,6 +74,7 @@ export async function resolvePushGatePolicy(baseDir) {
59
74
  concerns_blocks: review.concerns_blocks ?? PUSH_GATE_DEFAULT_CONCERNS_BLOCKS,
60
75
  timeout_ms: review.timeout_ms ?? PUSH_GATE_DEFAULT_TIMEOUT_MS,
61
76
  last_n_commits: review.last_n_commits,
77
+ auto_narrow_threshold: review.auto_narrow_threshold ?? PUSH_GATE_DEFAULT_AUTO_NARROW_THRESHOLD,
62
78
  policyMissing: false,
63
79
  };
64
80
  }
@@ -35,16 +35,28 @@ declare const PolicySchema: z.ZodObject<{
35
35
  concerns_blocks: z.ZodOptional<z.ZodBoolean>;
36
36
  timeout_ms: z.ZodOptional<z.ZodNumber>;
37
37
  last_n_commits: z.ZodOptional<z.ZodNumber>;
38
+ /**
39
+ * Auto-narrow threshold (J / 0.13.0). When the resolved diff base is more
40
+ * than N commits away from HEAD, the gate auto-scopes to
41
+ * `last_n_commits` (or the 0.13 fallback default of 10) and emits a
42
+ * stderr warning. Default 30 when unset; explicit 0 disables auto-narrow
43
+ * entirely. Suppressed when the operator pinned `--last-n-commits`,
44
+ * `--base`, or `policy.review.last_n_commits` (those are explicit
45
+ * intent and auto-narrow stays out of the way).
46
+ */
47
+ auto_narrow_threshold: z.ZodOptional<z.ZodNumber>;
38
48
  }, "strict", z.ZodTypeAny, {
39
49
  codex_required?: boolean | undefined;
40
50
  concerns_blocks?: boolean | undefined;
41
51
  timeout_ms?: number | undefined;
42
52
  last_n_commits?: number | undefined;
53
+ auto_narrow_threshold?: number | undefined;
43
54
  }, {
44
55
  codex_required?: boolean | undefined;
45
56
  concerns_blocks?: boolean | undefined;
46
57
  timeout_ms?: number | undefined;
47
58
  last_n_commits?: number | undefined;
59
+ auto_narrow_threshold?: number | undefined;
48
60
  }>>;
49
61
  redact: z.ZodOptional<z.ZodObject<{
50
62
  match_timeout_ms: z.ZodOptional<z.ZodNumber>;
@@ -139,6 +151,7 @@ declare const PolicySchema: z.ZodObject<{
139
151
  concerns_blocks?: boolean | undefined;
140
152
  timeout_ms?: number | undefined;
141
153
  last_n_commits?: number | undefined;
154
+ auto_narrow_threshold?: number | undefined;
142
155
  } | undefined;
143
156
  redact?: {
144
157
  match_timeout_ms?: number | undefined;
@@ -183,6 +196,7 @@ declare const PolicySchema: z.ZodObject<{
183
196
  concerns_blocks?: boolean | undefined;
184
197
  timeout_ms?: number | undefined;
185
198
  last_n_commits?: number | undefined;
199
+ auto_narrow_threshold?: number | undefined;
186
200
  } | undefined;
187
201
  redact?: {
188
202
  match_timeout_ms?: number | undefined;
@@ -28,6 +28,16 @@ const ReviewPolicySchema = z
28
28
  concerns_blocks: z.boolean().optional(),
29
29
  timeout_ms: z.number().int().positive().optional(),
30
30
  last_n_commits: z.number().int().positive().optional(),
31
+ /**
32
+ * Auto-narrow threshold (J / 0.13.0). When the resolved diff base is more
33
+ * than N commits away from HEAD, the gate auto-scopes to
34
+ * `last_n_commits` (or the 0.13 fallback default of 10) and emits a
35
+ * stderr warning. Default 30 when unset; explicit 0 disables auto-narrow
36
+ * entirely. Suppressed when the operator pinned `--last-n-commits`,
37
+ * `--base`, or `policy.review.last_n_commits` (those are explicit
38
+ * intent and auto-narrow stays out of the way).
39
+ */
40
+ auto_narrow_threshold: z.number().int().nonnegative().optional(),
31
41
  })
32
42
  .strict();
33
43
  /**
@@ -102,6 +102,34 @@ export interface ReviewPolicy {
102
102
  * Positive integer. The loader rejects zero/negative values.
103
103
  */
104
104
  last_n_commits?: number;
105
+ /**
106
+ * Auto-narrow threshold (J / 0.13.0). When the resolved diff base is more
107
+ * than N commits behind HEAD, the gate automatically scopes the review to
108
+ * the last 10 commits (or `last_n_commits` if pinned) and emits a stderr
109
+ * warning explaining the auto-narrow + how to override.
110
+ *
111
+ * Default `30` when unset. Explicit `0` disables auto-narrow entirely.
112
+ *
113
+ * Auto-narrow is SUPPRESSED when the operator already expressed explicit
114
+ * intent — any of these prevents auto-narrow from firing:
115
+ *
116
+ * - `--last-n-commits N` flag (the operator picked an exact window)
117
+ * - `--base <ref>` flag (the operator picked an exact base)
118
+ * - `policy.review.last_n_commits` (persistent narrow-window config)
119
+ *
120
+ * Audit metadata records `auto_narrowed: true|false` and
121
+ * `original_commit_count: N` on every reviewed event so operators can
122
+ * grep their audit log for narrowed reviews.
123
+ *
124
+ * Background: large feature branches (50+ commits relative to origin/main)
125
+ * routinely produced non-deterministic Codex verdicts, 10-minute timeouts,
126
+ * and the "thrashing" reported in helixir migration 2026-04-26. The 0.12.0
127
+ * `last_n_commits` knob fixed it for operators who knew to set it; J makes
128
+ * the protective default automatic.
129
+ *
130
+ * Non-negative integer. The loader rejects negative values.
131
+ */
132
+ auto_narrow_threshold?: number;
105
133
  }
106
134
  /**
107
135
  * User-supplied redaction pattern entry. Each pattern has a stable `name` used
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",