@bookedsolid/rea 0.25.0 → 0.26.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.
@@ -0,0 +1,325 @@
1
+ /**
2
+ * `rea review` — local-first codex review CLI (0.26.0+).
3
+ *
4
+ * Runs `codex exec review` against the working tree (or a specified
5
+ * base ref), parses the verdict, and writes a `rea.local_review`
6
+ * audit entry that `rea preflight` consults.
7
+ *
8
+ * Exit codes:
9
+ *
10
+ * 0 — pass (or skipped because `mode: off` + codex unavailable)
11
+ * 1 — concerns (configurable via --strict-fail-on)
12
+ * 2 — blocking, codex error, or codex unavailable in `mode: enforced`
13
+ *
14
+ * Behavior matrix:
15
+ *
16
+ * policy.local_review.mode codex available? result
17
+ * ------------------------ --------------- ----------------------
18
+ * enforced or unset (def.) yes run review, audit
19
+ * enforced or unset (def.) no exit 2 with helpful msg
20
+ * off yes run review, audit
21
+ * off no exit 0, audit skipped
22
+ *
23
+ * The `provider` field on the audit record is `'codex'` today. Future
24
+ * providers (Claude-subagent, Pi, Gemma) write the SAME `rea.local_review`
25
+ * shape with their own `provider:` value — `rea preflight` accepts any.
26
+ *
27
+ * The CLI is a thin wrapper around `runCodexReview` from
28
+ * `src/hooks/push-gate/codex-runner.ts`. We do NOT re-implement codex
29
+ * spawning. The push-gate's iron-gate defaults (gpt-5.4 + high reasoning)
30
+ * apply identically here so a local review carries the same weight as
31
+ * the push-gate's review.
32
+ */
33
+ import path from 'node:path';
34
+ import { spawnSync } from 'node:child_process';
35
+ import { appendAuditRecord } from '../audit/append.js';
36
+ import { LOCAL_REVIEW_TOOL_NAME, LOCAL_REVIEW_SKIPPED_UNAVAILABLE_TOOL_NAME, LOCAL_REVIEW_SERVER_NAME, } from '../audit/local-review-event.js';
37
+ import { Tier, InvocationStatus } from '../policy/types.js';
38
+ import { loadPolicyAsync } from '../policy/loader.js';
39
+ import { CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, IRON_GATE_DEFAULT_MODEL, IRON_GATE_DEFAULT_REASONING, createRealGitExecutor, runCodexReview, } from '../hooks/push-gate/codex-runner.js';
40
+ import { resolvePushGatePolicy } from '../hooks/push-gate/policy.js';
41
+ import { resolveBaseRef } from '../hooks/push-gate/base.js';
42
+ import { summarizeReview } from '../hooks/push-gate/findings.js';
43
+ import { computeTreeToken, EMPTY_TREE_SHA } from '../audit/content-token.js';
44
+ import { err, log } from './utils.js';
45
+ const PROVIDER_CODEX = 'codex';
46
+ /**
47
+ * Probe `codex --version` synchronously. Same shape as the push-gate's
48
+ * pre-flight probe — ENOENT/EACCES means "not installed".
49
+ */
50
+ function probeCodexAvailable(cwd) {
51
+ const probe = spawnSync('codex', ['--version'], {
52
+ cwd,
53
+ timeout: 2000,
54
+ encoding: 'utf8',
55
+ });
56
+ if (probe.error !== undefined) {
57
+ return { available: false };
58
+ }
59
+ if (probe.status !== 0) {
60
+ return { available: false };
61
+ }
62
+ const version = (probe.stdout ?? '').toString().trim();
63
+ return version.length > 0 ? { available: true, version } : { available: true };
64
+ }
65
+ /**
66
+ * Resolve the effective `local_review.mode`. A missing policy is treated
67
+ * as `enforced` — the protective default. A missing `local_review` block
68
+ * also enforces. Only an explicit `mode: off` opts out.
69
+ */
70
+ async function resolveLocalReviewMode(baseDir) {
71
+ let policy;
72
+ try {
73
+ policy = await loadPolicyAsync(baseDir);
74
+ }
75
+ catch {
76
+ // Missing/invalid policy — protective default.
77
+ return { mode: 'enforced', policy: undefined };
78
+ }
79
+ const mode = policy.review?.local_review?.mode ?? 'enforced';
80
+ return { mode, policy };
81
+ }
82
+ /**
83
+ * Public runner — exposed so tests can drive the function in-process and
84
+ * the commander binding can stay thin. Throws via `process.exit` (CLI
85
+ * convention across `src/cli/`).
86
+ */
87
+ export async function runReview(options) {
88
+ const baseDir = process.cwd();
89
+ const strictFailOn = options.strictFailOn ?? 'blocking';
90
+ const { mode, policy } = await resolveLocalReviewMode(baseDir);
91
+ // Probe codex before any heavy lifting so we can branch on availability.
92
+ const probe = probeCodexAvailable(baseDir);
93
+ // Codex unavailable — branch on policy mode.
94
+ if (!probe.available) {
95
+ if (mode === 'off') {
96
+ // Off mode: skip silently and audit so the absence is forensically
97
+ // visible. Exit 0 — the team has explicitly opted out.
98
+ const skipped = {
99
+ reason: 'codex-not-installed',
100
+ provider: PROVIDER_CODEX,
101
+ };
102
+ // Best-effort HEAD probe for the audit record.
103
+ try {
104
+ const git = createRealGitExecutor(baseDir);
105
+ const head = git.headSha();
106
+ if (head.length > 0)
107
+ skipped.head_sha = head;
108
+ }
109
+ catch {
110
+ /* no head — leave undefined */
111
+ }
112
+ await safeAudit(baseDir, LOCAL_REVIEW_SKIPPED_UNAVAILABLE_TOOL_NAME, InvocationStatus.Allowed, skipped, policy);
113
+ if (options.json === true) {
114
+ process.stdout.write(JSON.stringify({ status: 'skipped', reason: 'codex-not-installed' }) + '\n');
115
+ }
116
+ else {
117
+ log('codex not found on PATH — review skipped (policy.review.local_review.mode: off).');
118
+ }
119
+ process.exit(0);
120
+ }
121
+ // Enforced mode: hard-refuse with a helpful message.
122
+ err('codex CLI not found on PATH.');
123
+ console.error('');
124
+ console.error(' Install: npm i -g @openai/codex');
125
+ console.error(' Or set: policy.review.local_review.mode: off');
126
+ console.error(' (in .rea/policy.yaml — disables local-review enforcement');
127
+ console.error(' for teams without codex/claude installed)');
128
+ console.error('');
129
+ process.exit(2);
130
+ }
131
+ // Codex available — run the review.
132
+ let outcome;
133
+ try {
134
+ outcome = await executeCodexReview(baseDir, options);
135
+ }
136
+ catch (e) {
137
+ const msg = e instanceof Error ? e.message : String(e);
138
+ err(`codex review failed: ${msg}`);
139
+ // Audit the error so operators can correlate failures.
140
+ await safeAudit(baseDir, LOCAL_REVIEW_TOOL_NAME, InvocationStatus.Error, {
141
+ provider: PROVIDER_CODEX,
142
+ error: msg,
143
+ kind: classifyCodexError(e),
144
+ }, policy);
145
+ process.exit(2);
146
+ }
147
+ // Write the canonical audit record. THIS is the entry `rea preflight`
148
+ // looks for. Use server_name='rea' (the pre-existing convention) and
149
+ // tool_name='rea.local_review'.
150
+ //
151
+ // 0.26.0 helix-026 finding-1: `content_token` is the field preflight
152
+ // matches coverage on. `head_sha` is recorded for forensics. The token
153
+ // stays optional so legacy `codex.review` entries and future providers
154
+ // that can't compute a tree fingerprint still flow through preflight's
155
+ // back-compat head-sha fallback.
156
+ const metadata = {
157
+ head_sha: outcome.headSha,
158
+ base_ref: outcome.baseRef,
159
+ verdict: outcome.verdict,
160
+ finding_count: outcome.findingCount,
161
+ provider: PROVIDER_CODEX,
162
+ model: outcome.model,
163
+ reasoning_effort: outcome.reasoningEffort,
164
+ duration_seconds: outcome.durationSeconds,
165
+ };
166
+ if (outcome.contentToken.length > 0)
167
+ metadata.content_token = outcome.contentToken;
168
+ if (probe.version !== undefined)
169
+ metadata.provider_version = probe.version;
170
+ await safeAudit(baseDir, LOCAL_REVIEW_TOOL_NAME, outcome.verdict === 'blocking' ? InvocationStatus.Denied : InvocationStatus.Allowed, metadata, policy);
171
+ // Decide exit code based on strictFailOn.
172
+ let exitCode;
173
+ if (outcome.verdict === 'blocking') {
174
+ exitCode = 2;
175
+ }
176
+ else if (outcome.verdict === 'concerns') {
177
+ exitCode = strictFailOn === 'concerns' ? 1 : 0;
178
+ }
179
+ else {
180
+ exitCode = 0;
181
+ }
182
+ if (options.json === true) {
183
+ process.stdout.write(JSON.stringify({
184
+ status: outcome.verdict,
185
+ finding_count: outcome.findingCount,
186
+ head_sha: outcome.headSha,
187
+ base_ref: outcome.baseRef,
188
+ provider: PROVIDER_CODEX,
189
+ model: outcome.model,
190
+ reasoning_effort: outcome.reasoningEffort,
191
+ duration_seconds: outcome.durationSeconds,
192
+ exit_code: exitCode,
193
+ }) + '\n');
194
+ }
195
+ else {
196
+ log(`local review: ${outcome.verdict} (${outcome.findingCount} finding(s)) — head=${outcome.headSha.slice(0, 12)} base=${outcome.baseRef}`);
197
+ log(`audit entry written: tool_name=${LOCAL_REVIEW_TOOL_NAME}`);
198
+ }
199
+ process.exit(exitCode);
200
+ }
201
+ /**
202
+ * Execute the codex review subprocess and translate the output to a
203
+ * verdict. Reuses the push-gate's resolved policy so `codex_model` /
204
+ * `codex_reasoning_effort` / `timeout_ms` flow through identically.
205
+ */
206
+ async function executeCodexReview(baseDir, options) {
207
+ const resolved = await resolvePushGatePolicy(baseDir);
208
+ const git = createRealGitExecutor(baseDir);
209
+ const explicit = options.base !== undefined && options.base.length > 0 ? options.base : undefined;
210
+ const base = explicit !== undefined
211
+ ? resolveBaseRef(git, { explicit })
212
+ : resolveBaseRef(git);
213
+ // 0.26.0 round-25 P2-B fix: do NOT throw on empty HEAD. An unborn-HEAD
214
+ // repo (`git init` + immediately `rea review`, before any commit) is a
215
+ // legitimate scaffolding state — `create-helix-app` and similar tools
216
+ // bootstrap consumer repos this way. Pre-fix, `runReview()` threw
217
+ // "could not resolve HEAD sha — is this a valid git repo?" which under
218
+ // `refuse_at: commit/both` caused a deadlock: the commit-tier hook
219
+ // refused commits until rea review wrote an audit entry, but rea
220
+ // review refused without HEAD.
221
+ //
222
+ // Resolution: when HEAD is unborn, use git's well-known empty-tree
223
+ // SHA as the synthetic head_sha for the audit record. `computeTreeToken`
224
+ // already returns empty cleanly in this state; the working-tree tokens
225
+ // takes over via `git stash create` once the tree is dirty (round-25
226
+ // P1-A path), or remains empty for a truly empty repo. Preflight's
227
+ // content-token match (round-25 P1-A) handles either case.
228
+ //
229
+ // Round-27 F2 fix: EMPTY_TREE_SHA promoted to a shared constant in
230
+ // `src/audit/content-token.ts` so `rea preflight` (reader) uses the
231
+ // SAME value when its `git rev-parse HEAD` probe fails. Without that
232
+ // symmetry the reader returned `''` and short-circuited the lookup,
233
+ // deadlocking the documented `git init → rea review → git commit`
234
+ // bootstrap flow under `refuse_at: both`.
235
+ const resolvedHeadSha = git.headSha();
236
+ const headSha = resolvedHeadSha.length > 0 ? resolvedHeadSha : EMPTY_TREE_SHA;
237
+ // 0.26.0 helix-026 finding-1: capture working-tree token as the content
238
+ // token for preflight coverage matching. Computed BEFORE codex runs so
239
+ // we never race a concurrent commit (the token reflects the state codex
240
+ // is about to review). An empty token is allowed — preflight falls back
241
+ // to head_sha matching when the field is absent.
242
+ const contentToken = computeTreeToken(baseDir);
243
+ const codexResult = await runCodexReview({
244
+ baseRef: base.ref,
245
+ cwd: baseDir,
246
+ timeoutMs: resolved.timeout_ms,
247
+ env: process.env,
248
+ ...(resolved.codex_model !== undefined ? { model: resolved.codex_model } : {}),
249
+ ...(resolved.codex_reasoning_effort !== undefined
250
+ ? { reasoningEffort: resolved.codex_reasoning_effort }
251
+ : {}),
252
+ });
253
+ const summary = summarizeReview(codexResult.reviewText);
254
+ return {
255
+ verdict: summary.verdict,
256
+ findingCount: summary.findings.length,
257
+ baseRef: base.ref,
258
+ headSha,
259
+ contentToken,
260
+ durationSeconds: codexResult.durationSeconds,
261
+ model: resolved.codex_model ?? IRON_GATE_DEFAULT_MODEL,
262
+ reasoningEffort: resolved.codex_reasoning_effort ?? IRON_GATE_DEFAULT_REASONING,
263
+ };
264
+ }
265
+ function classifyCodexError(e) {
266
+ if (e instanceof CodexNotInstalledError)
267
+ return 'not-installed';
268
+ if (e instanceof CodexTimeoutError)
269
+ return 'timeout';
270
+ if (e instanceof CodexProtocolError)
271
+ return 'protocol';
272
+ if (e instanceof CodexSubprocessError)
273
+ return 'subprocess';
274
+ return 'unknown';
275
+ }
276
+ /**
277
+ * Best-effort audit append — never throws. An audit failure must not
278
+ * change the CLI exit code.
279
+ */
280
+ async function safeAudit(baseDir, toolName, status, metadata, policy) {
281
+ try {
282
+ const cleanMeta = {};
283
+ for (const [k, v] of Object.entries(metadata)) {
284
+ if (v !== undefined)
285
+ cleanMeta[k] = v;
286
+ }
287
+ await appendAuditRecord(baseDir, {
288
+ tool_name: toolName,
289
+ server_name: LOCAL_REVIEW_SERVER_NAME,
290
+ tier: Tier.Read,
291
+ status,
292
+ ...(Object.keys(cleanMeta).length > 0 ? { metadata: cleanMeta } : {}),
293
+ ...(policy !== undefined ? { policy } : {}),
294
+ });
295
+ }
296
+ catch (e) {
297
+ const msg = e instanceof Error ? e.message : String(e);
298
+ process.stderr.write(`rea: audit append failed (${toolName}): ${msg}\n`);
299
+ }
300
+ }
301
+ /**
302
+ * Attach `rea review` to a commander Program.
303
+ */
304
+ export function registerReviewCommand(program) {
305
+ program
306
+ .command('review')
307
+ .description('Run a local codex adversarial review of the working tree, write a `rea.local_review` audit entry, and exit 0 (pass), 1 (concerns), or 2 (blocking). The push-gate is the BACKUP layer — this is the primary review surface.')
308
+ .option('--base <ref>', 'explicit base ref to diff against (default: @{upstream} → origin/HEAD → main/master)')
309
+ .option('--strict-fail-on <level>', 'verdict floor that triggers non-zero exit: `concerns` or `blocking` (default `blocking`)', (raw) => {
310
+ if (raw !== 'concerns' && raw !== 'blocking') {
311
+ throw new Error(`--strict-fail-on must be "concerns" or "blocking", got ${JSON.stringify(raw)}`);
312
+ }
313
+ return raw;
314
+ })
315
+ .option('--json', 'emit a single-line JSON result instead of human-readable output')
316
+ .action(async (opts) => {
317
+ await runReview({
318
+ ...(opts.base !== undefined ? { base: opts.base } : {}),
319
+ ...(opts.strictFailOn !== undefined ? { strictFailOn: opts.strictFailOn } : {}),
320
+ ...(opts.json === true ? { json: true } : {}),
321
+ });
322
+ });
323
+ }
324
+ // Path constant for tests — not consumed elsewhere.
325
+ export const REA_AUDIT_RELATIVE = path.join('.rea', 'audit.jsonl');
@@ -90,6 +90,29 @@ declare const PolicySchema: z.ZodObject<{
90
90
  * verdict. Set to 0 to disable caching (every push re-invokes codex).
91
91
  */
92
92
  cache_ttl_ms: z.ZodOptional<z.ZodNumber>;
93
+ /**
94
+ * 0.26.0 local-first enforcement. Strict so a typo in the off-switch
95
+ * surface (`mode: of`, `refuse_at: pushh`) fails policy load instead
96
+ * of silently disabling. `bypass_env_var` is constrained to the
97
+ * shell-safe identifier alphabet so a nonsense value can't smuggle
98
+ * shell metacharacters through the Bash-tier gate that reads it.
99
+ */
100
+ local_review: z.ZodOptional<z.ZodObject<{
101
+ mode: z.ZodOptional<z.ZodEnum<["enforced", "off"]>>;
102
+ max_age_seconds: z.ZodOptional<z.ZodNumber>;
103
+ refuse_at: z.ZodOptional<z.ZodEnum<["push", "commit", "both"]>>;
104
+ bypass_env_var: z.ZodOptional<z.ZodString>;
105
+ }, "strict", z.ZodTypeAny, {
106
+ mode?: "enforced" | "off" | undefined;
107
+ max_age_seconds?: number | undefined;
108
+ refuse_at?: "push" | "commit" | "both" | undefined;
109
+ bypass_env_var?: string | undefined;
110
+ }, {
111
+ mode?: "enforced" | "off" | undefined;
112
+ max_age_seconds?: number | undefined;
113
+ refuse_at?: "push" | "commit" | "both" | undefined;
114
+ bypass_env_var?: string | undefined;
115
+ }>>;
93
116
  }, "strict", z.ZodTypeAny, {
94
117
  codex_required?: boolean | undefined;
95
118
  concerns_blocks?: boolean | undefined;
@@ -99,6 +122,12 @@ declare const PolicySchema: z.ZodObject<{
99
122
  codex_model?: string | undefined;
100
123
  codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
101
124
  cache_ttl_ms?: number | undefined;
125
+ local_review?: {
126
+ mode?: "enforced" | "off" | undefined;
127
+ max_age_seconds?: number | undefined;
128
+ refuse_at?: "push" | "commit" | "both" | undefined;
129
+ bypass_env_var?: string | undefined;
130
+ } | undefined;
102
131
  }, {
103
132
  codex_required?: boolean | undefined;
104
133
  concerns_blocks?: boolean | undefined;
@@ -108,6 +137,12 @@ declare const PolicySchema: z.ZodObject<{
108
137
  codex_model?: string | undefined;
109
138
  codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
110
139
  cache_ttl_ms?: number | undefined;
140
+ local_review?: {
141
+ mode?: "enforced" | "off" | undefined;
142
+ max_age_seconds?: number | undefined;
143
+ refuse_at?: "push" | "commit" | "both" | undefined;
144
+ bypass_env_var?: string | undefined;
145
+ } | undefined;
111
146
  }>>;
112
147
  redact: z.ZodOptional<z.ZodObject<{
113
148
  match_timeout_ms: z.ZodOptional<z.ZodNumber>;
@@ -185,6 +220,16 @@ declare const PolicySchema: z.ZodObject<{
185
220
  }, {
186
221
  patterns?: string[] | undefined;
187
222
  }>>;
223
+ commit_hygiene: z.ZodOptional<z.ZodObject<{
224
+ warn_at_commits: z.ZodOptional<z.ZodNumber>;
225
+ refuse_at_commits: z.ZodOptional<z.ZodNumber>;
226
+ }, "strict", z.ZodTypeAny, {
227
+ warn_at_commits?: number | undefined;
228
+ refuse_at_commits?: number | undefined;
229
+ }, {
230
+ warn_at_commits?: number | undefined;
231
+ refuse_at_commits?: number | undefined;
232
+ }>>;
188
233
  }, "strict", z.ZodTypeAny, {
189
234
  version: string;
190
235
  profile: string;
@@ -215,6 +260,12 @@ declare const PolicySchema: z.ZodObject<{
215
260
  codex_model?: string | undefined;
216
261
  codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
217
262
  cache_ttl_ms?: number | undefined;
263
+ local_review?: {
264
+ mode?: "enforced" | "off" | undefined;
265
+ max_age_seconds?: number | undefined;
266
+ refuse_at?: "push" | "commit" | "both" | undefined;
267
+ bypass_env_var?: string | undefined;
268
+ } | undefined;
218
269
  } | undefined;
219
270
  redact?: {
220
271
  match_timeout_ms?: number | undefined;
@@ -238,6 +289,10 @@ declare const PolicySchema: z.ZodObject<{
238
289
  architecture_review?: {
239
290
  patterns?: string[] | undefined;
240
291
  } | undefined;
292
+ commit_hygiene?: {
293
+ warn_at_commits?: number | undefined;
294
+ refuse_at_commits?: number | undefined;
295
+ } | undefined;
241
296
  }, {
242
297
  version: string;
243
298
  profile: string;
@@ -268,6 +323,12 @@ declare const PolicySchema: z.ZodObject<{
268
323
  codex_model?: string | undefined;
269
324
  codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
270
325
  cache_ttl_ms?: number | undefined;
326
+ local_review?: {
327
+ mode?: "enforced" | "off" | undefined;
328
+ max_age_seconds?: number | undefined;
329
+ refuse_at?: "push" | "commit" | "both" | undefined;
330
+ bypass_env_var?: string | undefined;
331
+ } | undefined;
271
332
  } | undefined;
272
333
  redact?: {
273
334
  match_timeout_ms?: number | undefined;
@@ -291,6 +352,10 @@ declare const PolicySchema: z.ZodObject<{
291
352
  architecture_review?: {
292
353
  patterns?: string[] | undefined;
293
354
  } | undefined;
355
+ commit_hygiene?: {
356
+ warn_at_commits?: number | undefined;
357
+ refuse_at_commits?: number | undefined;
358
+ } | undefined;
294
359
  }>;
295
360
  /**
296
361
  * Async policy loader with TTL cache and mtime-based invalidation.
@@ -89,6 +89,36 @@ const ReviewPolicySchema = z
89
89
  * verdict. Set to 0 to disable caching (every push re-invokes codex).
90
90
  */
91
91
  cache_ttl_ms: z.number().int().nonnegative().optional(),
92
+ /**
93
+ * 0.26.0 local-first enforcement. Strict so a typo in the off-switch
94
+ * surface (`mode: of`, `refuse_at: pushh`) fails policy load instead
95
+ * of silently disabling. `bypass_env_var` is constrained to the
96
+ * shell-safe identifier alphabet so a nonsense value can't smuggle
97
+ * shell metacharacters through the Bash-tier gate that reads it.
98
+ */
99
+ local_review: z
100
+ .object({
101
+ mode: z.enum(['enforced', 'off']).optional(),
102
+ max_age_seconds: z.number().int().positive().optional(),
103
+ refuse_at: z.enum(['push', 'commit', 'both']).optional(),
104
+ bypass_env_var: z
105
+ .string()
106
+ .regex(/^[A-Z][A-Z0-9_]{0,63}$/)
107
+ .optional(),
108
+ })
109
+ .strict()
110
+ .optional(),
111
+ })
112
+ .strict();
113
+ /**
114
+ * 0.26.0 commit hygiene refusal thresholds. Top-level policy block (NOT
115
+ * under `review`) — it's a process-discipline knob, not a review knob.
116
+ * `rea preflight` reads it; the push-gate ignores it.
117
+ */
118
+ const CommitHygienePolicySchema = z
119
+ .object({
120
+ warn_at_commits: z.number().int().nonnegative().optional(),
121
+ refuse_at_commits: z.number().int().nonnegative().optional(),
92
122
  })
93
123
  .strict();
94
124
  /**
@@ -214,6 +244,9 @@ const PolicySchema = z
214
244
  patterns: z.array(z.string()).optional(),
215
245
  })
216
246
  .optional(),
247
+ // 0.26.0 commit-hygiene thresholds — top-level so it's discoverable
248
+ // separately from `review.local_review`. `rea preflight` consumes it.
249
+ commit_hygiene: CommitHygienePolicySchema.optional(),
217
250
  })
218
251
  .strict();
219
252
  const DEFAULT_CACHE_TTL_MS = 30_000;
@@ -169,6 +169,84 @@ export interface ReviewPolicy {
169
169
  * a `rea.push_gate.verdict_flip` audit event and overwrite the cache.
170
170
  */
171
171
  cache_ttl_ms?: number;
172
+ /**
173
+ * Local-first review enforcement (0.26.0+ — CTO directive 2026-05-05).
174
+ *
175
+ * The push-gate is the BACKUP layer. The primary review surface is the
176
+ * working tree BEFORE commit, run via `rea review`, recorded as a
177
+ * `rea.local_review` audit entry. The Bash-tier `local-review-gate.sh`
178
+ * hook + husky `rea preflight --strict` refuse `git push` (and optionally
179
+ * `git commit`) when no recent matching audit entry exists for HEAD.
180
+ *
181
+ * The off-switch is the FIRST-class concern. Teams without codex/claude
182
+ * installed set `mode: off` to disable the new enforcement layers
183
+ * cleanly — no env-var hacks, no policy strip, no special init flag.
184
+ *
185
+ * The provider seam is the audit-record `provider` field, NOT this
186
+ * policy block. Future providers (Claude-subagent, Pi, Gemma) write
187
+ * `rea.local_review` records with their own `provider:` value; this
188
+ * block governs WHETHER the gate fires, not WHO runs the review.
189
+ */
190
+ local_review?: LocalReviewPolicy;
191
+ }
192
+ /**
193
+ * Local-first review enforcement (0.26.0+).
194
+ *
195
+ * `mode: 'enforced'` — the new Bash-tier gate, husky preflight, and
196
+ * `rea review` requirement all fire. Pushes are refused unless a
197
+ * recent matching `rea.local_review` audit entry exists OR
198
+ * `bypass_env_var` is set with a non-empty reason.
199
+ *
200
+ * `mode: 'off'` — every new enforcement layer becomes a silent no-op.
201
+ * Teams without codex/claude opt out cleanly. The push-gate (which is
202
+ * a separate layer governed by `codex_required`) is unaffected by this
203
+ * setting.
204
+ *
205
+ * Default when unset: `enforced`. The CTO directive 2026-05-05 applies
206
+ * to ALL rea work, OSS + enterprise — the off-switch is opt-out, never
207
+ * opt-in.
208
+ */
209
+ export interface LocalReviewPolicy {
210
+ mode?: 'enforced' | 'off';
211
+ /**
212
+ * Maximum age (seconds) of a `rea.local_review` audit entry that
213
+ * `rea preflight` will accept as covering the current HEAD. A review
214
+ * older than this is treated as missing and the gate refuses.
215
+ * Default 86400 (24 hours).
216
+ */
217
+ max_age_seconds?: number;
218
+ /**
219
+ * Which git operations the Bash-tier gate refuses when no recent
220
+ * review covers HEAD.
221
+ * - `'push'` — refuse `git push` only (default)
222
+ * - `'commit'` — refuse `git commit` only
223
+ * - `'both'` — refuse both
224
+ *
225
+ * The husky pre-push hook honors `'push' | 'both'`. The Bash-tier
226
+ * hook honors all three.
227
+ */
228
+ refuse_at?: 'push' | 'commit' | 'both';
229
+ /**
230
+ * Env-var name that, when set with a non-empty value, causes
231
+ * `rea preflight` to short-circuit (exit 0) AFTER writing a
232
+ * `rea.local_review.skipped_override` audit entry that records
233
+ * the reason. Default `REA_SKIP_LOCAL_REVIEW`.
234
+ *
235
+ * The override is per-invocation, audited every time, and a
236
+ * release valve — not a sustained way to disable enforcement.
237
+ * Teams that need to DISABLE enforcement set `mode: off`.
238
+ */
239
+ bypass_env_var?: string;
240
+ }
241
+ /**
242
+ * Commit-hygiene refusal thresholds (0.26.0+). `rea preflight` runs
243
+ * `git rev-list --count <base>..HEAD` and compares against these
244
+ * thresholds. Set to a sentinel value (e.g. very large integer) to
245
+ * effectively disable.
246
+ */
247
+ export interface CommitHygienePolicy {
248
+ warn_at_commits?: number;
249
+ refuse_at_commits?: number;
172
250
  }
173
251
  /**
174
252
  * User-supplied redaction pattern entry. Each pattern has a stable `name` used
@@ -301,4 +379,15 @@ export interface Policy {
301
379
  architecture_review?: {
302
380
  patterns?: string[];
303
381
  };
382
+ /**
383
+ * Commit-hygiene refusal thresholds (0.26.0+). `rea preflight` checks
384
+ * `git rev-list --count <base>..HEAD`; `> warn_at_commits` warns
385
+ * (exit 1), `> refuse_at_commits` refuses (exit 2). The CTO directive
386
+ * 2026-05-05 sets the new BST default at warn_at=1 / refuse_at=5 to
387
+ * push every change toward squash-on-commit hygiene.
388
+ *
389
+ * Top-level (not under `review`) because it's a process-discipline
390
+ * knob, not a review knob. The push-gate doesn't consume it.
391
+ */
392
+ commit_hygiene?: CommitHygienePolicy;
304
393
  }