@bookedsolid/rea 0.11.0 → 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.
@@ -124,38 +124,102 @@ export async function runPushGate(deps) {
124
124
  summary: 'review.codex_required is false — push-gate skipped',
125
125
  };
126
126
  }
127
- // 3. REA_SKIP_PUSH_GATE — value-carrying waiver. HALT-wins ordering means
128
- // this is checked AFTER halt (step 1) and AFTER codex_required=false
127
+ // 3. Value-carrying skip waivers. HALT-wins ordering means these are
128
+ // checked AFTER halt (step 1) and AFTER codex_required=false
129
129
  // short-circuit (step 2). Both of those should hold anyway; this is
130
- // for the case where codex is required but the operator wants to
130
+ // for the case where codex IS required but the operator wants to
131
131
  // skip for a narrow, documented reason.
132
- const skipReason = (env.REA_SKIP_PUSH_GATE ?? '').trim();
133
- if (skipReason.length > 0) {
134
- stderr(`rea: REA_SKIP_PUSH_GATE=${skipReason} push-gate skipped (audited).\n`);
132
+ //
133
+ // Two equivalent env vars are honored — gate behavior is identical;
134
+ // only the audit metadata's `skip_var` differs so operators can grep
135
+ // their audit log to see which variant agents used:
136
+ //
137
+ // - REA_SKIP_PUSH_GATE — the original 0.11.0 var
138
+ // - REA_SKIP_CODEX_REVIEW — added in 0.12.0 to match the variant
139
+ // documented elsewhere in the codebase
140
+ // (gateway/reviewers, codex-probe). Prior
141
+ // to 0.12.0 this string only worked at
142
+ // the gateway tier; agents who set it on
143
+ // a `git push` got no skip and codex still
144
+ // ran. The mismatch surfaced during the
145
+ // helixir migration session 2026-04-26.
146
+ //
147
+ // Precedence on simultaneous set: REA_SKIP_PUSH_GATE wins (it was the
148
+ // canonical name) and REA_SKIP_CODEX_REVIEW is logged but not used.
149
+ // Either var alone with non-empty reason short-circuits.
150
+ const skipPush = (env.REA_SKIP_PUSH_GATE ?? '').trim();
151
+ const skipCodex = (env.REA_SKIP_CODEX_REVIEW ?? '').trim();
152
+ if (skipPush.length > 0 || skipCodex.length > 0) {
153
+ const skipVar = skipPush.length > 0 ? 'REA_SKIP_PUSH_GATE' : 'REA_SKIP_CODEX_REVIEW';
154
+ const skipReason = skipVar === 'REA_SKIP_PUSH_GATE' ? skipPush : skipCodex;
155
+ stderr(`rea: ${skipVar}=${skipReason} — push-gate skipped (audited).\n`);
135
156
  await safeAppend(appendAuditFn, deps.baseDir, EVT_SKIPPED, {
136
157
  reason: skipReason,
158
+ skip_var: skipVar,
137
159
  });
138
160
  return {
139
161
  status: 'skipped',
140
162
  exitCode: 0,
141
- summary: `REA_SKIP_PUSH_GATE waiver: ${skipReason}`,
163
+ summary: `${skipVar} waiver: ${skipReason}`,
142
164
  };
143
165
  }
144
166
  // 4. Resolve (base_ref, head_sha) for the actual review.
145
167
  //
146
- // When pre-push stdin yielded at least one refspec (`git push` path),
147
- // diff against the first NON-DELETION refspec's (remote_sha..local_sha).
148
- // This matches what git itself is about to push — critical when the
149
- // operator uses `git push origin HEAD:release/1.0` and the branch's
150
- // tracking ref is a different branch entirely (the 0.10.x gate
151
- // silently reviewed against the wrong base in that case).
168
+ // Precedence (highest first):
169
+ // a) `--base <ref>` CLI flag (deps.explicitBase) — explicit ref the
170
+ // operator named; we trust it.
171
+ // b) `--last-n-commits N` CLI flag (deps.lastNCommits) diff
172
+ // against HEAD~N. Wins over the policy key.
173
+ // c) `policy.review.last_n_commits` same effect as (b), but
174
+ // configured in `.rea/policy.yaml`. Persistent narrow-window.
175
+ // d) Active refspec from pre-push stdin — what git is about to
176
+ // push. Critical for `git push origin HEAD:release/1.0`.
177
+ // e) Upstream → origin/HEAD → main/master ladder.
152
178
  //
153
- // When stdin was empty (manual invocation, test), fall back to the
154
- // upstream origin/HEAD → main/master ladder.
179
+ // When (a) collides with (b) or (c), (a) wins and we warn — explicit
180
+ // ref beats relative count.
181
+ const policyLastN = policy.last_n_commits;
182
+ const explicitBaseSet = deps.explicitBase !== undefined && deps.explicitBase.length > 0;
183
+ const lastNFromFlag = deps.lastNCommits;
184
+ const effectiveLastN = lastNFromFlag !== undefined ? lastNFromFlag : policyLastN;
185
+ if (explicitBaseSet && effectiveLastN !== undefined) {
186
+ const source = lastNFromFlag !== undefined ? '--last-n-commits' : 'policy.review.last_n_commits';
187
+ stderr(`rea: --base ${deps.explicitBase} overrides ${source}=${effectiveLastN}; using explicit ref.\n`);
188
+ }
155
189
  const activeRefspec = (deps.refspecs ?? []).find((r) => r.localSha !== NULL_SHA && r.localSha.length > 0);
156
190
  let base;
157
191
  let headSha;
158
- if (activeRefspec !== undefined && (deps.explicitBase === undefined || deps.explicitBase.length === 0)) {
192
+ 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.
159
223
  headSha = activeRefspec.localSha;
160
224
  if (activeRefspec.remoteSha === NULL_SHA || activeRefspec.remoteSha.length === 0) {
161
225
  // New remote ref — no existing commits to diff against. Fall back to
@@ -168,11 +232,8 @@ export async function runPushGate(deps) {
168
232
  }
169
233
  }
170
234
  else {
171
- base = resolveBaseRef(git, {
172
- ...(deps.explicitBase !== undefined && deps.explicitBase.length > 0
173
- ? { explicit: deps.explicitBase }
174
- : {}),
175
- });
235
+ // (e) upstream ladder.
236
+ base = resolveBaseRef(git);
176
237
  headSha = git.headSha();
177
238
  }
178
239
  if (headSha.length === 0) {
@@ -190,6 +251,8 @@ export async function runPushGate(deps) {
190
251
  base_ref: base.ref,
191
252
  base_source: base.source,
192
253
  head_sha: headSha,
254
+ last_n_commits: base.lastNCommits,
255
+ last_n_commits_requested: base.lastNCommitsRequested,
193
256
  });
194
257
  return {
195
258
  status: 'empty-diff',
@@ -238,6 +301,8 @@ export async function runPushGate(deps) {
238
301
  duration_seconds: codexResult.durationSeconds,
239
302
  event_count: codexResult.eventCount,
240
303
  concerns_override: summary.verdict === 'concerns' && isConcernsOverrideSet(env) ? true : undefined,
304
+ last_n_commits: base.lastNCommits,
305
+ last_n_commits_requested: base.lastNCommitsRequested,
241
306
  });
242
307
  if (blocked) {
243
308
  return {
@@ -9,9 +9,16 @@
9
9
  * skip". This module is pure policy.
10
10
  *
11
11
  * Defaults (when a field is absent or `review:` is missing entirely):
12
- * - `codex_required` → `true` (safe-by-default: run Codex)
13
- * - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
14
- * - `timeout_ms` → 600_000 (10 minutes)
12
+ * - `codex_required` → `true` (safe-by-default: run Codex)
13
+ * - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
14
+ * - `timeout_ms` → 1_800_000 (30 minutes — raised in 0.12.0 from the
15
+ * previous 10-minute default after the
16
+ * helixir migration session 2026-04-26
17
+ * showed realistic feature-branch
18
+ * reviews routinely exceeded 10 minutes
19
+ * on large diffs. Operators who pin
20
+ * `timeout_ms:` in policy.yaml are
21
+ * unaffected by this change.)
15
22
  *
16
23
  * A missing `.rea/policy.yaml` is treated as "defaults apply" — the
17
24
  * operator may not have run `rea init` yet, and the gate's behavior
@@ -22,10 +29,17 @@ export interface ResolvedReviewPolicy {
22
29
  codex_required: boolean;
23
30
  concerns_blocks: boolean;
24
31
  timeout_ms: number;
32
+ /**
33
+ * When set, the gate resolves the diff base to `HEAD~N` (see Fix D in
34
+ * 0.12.0). The CLI flag `--last-n-commits N` overrides this; the
35
+ * policy key surfaces here as a runtime knob with the same effect.
36
+ * `undefined` when unset (default-untouched behavior).
37
+ */
38
+ last_n_commits: number | undefined;
25
39
  /** `true` when `.rea/policy.yaml` was absent; defaults apply. */
26
40
  policyMissing: boolean;
27
41
  }
28
- export declare const PUSH_GATE_DEFAULT_TIMEOUT_MS = 600000;
42
+ export declare const PUSH_GATE_DEFAULT_TIMEOUT_MS = 1800000;
29
43
  export declare const PUSH_GATE_DEFAULT_CODEX_REQUIRED = true;
30
44
  export declare const PUSH_GATE_DEFAULT_CONCERNS_BLOCKS = true;
31
45
  /**
@@ -9,9 +9,16 @@
9
9
  * skip". This module is pure policy.
10
10
  *
11
11
  * Defaults (when a field is absent or `review:` is missing entirely):
12
- * - `codex_required` → `true` (safe-by-default: run Codex)
13
- * - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
14
- * - `timeout_ms` → 600_000 (10 minutes)
12
+ * - `codex_required` → `true` (safe-by-default: run Codex)
13
+ * - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
14
+ * - `timeout_ms` → 1_800_000 (30 minutes — raised in 0.12.0 from the
15
+ * previous 10-minute default after the
16
+ * helixir migration session 2026-04-26
17
+ * showed realistic feature-branch
18
+ * reviews routinely exceeded 10 minutes
19
+ * on large diffs. Operators who pin
20
+ * `timeout_ms:` in policy.yaml are
21
+ * unaffected by this change.)
15
22
  *
16
23
  * A missing `.rea/policy.yaml` is treated as "defaults apply" — the
17
24
  * operator may not have run `rea init` yet, and the gate's behavior
@@ -21,7 +28,7 @@
21
28
  import fs from 'node:fs';
22
29
  import path from 'node:path';
23
30
  import { loadPolicyAsync } from '../../policy/loader.js';
24
- export const PUSH_GATE_DEFAULT_TIMEOUT_MS = 600_000;
31
+ export const PUSH_GATE_DEFAULT_TIMEOUT_MS = 1_800_000;
25
32
  export const PUSH_GATE_DEFAULT_CODEX_REQUIRED = true;
26
33
  export const PUSH_GATE_DEFAULT_CONCERNS_BLOCKS = true;
27
34
  /**
@@ -41,6 +48,7 @@ export async function resolvePushGatePolicy(baseDir) {
41
48
  codex_required: PUSH_GATE_DEFAULT_CODEX_REQUIRED,
42
49
  concerns_blocks: PUSH_GATE_DEFAULT_CONCERNS_BLOCKS,
43
50
  timeout_ms: PUSH_GATE_DEFAULT_TIMEOUT_MS,
51
+ last_n_commits: undefined,
44
52
  policyMissing: true,
45
53
  };
46
54
  }
@@ -50,6 +58,7 @@ export async function resolvePushGatePolicy(baseDir) {
50
58
  codex_required: review.codex_required ?? PUSH_GATE_DEFAULT_CODEX_REQUIRED,
51
59
  concerns_blocks: review.concerns_blocks ?? PUSH_GATE_DEFAULT_CONCERNS_BLOCKS,
52
60
  timeout_ms: review.timeout_ms ?? PUSH_GATE_DEFAULT_TIMEOUT_MS,
61
+ last_n_commits: review.last_n_commits,
53
62
  policyMissing: false,
54
63
  };
55
64
  }
@@ -34,14 +34,17 @@ declare const PolicySchema: z.ZodObject<{
34
34
  codex_required: z.ZodOptional<z.ZodBoolean>;
35
35
  concerns_blocks: z.ZodOptional<z.ZodBoolean>;
36
36
  timeout_ms: z.ZodOptional<z.ZodNumber>;
37
+ last_n_commits: z.ZodOptional<z.ZodNumber>;
37
38
  }, "strict", z.ZodTypeAny, {
38
39
  codex_required?: boolean | undefined;
39
40
  concerns_blocks?: boolean | undefined;
40
41
  timeout_ms?: number | undefined;
42
+ last_n_commits?: number | undefined;
41
43
  }, {
42
44
  codex_required?: boolean | undefined;
43
45
  concerns_blocks?: boolean | undefined;
44
46
  timeout_ms?: number | undefined;
47
+ last_n_commits?: number | undefined;
45
48
  }>>;
46
49
  redact: z.ZodOptional<z.ZodObject<{
47
50
  match_timeout_ms: z.ZodOptional<z.ZodNumber>;
@@ -135,6 +138,7 @@ declare const PolicySchema: z.ZodObject<{
135
138
  codex_required?: boolean | undefined;
136
139
  concerns_blocks?: boolean | undefined;
137
140
  timeout_ms?: number | undefined;
141
+ last_n_commits?: number | undefined;
138
142
  } | undefined;
139
143
  redact?: {
140
144
  match_timeout_ms?: number | undefined;
@@ -178,6 +182,7 @@ declare const PolicySchema: z.ZodObject<{
178
182
  codex_required?: boolean | undefined;
179
183
  concerns_blocks?: boolean | undefined;
180
184
  timeout_ms?: number | undefined;
185
+ last_n_commits?: number | undefined;
181
186
  } | undefined;
182
187
  redact?: {
183
188
  match_timeout_ms?: number | undefined;
@@ -27,6 +27,7 @@ const ReviewPolicySchema = z
27
27
  codex_required: z.boolean().optional(),
28
28
  concerns_blocks: z.boolean().optional(),
29
29
  timeout_ms: z.number().int().positive().optional(),
30
+ last_n_commits: z.number().int().positive().optional(),
30
31
  })
31
32
  .strict();
32
33
  /**
@@ -54,12 +54,54 @@ export interface ReviewPolicy {
54
54
  /**
55
55
  * Hard cap on the `codex exec review` subprocess in milliseconds. Exceeding
56
56
  * this kills the subprocess and the gate returns exit 2 with a timeout
57
- * error (audited). Default when unset is 600_000 (10 minutes) matches
58
- * the upper bound we observe for a 500-line diff review on a slow link.
57
+ * error (audited). Default when unset is 1_800_000 (30 minutes) as of
58
+ * 0.12.0 raised from 10 minutes after the helixir migration session
59
+ * 2026-04-26 showed realistic feature-branch diffs routinely exceeded
60
+ * the previous default. Operators with explicit `timeout_ms:` in
61
+ * `.rea/policy.yaml` are unaffected.
59
62
  *
60
63
  * Positive integer only. The loader rejects zero/negative values.
61
64
  */
62
65
  timeout_ms?: number;
66
+ /**
67
+ * When set, `rea hook push-gate` resolves the diff base to `HEAD~N`
68
+ * instead of the upstream → origin/HEAD ladder. Useful when a feature
69
+ * branch accumulates many commits and the full origin/main diff
70
+ * overwhelms the reviewer (the helixir 2026-04-26 case: 50+ commits
71
+ * relative to origin/main produced non-deterministic Codex verdicts and
72
+ * 10-minute timeouts).
73
+ *
74
+ * Precedence: explicit `--base <ref>` flag wins; then `--last-n-commits N`
75
+ * flag; then this policy key; then refspec-aware base resolution; then
76
+ * the upstream-ladder fallback. When `--base` AND
77
+ * `--last-n-commits`/`policy.last_n_commits` are both set, `--base`
78
+ * wins and a stderr warning is emitted.
79
+ *
80
+ * Resolution: `git rev-parse HEAD~N`. When `HEAD~N` is unreachable
81
+ * the resolver consults `git rev-parse --is-shallow-repository` to
82
+ * pick the right clamp:
83
+ *
84
+ * - FULL clone, branch shorter than N: clamps to the empty-tree
85
+ * sentinel so the root commit's changes are included
86
+ * (`git diff base..HEAD` excludes `base`, so diffing against
87
+ * `HEAD~K` would silently drop the root commit). Reports
88
+ * `last_n_commits: K+1` — every commit on the branch reviewed.
89
+ *
90
+ * - SHALLOW clone: clamps to `HEAD~K` (the deepest LOCALLY
91
+ * resolvable ancestor) since older history exists on the remote
92
+ * but isn't fetched. Using empty-tree here would balloon the
93
+ * review to every tracked file in the checkout. Reports
94
+ * `last_n_commits: K`. The K-th commit's content is excluded —
95
+ * accepted as the cost of the shallow clone.
96
+ *
97
+ * A stderr warning surfaces the requested-vs-clamped numbers in
98
+ * both cases. Audit metadata records `base_source: 'last-n-commits'`,
99
+ * `last_n_commits: <count actually reviewed>`, and
100
+ * `last_n_commits_requested: N` (only present when clamped).
101
+ *
102
+ * Positive integer. The loader rejects zero/negative values.
103
+ */
104
+ last_n_commits?: number;
63
105
  }
64
106
  /**
65
107
  * 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.11.0",
3
+ "version": "0.12.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)",
@@ -74,8 +74,13 @@ echo "[smoke] → $VERSION_OUT"
74
74
  echo "[smoke] rea --help"
75
75
  ./node_modules/.bin/rea --help >/dev/null
76
76
 
77
- echo "[smoke] rea init --yes --profile open-source"
78
- ./node_modules/.bin/rea init --yes --profile open-source
77
+ echo "[smoke] rea init --yes --profile open-source-no-codex"
78
+ # 0.12.0+: doctor hard-fails when policy.review.codex_required: true and codex
79
+ # is not on PATH (fix C of the helixir migration unblocker — see PR #85). CI
80
+ # does not provision the codex CLI, so the smoke uses the -no-codex profile
81
+ # variant which defaults codex_required: false. The new doctor probe is
82
+ # covered by unit tests in src/cli/doctor.test.ts.
83
+ ./node_modules/.bin/rea init --yes --profile open-source-no-codex
79
84
 
80
85
  # Verify the installed layout matches what init claims it wrote.
81
86
  for expected in .rea/policy.yaml .rea/registry.yaml .claude/settings.json CLAUDE.md .rea/install-manifest.json; do