@bookedsolid/rea 0.10.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.husky/pre-push +48 -162
  2. package/README.md +834 -552
  3. package/agents/codex-adversarial.md +5 -3
  4. package/commands/codex-review.md +3 -5
  5. package/dist/audit/append.d.ts +7 -32
  6. package/dist/audit/append.js +7 -35
  7. package/dist/cli/audit.d.ts +0 -31
  8. package/dist/cli/audit.js +5 -74
  9. package/dist/cli/doctor.d.ts +12 -0
  10. package/dist/cli/doctor.js +96 -17
  11. package/dist/cli/hook.d.ts +55 -0
  12. package/dist/cli/hook.js +138 -0
  13. package/dist/cli/index.js +5 -80
  14. package/dist/cli/init.js +1 -1
  15. package/dist/cli/install/gitignore.d.ts +2 -2
  16. package/dist/cli/install/gitignore.js +3 -3
  17. package/dist/cli/install/pre-push.d.ts +158 -272
  18. package/dist/cli/install/pre-push.js +491 -2633
  19. package/dist/cli/install/settings-merge.d.ts +17 -0
  20. package/dist/cli/install/settings-merge.js +48 -1
  21. package/dist/cli/upgrade.js +131 -3
  22. package/dist/config/tier-map.js +18 -25
  23. package/dist/hooks/push-gate/base.d.ts +104 -0
  24. package/dist/hooks/push-gate/base.js +198 -0
  25. package/dist/hooks/push-gate/codex-runner.d.ts +126 -0
  26. package/dist/hooks/push-gate/codex-runner.js +223 -0
  27. package/dist/hooks/push-gate/findings.d.ts +68 -0
  28. package/dist/hooks/push-gate/findings.js +142 -0
  29. package/dist/hooks/push-gate/halt.d.ts +28 -0
  30. package/dist/hooks/push-gate/halt.js +49 -0
  31. package/dist/hooks/push-gate/index.d.ts +98 -0
  32. package/dist/hooks/push-gate/index.js +416 -0
  33. package/dist/hooks/push-gate/policy.d.ts +55 -0
  34. package/dist/hooks/push-gate/policy.js +64 -0
  35. package/dist/hooks/push-gate/report.d.ts +89 -0
  36. package/dist/hooks/push-gate/report.js +140 -0
  37. package/dist/policy/loader.d.ts +15 -10
  38. package/dist/policy/loader.js +8 -6
  39. package/dist/policy/types.d.ts +73 -22
  40. package/package.json +1 -1
  41. package/scripts/tarball-smoke.sh +7 -2
  42. package/dist/cache/review-cache.d.ts +0 -115
  43. package/dist/cache/review-cache.js +0 -200
  44. package/dist/cli/cache.d.ts +0 -84
  45. package/dist/cli/cache.js +0 -150
  46. package/dist/hooks/review-gate/args.d.ts +0 -126
  47. package/dist/hooks/review-gate/args.js +0 -315
  48. package/dist/hooks/review-gate/audit.d.ts +0 -131
  49. package/dist/hooks/review-gate/audit.js +0 -181
  50. package/dist/hooks/review-gate/banner.d.ts +0 -97
  51. package/dist/hooks/review-gate/banner.js +0 -172
  52. package/dist/hooks/review-gate/base-resolve.d.ts +0 -155
  53. package/dist/hooks/review-gate/base-resolve.js +0 -247
  54. package/dist/hooks/review-gate/cache-key.d.ts +0 -55
  55. package/dist/hooks/review-gate/cache-key.js +0 -41
  56. package/dist/hooks/review-gate/cache.d.ts +0 -108
  57. package/dist/hooks/review-gate/cache.js +0 -120
  58. package/dist/hooks/review-gate/constants.d.ts +0 -26
  59. package/dist/hooks/review-gate/constants.js +0 -34
  60. package/dist/hooks/review-gate/diff.d.ts +0 -181
  61. package/dist/hooks/review-gate/diff.js +0 -232
  62. package/dist/hooks/review-gate/errors.d.ts +0 -72
  63. package/dist/hooks/review-gate/errors.js +0 -100
  64. package/dist/hooks/review-gate/hash.d.ts +0 -43
  65. package/dist/hooks/review-gate/hash.js +0 -46
  66. package/dist/hooks/review-gate/index.d.ts +0 -31
  67. package/dist/hooks/review-gate/index.js +0 -35
  68. package/dist/hooks/review-gate/metadata.d.ts +0 -98
  69. package/dist/hooks/review-gate/metadata.js +0 -158
  70. package/dist/hooks/review-gate/policy.d.ts +0 -55
  71. package/dist/hooks/review-gate/policy.js +0 -71
  72. package/dist/hooks/review-gate/protected-paths.d.ts +0 -46
  73. package/dist/hooks/review-gate/protected-paths.js +0 -76
  74. package/hooks/_lib/push-review-core.sh +0 -1250
  75. package/hooks/commit-review-gate.sh +0 -330
  76. package/hooks/push-review-gate-git.sh +0 -94
  77. package/hooks/push-review-gate.sh +0 -92
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Push-gate composition — the pure orchestrator that `rea hook push-gate`
3
+ * calls.
4
+ *
5
+ * Contract: `runPushGate(deps)` returns a `GateResult` with an `exitCode`
6
+ * the CLI wrapper hands back to `git`. Exit codes:
7
+ *
8
+ * - `0` push proceeds (pass, disabled, skipped, empty-diff)
9
+ * - `1` HALT kill-switch active — rea unfreeze required
10
+ * - `2` blocked — blocking verdict, timeout, or protocol error
11
+ *
12
+ * The happy path is a single call: resolve policy → resolve base → spawn
13
+ * codex exec review → parse findings → write last-review.json → emit audit
14
+ * record → return exit code. No cache lookups, no SHA matching, no
15
+ * attestation gymnastics. Every push runs codex afresh; Codex is the
16
+ * source of truth.
17
+ *
18
+ * The function is pure-compositional: every external dependency (git,
19
+ * codex, halt, policy) is injected via `PushGateDeps`, which is the
20
+ * affordance tests use to replace subprocess calls with deterministic
21
+ * fakes. `runPushGate` never reaches for `process.env` or `process.cwd`
22
+ * directly — `deps.env` and `deps.baseDir` are the only ambient state.
23
+ */
24
+ import { appendAuditRecord } from '../../audit/append.js';
25
+ import { type ResolvedReviewPolicy } from './policy.js';
26
+ import { type HaltState } from './halt.js';
27
+ import { runCodexReview, type GitExecutor } from './codex-runner.js';
28
+ import { type Verdict } from './findings.js';
29
+ import { writeLastReview } from './report.js';
30
+ export type GateStatus = 'pass' | 'concerns' | 'blocking' | 'halted' | 'disabled' | 'skipped' | 'empty-diff' | 'error';
31
+ export interface GateResult {
32
+ status: GateStatus;
33
+ exitCode: 0 | 1 | 2;
34
+ /** Human-readable summary suitable for the audit record `metadata.summary`. */
35
+ summary: string;
36
+ /** Non-empty only for 'pass' | 'concerns' | 'blocking'. */
37
+ verdict?: Verdict;
38
+ findingCount?: number;
39
+ baseRef?: string;
40
+ headSha?: string;
41
+ }
42
+ /**
43
+ * A single refspec the pre-push stdin contract yields. Git passes one line
44
+ * per refspec being pushed: `<local_ref> <local_sha> <remote_ref> <remote_sha>`.
45
+ * See githooks(5) — Hook "pre-push".
46
+ */
47
+ export interface PrePushRefspec {
48
+ localRef: string;
49
+ localSha: string;
50
+ remoteRef: string;
51
+ remoteSha: string;
52
+ }
53
+ /**
54
+ * Parse the raw pre-push stdin text into refspecs. Each line is four
55
+ * whitespace-separated fields. Blank lines and malformed lines are
56
+ * silently dropped — the empty result then falls through to the
57
+ * upstream-resolver path in `runPushGate`.
58
+ */
59
+ export declare function parsePrePushStdin(raw: string): PrePushRefspec[];
60
+ export interface PushGateDeps {
61
+ baseDir: string;
62
+ env: NodeJS.ProcessEnv;
63
+ stderr: (line: string) => void;
64
+ /** Override via `--base <ref>`. Absent → auto-resolve. */
65
+ explicitBase?: string;
66
+ /**
67
+ * Override from the `--last-n-commits N` CLI flag. When set, the gate
68
+ * diffs against `HEAD~N` instead of running the upstream ladder. Wins
69
+ * over `policy.review.last_n_commits` but loses to `explicitBase`. When
70
+ * both `explicitBase` and this are set, `explicitBase` is used and a
71
+ * stderr warning is emitted noting the conflict.
72
+ */
73
+ lastNCommits?: number;
74
+ /**
75
+ * Pre-push refspecs from git's stdin. Empty when invoked outside a
76
+ * pre-push context (manual `rea hook push-gate` from the CLI). When
77
+ * non-empty, the gate diffs each refspec's (remote_sha..local_sha) and
78
+ * reviews against the actual push target — matters when the operator
79
+ * does `git push origin HEAD:release/1.0` and the tracking branch is
80
+ * a different branch entirely.
81
+ */
82
+ refspecs?: PrePushRefspec[];
83
+ /** Test seams; production wires these to the real implementations. */
84
+ git?: GitExecutor;
85
+ resolvePolicy?: (baseDir: string) => Promise<ResolvedReviewPolicy>;
86
+ readHalt?: (baseDir: string) => HaltState;
87
+ runCodex?: typeof runCodexReview;
88
+ writeLastReview?: typeof writeLastReview;
89
+ appendAudit?: typeof appendAuditRecord;
90
+ now?: () => Date;
91
+ }
92
+ export declare function runPushGate(deps: PushGateDeps): Promise<GateResult>;
93
+ export { resolvePushGatePolicy } from './policy.js';
94
+ export { readHalt } from './halt.js';
95
+ export { resolveBaseRef } from './base.js';
96
+ export { runCodexReview, createRealGitExecutor } from './codex-runner.js';
97
+ export { summarizeReview, parseFindings, inferVerdict } from './findings.js';
98
+ export { writeLastReview, renderBanner } from './report.js';
@@ -0,0 +1,416 @@
1
+ /**
2
+ * Push-gate composition — the pure orchestrator that `rea hook push-gate`
3
+ * calls.
4
+ *
5
+ * Contract: `runPushGate(deps)` returns a `GateResult` with an `exitCode`
6
+ * the CLI wrapper hands back to `git`. Exit codes:
7
+ *
8
+ * - `0` push proceeds (pass, disabled, skipped, empty-diff)
9
+ * - `1` HALT kill-switch active — rea unfreeze required
10
+ * - `2` blocked — blocking verdict, timeout, or protocol error
11
+ *
12
+ * The happy path is a single call: resolve policy → resolve base → spawn
13
+ * codex exec review → parse findings → write last-review.json → emit audit
14
+ * record → return exit code. No cache lookups, no SHA matching, no
15
+ * attestation gymnastics. Every push runs codex afresh; Codex is the
16
+ * source of truth.
17
+ *
18
+ * The function is pure-compositional: every external dependency (git,
19
+ * codex, halt, policy) is injected via `PushGateDeps`, which is the
20
+ * affordance tests use to replace subprocess calls with deterministic
21
+ * fakes. `runPushGate` never reaches for `process.env` or `process.cwd`
22
+ * directly — `deps.env` and `deps.baseDir` are the only ambient state.
23
+ */
24
+ import path from 'node:path';
25
+ import { appendAuditRecord } from '../../audit/append.js';
26
+ import { Tier, InvocationStatus } from '../../policy/types.js';
27
+ import { resolvePushGatePolicy, } from './policy.js';
28
+ import { readHalt } from './halt.js';
29
+ import { resolveBaseRef } from './base.js';
30
+ import { createRealGitExecutor, runCodexReview, CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, } from './codex-runner.js';
31
+ import { summarizeReview } from './findings.js';
32
+ import { renderBanner, writeLastReview } from './report.js';
33
+ /**
34
+ * Parse the raw pre-push stdin text into refspecs. Each line is four
35
+ * whitespace-separated fields. Blank lines and malformed lines are
36
+ * silently dropped — the empty result then falls through to the
37
+ * upstream-resolver path in `runPushGate`.
38
+ */
39
+ export function parsePrePushStdin(raw) {
40
+ const out = [];
41
+ for (const line of raw.split(/\r?\n/)) {
42
+ const trimmed = line.trim();
43
+ if (trimmed.length === 0)
44
+ continue;
45
+ const fields = trimmed.split(/\s+/);
46
+ if (fields.length !== 4)
47
+ continue;
48
+ const [localRef, localSha, remoteRef, remoteSha] = fields;
49
+ if (typeof localRef !== 'string' ||
50
+ typeof localSha !== 'string' ||
51
+ typeof remoteRef !== 'string' ||
52
+ typeof remoteSha !== 'string') {
53
+ continue;
54
+ }
55
+ out.push({ localRef, localSha, remoteRef, remoteSha });
56
+ }
57
+ return out;
58
+ }
59
+ /**
60
+ * Well-known "null SHA" in git's wire format. Pre-push sends this as
61
+ * `remote_sha` for a fresh remote ref (the branch doesn't exist yet on
62
+ * the remote) and as `local_sha` for a branch deletion.
63
+ */
64
+ const NULL_SHA = '0000000000000000000000000000000000000000';
65
+ // ---------------------------------------------------------------------------
66
+ // Audit event names (advisory — no gate ever reads these back)
67
+ // ---------------------------------------------------------------------------
68
+ const AUDIT_SERVER_NAME = 'rea';
69
+ const EVT_REVIEWED = 'rea.push_gate.reviewed';
70
+ const EVT_HALTED = 'rea.push_gate.halted';
71
+ const EVT_DISABLED = 'rea.push_gate.disabled';
72
+ const EVT_SKIPPED = 'rea.push_gate.skipped';
73
+ const EVT_EMPTY = 'rea.push_gate.empty_diff';
74
+ const EVT_ERROR = 'rea.push_gate.error';
75
+ // ---------------------------------------------------------------------------
76
+ // Composer
77
+ // ---------------------------------------------------------------------------
78
+ export async function runPushGate(deps) {
79
+ const stderr = deps.stderr;
80
+ const env = deps.env;
81
+ const readHaltFn = deps.readHalt ?? readHalt;
82
+ const resolvePolicyFn = deps.resolvePolicy ?? resolvePushGatePolicy;
83
+ const writeLastReviewFn = deps.writeLastReview ?? writeLastReview;
84
+ const runCodexFn = deps.runCodex ?? runCodexReview;
85
+ const appendAuditFn = deps.appendAudit ?? appendAuditRecord;
86
+ const git = deps.git ?? createRealGitExecutor(deps.baseDir);
87
+ // 1. HALT wins over everything, including `review.codex_required: false`.
88
+ // Reading it before policy also means a corrupted policy.yaml doesn't
89
+ // prevent the kill-switch from firing.
90
+ const halt = readHaltFn(deps.baseDir);
91
+ if (halt.halted) {
92
+ stderr(`REA HALT: ${halt.reason ?? 'unknown'}\nAll push operations suspended. Run: rea unfreeze\n`);
93
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_HALTED, {
94
+ reason: halt.reason ?? 'unknown',
95
+ });
96
+ return {
97
+ status: 'halted',
98
+ exitCode: 1,
99
+ summary: `HALT active: ${halt.reason ?? 'unknown'}`,
100
+ };
101
+ }
102
+ // 2. Load policy. A malformed policy.yaml surfaces as a thrown zod error;
103
+ // we catch it, audit, and exit 2 rather than silently bypass.
104
+ let policy;
105
+ try {
106
+ policy = await resolvePolicyFn(deps.baseDir);
107
+ }
108
+ catch (e) {
109
+ const msg = e instanceof Error ? e.message : String(e);
110
+ stderr(`PUSH BLOCKED: failed to load .rea/policy.yaml — ${msg}\n`);
111
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, {
112
+ kind: 'policy-load',
113
+ error: msg,
114
+ });
115
+ return { status: 'error', exitCode: 2, summary: `policy-load error: ${msg}` };
116
+ }
117
+ if (!policy.codex_required) {
118
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_DISABLED, {
119
+ policy_missing: policy.policyMissing,
120
+ });
121
+ return {
122
+ status: 'disabled',
123
+ exitCode: 0,
124
+ summary: 'review.codex_required is false — push-gate skipped',
125
+ };
126
+ }
127
+ // 3. Value-carrying skip waivers. HALT-wins ordering means these are
128
+ // checked AFTER halt (step 1) and AFTER codex_required=false
129
+ // short-circuit (step 2). Both of those should hold anyway; this is
130
+ // for the case where codex IS required but the operator wants to
131
+ // skip for a narrow, documented reason.
132
+ //
133
+ // Two equivalent env vars are honored — gate behavior is identical;
134
+ // only the audit metadata's `skip_var` differs so operators can grep
135
+ // their audit log to see which variant agents used:
136
+ //
137
+ // - REA_SKIP_PUSH_GATE — the original 0.11.0 var
138
+ // - REA_SKIP_CODEX_REVIEW — added in 0.12.0 to match the variant
139
+ // documented elsewhere in the codebase
140
+ // (gateway/reviewers, codex-probe). Prior
141
+ // to 0.12.0 this string only worked at
142
+ // the gateway tier; agents who set it on
143
+ // a `git push` got no skip and codex still
144
+ // ran. The mismatch surfaced during the
145
+ // helixir migration session 2026-04-26.
146
+ //
147
+ // Precedence on simultaneous set: REA_SKIP_PUSH_GATE wins (it was the
148
+ // canonical name) and REA_SKIP_CODEX_REVIEW is logged but not used.
149
+ // Either var alone with non-empty reason short-circuits.
150
+ const skipPush = (env.REA_SKIP_PUSH_GATE ?? '').trim();
151
+ const skipCodex = (env.REA_SKIP_CODEX_REVIEW ?? '').trim();
152
+ if (skipPush.length > 0 || skipCodex.length > 0) {
153
+ const skipVar = skipPush.length > 0 ? 'REA_SKIP_PUSH_GATE' : 'REA_SKIP_CODEX_REVIEW';
154
+ const skipReason = skipVar === 'REA_SKIP_PUSH_GATE' ? skipPush : skipCodex;
155
+ stderr(`rea: ${skipVar}=${skipReason} — push-gate skipped (audited).\n`);
156
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_SKIPPED, {
157
+ reason: skipReason,
158
+ skip_var: skipVar,
159
+ });
160
+ return {
161
+ status: 'skipped',
162
+ exitCode: 0,
163
+ summary: `${skipVar} waiver: ${skipReason}`,
164
+ };
165
+ }
166
+ // 4. Resolve (base_ref, head_sha) for the actual review.
167
+ //
168
+ // Precedence (highest first):
169
+ // a) `--base <ref>` CLI flag (deps.explicitBase) — explicit ref the
170
+ // operator named; we trust it.
171
+ // b) `--last-n-commits N` CLI flag (deps.lastNCommits) — diff
172
+ // against HEAD~N. Wins over the policy key.
173
+ // c) `policy.review.last_n_commits` — same effect as (b), but
174
+ // configured in `.rea/policy.yaml`. Persistent narrow-window.
175
+ // d) Active refspec from pre-push stdin — what git is about to
176
+ // push. Critical for `git push origin HEAD:release/1.0`.
177
+ // e) Upstream → origin/HEAD → main/master ladder.
178
+ //
179
+ // When (a) collides with (b) or (c), (a) wins and we warn — explicit
180
+ // ref beats relative count.
181
+ const policyLastN = policy.last_n_commits;
182
+ const explicitBaseSet = deps.explicitBase !== undefined && deps.explicitBase.length > 0;
183
+ const lastNFromFlag = deps.lastNCommits;
184
+ const effectiveLastN = lastNFromFlag !== undefined ? lastNFromFlag : policyLastN;
185
+ if (explicitBaseSet && effectiveLastN !== undefined) {
186
+ const source = lastNFromFlag !== undefined ? '--last-n-commits' : 'policy.review.last_n_commits';
187
+ stderr(`rea: --base ${deps.explicitBase} overrides ${source}=${effectiveLastN}; using explicit ref.\n`);
188
+ }
189
+ const activeRefspec = (deps.refspecs ?? []).find((r) => r.localSha !== NULL_SHA && r.localSha.length > 0);
190
+ let base;
191
+ let headSha;
192
+ if (explicitBaseSet) {
193
+ // (a) explicit base wins absolutely.
194
+ base = resolveBaseRef(git, { explicit: deps.explicitBase });
195
+ headSha = activeRefspec !== undefined ? activeRefspec.localSha : git.headSha();
196
+ }
197
+ else if (effectiveLastN !== undefined && effectiveLastN > 0) {
198
+ // (b) / (c) last-n-commits. Resolves to a SHA via `git rev-parse
199
+ // <headRef>~N`. Compute headSha FIRST so the resolver walks back N
200
+ // commits from the pushed ref rather than the local HEAD — critical
201
+ // for `git push origin some-other-branch` where the active refspec's
202
+ // localSha is a different branch entirely from the checkout's HEAD.
203
+ headSha = activeRefspec !== undefined ? activeRefspec.localSha : git.headSha();
204
+ base = resolveBaseRef(git, {
205
+ lastNCommits: effectiveLastN,
206
+ headRef: headSha,
207
+ });
208
+ if (base.lastNCommitsRequested !== undefined &&
209
+ base.lastNCommits !== undefined &&
210
+ base.lastNCommits < base.lastNCommitsRequested) {
211
+ // Clamp warning: the resolver couldn't go back N commits, so it
212
+ // clamped to the entire branch history (diff vs empty-tree, K+1
213
+ // commits reviewed) — `base.lastNCommits` carries the actual K+1.
214
+ // This warning fires both when source is 'last-n-commits' (clamped
215
+ // mid-branch, root commit included via empty-tree) and when source
216
+ // is 'empty-tree' (single-commit branch). The user-facing message
217
+ // is identical: we wanted N, got K, here's what we reviewed.
218
+ stderr(`rea: ${headSha.slice(0, 12)}~${base.lastNCommitsRequested} not reachable; reviewing all ${base.lastNCommits} commits on this branch instead.\n`);
219
+ }
220
+ }
221
+ else if (activeRefspec !== undefined) {
222
+ // (d) refspec-aware base — use what git is about to push.
223
+ headSha = activeRefspec.localSha;
224
+ if (activeRefspec.remoteSha === NULL_SHA || activeRefspec.remoteSha.length === 0) {
225
+ // New remote ref — no existing commits to diff against. Fall back to
226
+ // the resolver ladder so we still get a meaningful review (e.g. vs
227
+ // origin/main) rather than an empty-tree diff of everything.
228
+ base = resolveBaseRef(git);
229
+ }
230
+ else {
231
+ base = { ref: activeRefspec.remoteSha, source: 'explicit' };
232
+ }
233
+ }
234
+ else {
235
+ // (e) upstream ladder.
236
+ base = resolveBaseRef(git);
237
+ headSha = git.headSha();
238
+ }
239
+ if (headSha.length === 0) {
240
+ stderr('PUSH BLOCKED: could not resolve HEAD SHA. Is this a valid git repo?\n');
241
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, { kind: 'head-sha-missing' });
242
+ return { status: 'error', exitCode: 2, summary: 'head-sha-missing' };
243
+ }
244
+ // 5. Empty-diff short-circuit. An initial push against the empty-tree
245
+ // sentinel ALWAYS has a non-empty diff (HEAD vs empty tree); this
246
+ // short-circuit only fires when the feature branch really is a
247
+ // no-op relative to base.
248
+ const diff = git.diffNames(base.ref, headSha);
249
+ if (diff.length === 0) {
250
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_EMPTY, {
251
+ base_ref: base.ref,
252
+ base_source: base.source,
253
+ head_sha: headSha,
254
+ last_n_commits: base.lastNCommits,
255
+ last_n_commits_requested: base.lastNCommitsRequested,
256
+ });
257
+ return {
258
+ status: 'empty-diff',
259
+ exitCode: 0,
260
+ summary: 'empty diff — nothing to review',
261
+ baseRef: base.ref,
262
+ headSha,
263
+ };
264
+ }
265
+ // 6. Run Codex. Typed errors translate to exit 2 with distinct stderr.
266
+ try {
267
+ const codexResult = await runCodexFn({
268
+ baseRef: base.ref,
269
+ cwd: deps.baseDir,
270
+ timeoutMs: policy.timeout_ms,
271
+ env,
272
+ });
273
+ const summary = summarizeReview(codexResult.reviewText);
274
+ const blocked = summary.verdict === 'blocking'
275
+ || (summary.verdict === 'concerns'
276
+ && policy.concerns_blocks
277
+ && !isConcernsOverrideSet(env));
278
+ const lastReviewPath = path.join(deps.baseDir, '.rea', 'last-review.json');
279
+ const payload = writeLastReviewFn({
280
+ baseDir: deps.baseDir,
281
+ summary,
282
+ baseRef: base.ref,
283
+ headSha,
284
+ eventCount: codexResult.eventCount,
285
+ durationSeconds: codexResult.durationSeconds,
286
+ ...(deps.now !== undefined ? { now: deps.now() } : {}),
287
+ });
288
+ stderr(renderBanner({
289
+ payload,
290
+ baseSource: base.source,
291
+ blocked,
292
+ lastReviewPath,
293
+ }));
294
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_REVIEWED, {
295
+ verdict: summary.verdict,
296
+ finding_count: summary.findings.length,
297
+ base_ref: base.ref,
298
+ base_source: base.source,
299
+ head_sha: headSha,
300
+ blocked,
301
+ duration_seconds: codexResult.durationSeconds,
302
+ event_count: codexResult.eventCount,
303
+ concerns_override: summary.verdict === 'concerns' && isConcernsOverrideSet(env) ? true : undefined,
304
+ last_n_commits: base.lastNCommits,
305
+ last_n_commits_requested: base.lastNCommitsRequested,
306
+ });
307
+ if (blocked) {
308
+ return {
309
+ status: summary.verdict === 'blocking' ? 'blocking' : 'concerns',
310
+ exitCode: 2,
311
+ summary: `${summary.verdict}: ${summary.findings.length} finding(s)`,
312
+ verdict: summary.verdict,
313
+ findingCount: summary.findings.length,
314
+ baseRef: base.ref,
315
+ headSha,
316
+ };
317
+ }
318
+ return {
319
+ status: summary.verdict === 'blocking'
320
+ ? 'blocking'
321
+ : summary.verdict === 'concerns'
322
+ ? 'concerns'
323
+ : 'pass',
324
+ exitCode: 0,
325
+ summary: `${summary.verdict}: ${summary.findings.length} finding(s)`,
326
+ verdict: summary.verdict,
327
+ findingCount: summary.findings.length,
328
+ baseRef: base.ref,
329
+ headSha,
330
+ };
331
+ }
332
+ catch (e) {
333
+ return handleCodexError(e, deps, base, headSha, appendAuditFn);
334
+ }
335
+ }
336
+ function isConcernsOverrideSet(env) {
337
+ const raw = env.REA_ALLOW_CONCERNS;
338
+ if (raw === undefined)
339
+ return false;
340
+ const normalized = raw.trim().toLowerCase();
341
+ return normalized === '1' || normalized === 'true' || normalized === 'yes';
342
+ }
343
+ async function handleCodexError(e, deps, base, headSha, appendAuditFn) {
344
+ const stderr = deps.stderr;
345
+ const runError = classifyCodexError(e);
346
+ const metadata = {
347
+ base_ref: base.ref,
348
+ base_source: base.source,
349
+ head_sha: headSha,
350
+ kind: runError.kind,
351
+ };
352
+ if (runError.message.length > 0)
353
+ metadata.error = runError.message;
354
+ stderr(`PUSH BLOCKED: ${runError.message}\n`);
355
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, metadata);
356
+ return {
357
+ status: 'error',
358
+ exitCode: 2,
359
+ summary: `codex error (${runError.kind}): ${runError.message}`,
360
+ baseRef: base.ref,
361
+ headSha,
362
+ };
363
+ }
364
+ function classifyCodexError(e) {
365
+ if (e instanceof CodexNotInstalledError)
366
+ return { kind: 'not-installed', message: e.message };
367
+ if (e instanceof CodexTimeoutError)
368
+ return { kind: 'timeout', message: e.message };
369
+ if (e instanceof CodexProtocolError)
370
+ return { kind: 'protocol', message: e.message };
371
+ if (e instanceof CodexSubprocessError)
372
+ return { kind: 'subprocess', message: e.message };
373
+ if (e instanceof Error)
374
+ return { kind: 'unknown', message: e.message };
375
+ return { kind: 'unknown', message: String(e) };
376
+ }
377
+ /**
378
+ * Audit-record helper. Never throws — audit failures are themselves audited
379
+ * (best-effort warn to stderr) but must not prevent the gate from returning
380
+ * its primary result. The hash chain remains intact if this succeeds; on
381
+ * failure we've already made the gate decision based on the actual review.
382
+ */
383
+ async function safeAppend(appendFn, baseDir, toolName, metadata) {
384
+ try {
385
+ // Prune undefined values — the audit record schema's `metadata` is an
386
+ // arbitrary map, but `undefined` values cause JSON.stringify to emit
387
+ // missing keys which breaks round-trips on some readers.
388
+ const cleanMeta = {};
389
+ for (const [k, v] of Object.entries(metadata)) {
390
+ if (v !== undefined)
391
+ cleanMeta[k] = v;
392
+ }
393
+ await appendFn(baseDir, {
394
+ tool_name: toolName,
395
+ server_name: AUDIT_SERVER_NAME,
396
+ tier: Tier.Read,
397
+ status: InvocationStatus.Allowed,
398
+ ...(Object.keys(cleanMeta).length > 0 ? { metadata: cleanMeta } : {}),
399
+ });
400
+ }
401
+ catch (e) {
402
+ // Audit persistence failure should never cascade into a push block when
403
+ // the gate itself decided to pass — but we do want operator visibility.
404
+ const msg = e instanceof Error ? e.message : String(e);
405
+ // Use the deps.stderr is unavailable here (different stack frame); write
406
+ // directly to process.stderr as a fallback.
407
+ process.stderr.write(`rea: audit append failed (${toolName}): ${msg}\n`);
408
+ }
409
+ }
410
+ // Re-exports for the CLI wrapper so it can construct dependency defaults.
411
+ export { resolvePushGatePolicy } from './policy.js';
412
+ export { readHalt } from './halt.js';
413
+ export { resolveBaseRef } from './base.js';
414
+ export { runCodexReview, createRealGitExecutor } from './codex-runner.js';
415
+ export { summarizeReview, parseFindings, inferVerdict } from './findings.js';
416
+ export { writeLastReview, renderBanner } from './report.js';
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Push-gate policy resolution.
3
+ *
4
+ * Loads `.rea/policy.yaml` via the shared loader and flattens the subset the
5
+ * gate cares about into a single `ResolvedReviewPolicy`. Env-var overrides
6
+ * (`REA_SKIP_PUSH_GATE`, `REA_ALLOW_CONCERNS`) are NOT consumed here — the
7
+ * gate composition in `./index.ts` inspects them directly after policy load
8
+ * so the audit trail can distinguish "policy says skip" from "env says
9
+ * skip". This module is pure policy.
10
+ *
11
+ * Defaults (when a field is absent or `review:` is missing entirely):
12
+ * - `codex_required` → `true` (safe-by-default: run Codex)
13
+ * - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
14
+ * - `timeout_ms` → 1_800_000 (30 minutes — raised in 0.12.0 from the
15
+ * previous 10-minute default after the
16
+ * helixir migration session 2026-04-26
17
+ * showed realistic feature-branch
18
+ * reviews routinely exceeded 10 minutes
19
+ * on large diffs. Operators who pin
20
+ * `timeout_ms:` in policy.yaml are
21
+ * unaffected by this change.)
22
+ *
23
+ * A missing `.rea/policy.yaml` is treated as "defaults apply" — the
24
+ * operator may not have run `rea init` yet, and the gate's behavior
25
+ * should match the most protective stance available. The caller is free
26
+ * to treat `policyMissing: true` as a doctor finding.
27
+ */
28
+ export interface ResolvedReviewPolicy {
29
+ codex_required: boolean;
30
+ concerns_blocks: boolean;
31
+ timeout_ms: number;
32
+ /**
33
+ * When set, the gate resolves the diff base to `HEAD~N` (see Fix D in
34
+ * 0.12.0). The CLI flag `--last-n-commits N` overrides this; the
35
+ * policy key surfaces here as a runtime knob with the same effect.
36
+ * `undefined` when unset (default-untouched behavior).
37
+ */
38
+ last_n_commits: number | undefined;
39
+ /** `true` when `.rea/policy.yaml` was absent; defaults apply. */
40
+ policyMissing: boolean;
41
+ }
42
+ export declare const PUSH_GATE_DEFAULT_TIMEOUT_MS = 1800000;
43
+ export declare const PUSH_GATE_DEFAULT_CODEX_REQUIRED = true;
44
+ export declare const PUSH_GATE_DEFAULT_CONCERNS_BLOCKS = true;
45
+ /**
46
+ * Resolve the push-gate policy for `baseDir`. Never throws — a malformed
47
+ * policy file surfaces as a typed error via the underlying zod validator,
48
+ * which we re-raise. The gate's `runPushGate()` catches that and returns
49
+ * `{ status: 'error', exitCode: 2 }` rather than silently bypassing.
50
+ *
51
+ * Returning a fully-populated object (no `undefined` knobs) means every
52
+ * downstream module can treat the policy as total — no `?? default` dance
53
+ * at each call site.
54
+ */
55
+ export declare function resolvePushGatePolicy(baseDir: string): Promise<ResolvedReviewPolicy>;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Push-gate policy resolution.
3
+ *
4
+ * Loads `.rea/policy.yaml` via the shared loader and flattens the subset the
5
+ * gate cares about into a single `ResolvedReviewPolicy`. Env-var overrides
6
+ * (`REA_SKIP_PUSH_GATE`, `REA_ALLOW_CONCERNS`) are NOT consumed here — the
7
+ * gate composition in `./index.ts` inspects them directly after policy load
8
+ * so the audit trail can distinguish "policy says skip" from "env says
9
+ * skip". This module is pure policy.
10
+ *
11
+ * Defaults (when a field is absent or `review:` is missing entirely):
12
+ * - `codex_required` → `true` (safe-by-default: run Codex)
13
+ * - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
14
+ * - `timeout_ms` → 1_800_000 (30 minutes — raised in 0.12.0 from the
15
+ * previous 10-minute default after the
16
+ * helixir migration session 2026-04-26
17
+ * showed realistic feature-branch
18
+ * reviews routinely exceeded 10 minutes
19
+ * on large diffs. Operators who pin
20
+ * `timeout_ms:` in policy.yaml are
21
+ * unaffected by this change.)
22
+ *
23
+ * A missing `.rea/policy.yaml` is treated as "defaults apply" — the
24
+ * operator may not have run `rea init` yet, and the gate's behavior
25
+ * should match the most protective stance available. The caller is free
26
+ * to treat `policyMissing: true` as a doctor finding.
27
+ */
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ import { loadPolicyAsync } from '../../policy/loader.js';
31
+ export const PUSH_GATE_DEFAULT_TIMEOUT_MS = 1_800_000;
32
+ export const PUSH_GATE_DEFAULT_CODEX_REQUIRED = true;
33
+ export const PUSH_GATE_DEFAULT_CONCERNS_BLOCKS = true;
34
+ /**
35
+ * Resolve the push-gate policy for `baseDir`. Never throws — a malformed
36
+ * policy file surfaces as a typed error via the underlying zod validator,
37
+ * which we re-raise. The gate's `runPushGate()` catches that and returns
38
+ * `{ status: 'error', exitCode: 2 }` rather than silently bypassing.
39
+ *
40
+ * Returning a fully-populated object (no `undefined` knobs) means every
41
+ * downstream module can treat the policy as total — no `?? default` dance
42
+ * at each call site.
43
+ */
44
+ export async function resolvePushGatePolicy(baseDir) {
45
+ const policyPath = path.join(baseDir, '.rea', 'policy.yaml');
46
+ if (!fs.existsSync(policyPath)) {
47
+ return {
48
+ codex_required: PUSH_GATE_DEFAULT_CODEX_REQUIRED,
49
+ concerns_blocks: PUSH_GATE_DEFAULT_CONCERNS_BLOCKS,
50
+ timeout_ms: PUSH_GATE_DEFAULT_TIMEOUT_MS,
51
+ last_n_commits: undefined,
52
+ policyMissing: true,
53
+ };
54
+ }
55
+ const policy = await loadPolicyAsync(baseDir);
56
+ const review = policy.review ?? {};
57
+ return {
58
+ codex_required: review.codex_required ?? PUSH_GATE_DEFAULT_CODEX_REQUIRED,
59
+ concerns_blocks: review.concerns_blocks ?? PUSH_GATE_DEFAULT_CONCERNS_BLOCKS,
60
+ timeout_ms: review.timeout_ms ?? PUSH_GATE_DEFAULT_TIMEOUT_MS,
61
+ last_n_commits: review.last_n_commits,
62
+ policyMissing: false,
63
+ };
64
+ }