@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 +32 -0
- package/.husky/pre-push +47 -3
- package/README.md +82 -0
- package/dist/cli/doctor.js +62 -0
- package/dist/cli/install/commit-msg.d.ts +34 -0
- package/dist/cli/install/commit-msg.js +60 -0
- package/dist/cli/install/pre-push.d.ts +21 -10
- package/dist/cli/install/pre-push.js +72 -13
- package/dist/hooks/push-gate/codex-runner.d.ts +8 -0
- package/dist/hooks/push-gate/codex-runner.js +13 -0
- package/dist/hooks/push-gate/index.js +76 -1
- package/dist/hooks/push-gate/policy.d.ts +21 -0
- package/dist/hooks/push-gate/policy.js +16 -0
- package/dist/policy/loader.d.ts +14 -0
- package/dist/policy/loader.js +10 -0
- package/dist/policy/types.d.ts +28 -0
- package/package.json +1 -1
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
|
|
3
|
-
# rea:gate-body-
|
|
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
|
-
|
|
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:
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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-
|
|
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:
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
-
*
|
|
121
|
-
* delegating). Used to trigger the
|
|
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
|
|
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.
|
|
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
|
|
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-
|
|
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
|
-
|
|
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:
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
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 ===
|
|
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
|
-
*
|
|
252
|
-
* delegating). Used to trigger the
|
|
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,
|
|
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
|
}
|
package/dist/policy/loader.d.ts
CHANGED
|
@@ -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;
|
package/dist/policy/loader.js
CHANGED
|
@@ -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
|
/**
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -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.
|
|
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)",
|