@bookedsolid/rea 0.25.0 → 0.26.1

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,120 @@
1
+ /**
2
+ * `rea preflight` — local-first enforcement workhorse (0.26.0+).
3
+ *
4
+ * Called by:
5
+ * - The husky pre-push template (`exec rea preflight --strict`)
6
+ * - The Bash-tier `local-review-gate.sh` PreToolUse hook
7
+ * - Operators directly (`rea preflight` to check status)
8
+ *
9
+ * Decision flow:
10
+ *
11
+ * 1. `policy.review.local_review.mode === 'off'` → exit 0 (no-op)
12
+ * 2. `<bypass_env_var>` is set (default REA_SKIP_LOCAL_REVIEW) → audit
13
+ * `rea.local_review.skipped_override` with the reason; exit 0
14
+ * 3. `--no-review-check` flag → audit `rea.preflight.review_skipped`;
15
+ * proceed to commit-count check only
16
+ * 4. Tail `.rea/audit.jsonl` for a `rea.local_review` (or back-compat
17
+ * `codex.review`) entry with `metadata.head_sha === <git HEAD>`
18
+ * AND `now - timestamp < max_age_seconds`. Found → exit 0.
19
+ * Missing → exit 2 with helpful message.
20
+ * 5. Commit-count check (independent of step 4):
21
+ * `git rev-list --count <base>..HEAD` against thresholds
22
+ * from `policy.commit_hygiene`.
23
+ *
24
+ * Exit codes:
25
+ *
26
+ * 0 — clean (mode=off, recent review found, or override set)
27
+ * 1 — warn (commit count > warn_at_commits but ≤ refuse_at_commits)
28
+ * 2 — refuse (no recent review covering HEAD, OR commit count >
29
+ * refuse_at_commits, OR --strict elevated a warn to refuse)
30
+ */
31
+ import type { Command } from 'commander';
32
+ import { type Policy } from '../policy/types.js';
33
+ /** Default max age for a local-review audit entry (24h). */
34
+ export declare const DEFAULT_MAX_AGE_SECONDS = 86400;
35
+ /** Default bypass env-var name. */
36
+ export declare const DEFAULT_BYPASS_ENV_VAR = "REA_SKIP_LOCAL_REVIEW";
37
+ /** Default commit-hygiene thresholds. */
38
+ export declare const DEFAULT_WARN_AT_COMMITS = 1;
39
+ export declare const DEFAULT_REFUSE_AT_COMMITS = 5;
40
+ export interface RunPreflightOptions {
41
+ /**
42
+ * Treat warn-tier commit-hygiene findings as refusals. Husky pre-push
43
+ * always sets this — a warn that doesn't refuse is a useless warning
44
+ * at the terminal layer.
45
+ */
46
+ strict?: boolean;
47
+ /**
48
+ * Skip the audit-log check. The commit-count check still runs. Used
49
+ * by operators who explicitly want to defer review (audit-logged so
50
+ * the deferral is forensically visible).
51
+ */
52
+ noReviewCheck?: boolean;
53
+ /** Emit a single JSON line on stdout instead of pretty output. */
54
+ json?: boolean;
55
+ }
56
+ interface PreflightOutcome {
57
+ status: 'clean' | 'warn' | 'refuse';
58
+ reason: string;
59
+ exitCode: 0 | 1 | 2;
60
+ details: Record<string, unknown>;
61
+ }
62
+ /**
63
+ * Run preflight in-process. Tests drive this directly. The CLI binding
64
+ * exits via `process.exit` at the end of `runPreflight()`.
65
+ */
66
+ export declare function computePreflight(baseDir: string, options: RunPreflightOptions, env?: NodeJS.ProcessEnv): Promise<{
67
+ outcome: PreflightOutcome;
68
+ policy: Policy | undefined;
69
+ }>;
70
+ export declare function runPreflight(options: RunPreflightOptions): Promise<void>;
71
+ /**
72
+ * Tail `.rea/audit.jsonl` for the most recent matching local-review
73
+ * entry. We accept BOTH `rea.local_review` (canonical) and
74
+ * `codex.review` (back-compat from pre-0.26.0 audit data) so existing
75
+ * users with prior reviews don't have to re-review on upgrade.
76
+ *
77
+ * Streaming approach: read the whole file (audit logs are typically
78
+ * < 10 MB even after months of use) and walk lines from the end. The
79
+ * audit log is append-only and timestamps are monotonic per writer.
80
+ *
81
+ * # Coverage matching (0.26.0 helix-026 finding-1)
82
+ *
83
+ * The first valid `metadata.content_token` on each record wins:
84
+ *
85
+ * 1. Record has `content_token` AND caller supplied `contentToken` →
86
+ * exact-string match. Stable across `--amend` / fixup rebases.
87
+ * 2. Record has NO `content_token` (legacy `codex.review` entry, or
88
+ * a future provider that can't compute one) → fall back to
89
+ * exact-string `head_sha` match. Pre-0.26.0 reviews still cover.
90
+ * 3. Record has `content_token` but caller's `contentToken` is empty
91
+ * (preflight on a non-git directory or detached state) → fall back
92
+ * to `head_sha` match. The content path is the additive layer; the
93
+ * head-sha layer remains as the floor.
94
+ *
95
+ * Hierarchy invariant: an entry is valid coverage when EITHER the token
96
+ * matches OR the head_sha matches. The two are not AND-ed — that would
97
+ * make legacy entries un-matchable and would break the local-first loop
98
+ * back to the old "commit first, then review" inversion.
99
+ */
100
+ export interface LocalReviewLookupResult {
101
+ found: boolean;
102
+ /** Audit-record metadata payload, when found. */
103
+ metadata?: Record<string, unknown>;
104
+ /** ISO timestamp on the matching record. */
105
+ timestamp?: string;
106
+ /** Tool name that matched (canonical or legacy). */
107
+ tool_name?: string;
108
+ /**
109
+ * Which match-path validated this entry. Useful for tests and for the
110
+ * `--json` outcome: `'content_token'` (preferred), `'head_sha'`
111
+ * (back-compat / fallback).
112
+ */
113
+ match_kind?: 'content_token' | 'head_sha';
114
+ }
115
+ export declare function findRecentLocalReview(baseDir: string, headSha: string, maxAgeSeconds: number, now?: Date, contentToken?: string): LocalReviewLookupResult;
116
+ /**
117
+ * Attach `rea preflight` to a commander Program.
118
+ */
119
+ export declare function registerPreflightCommand(program: Command): void;
120
+ export {};
@@ -0,0 +1,487 @@
1
+ /**
2
+ * `rea preflight` — local-first enforcement workhorse (0.26.0+).
3
+ *
4
+ * Called by:
5
+ * - The husky pre-push template (`exec rea preflight --strict`)
6
+ * - The Bash-tier `local-review-gate.sh` PreToolUse hook
7
+ * - Operators directly (`rea preflight` to check status)
8
+ *
9
+ * Decision flow:
10
+ *
11
+ * 1. `policy.review.local_review.mode === 'off'` → exit 0 (no-op)
12
+ * 2. `<bypass_env_var>` is set (default REA_SKIP_LOCAL_REVIEW) → audit
13
+ * `rea.local_review.skipped_override` with the reason; exit 0
14
+ * 3. `--no-review-check` flag → audit `rea.preflight.review_skipped`;
15
+ * proceed to commit-count check only
16
+ * 4. Tail `.rea/audit.jsonl` for a `rea.local_review` (or back-compat
17
+ * `codex.review`) entry with `metadata.head_sha === <git HEAD>`
18
+ * AND `now - timestamp < max_age_seconds`. Found → exit 0.
19
+ * Missing → exit 2 with helpful message.
20
+ * 5. Commit-count check (independent of step 4):
21
+ * `git rev-list --count <base>..HEAD` against thresholds
22
+ * from `policy.commit_hygiene`.
23
+ *
24
+ * Exit codes:
25
+ *
26
+ * 0 — clean (mode=off, recent review found, or override set)
27
+ * 1 — warn (commit count > warn_at_commits but ≤ refuse_at_commits)
28
+ * 2 — refuse (no recent review covering HEAD, OR commit count >
29
+ * refuse_at_commits, OR --strict elevated a warn to refuse)
30
+ */
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
33
+ import { spawnSync } from 'node:child_process';
34
+ import { appendAuditRecord } from '../audit/append.js';
35
+ import { LOCAL_REVIEW_TOOL_NAME, LOCAL_REVIEW_SKIPPED_OVERRIDE_TOOL_NAME, LOCAL_REVIEW_PREFLIGHT_SKIPPED_TOOL_NAME, LOCAL_REVIEW_SERVER_NAME, } from '../audit/local-review-event.js';
36
+ import { CODEX_REVIEW_TOOL_NAME } from '../audit/codex-event.js';
37
+ import { computeTreeToken, EMPTY_TREE_SHA } from '../audit/content-token.js';
38
+ import { readHalt } from '../hooks/push-gate/halt.js';
39
+ import { Tier, InvocationStatus } from '../policy/types.js';
40
+ import { loadPolicyAsync } from '../policy/loader.js';
41
+ import { err, log } from './utils.js';
42
+ /** Default max age for a local-review audit entry (24h). */
43
+ export const DEFAULT_MAX_AGE_SECONDS = 86_400;
44
+ /** Default bypass env-var name. */
45
+ export const DEFAULT_BYPASS_ENV_VAR = 'REA_SKIP_LOCAL_REVIEW';
46
+ /** Default commit-hygiene thresholds. */
47
+ export const DEFAULT_WARN_AT_COMMITS = 1;
48
+ export const DEFAULT_REFUSE_AT_COMMITS = 5;
49
+ /**
50
+ * Run preflight in-process. Tests drive this directly. The CLI binding
51
+ * exits via `process.exit` at the end of `runPreflight()`.
52
+ */
53
+ export async function computePreflight(baseDir, options, env = process.env) {
54
+ const policy = await tryLoadPolicy(baseDir);
55
+ // Round-27 F4 fix: HALT check BEFORE every other path. The Bash-tier
56
+ // `local-review-gate.sh` and the canonical husky BODY_TEMPLATE both
57
+ // honor `.rea/HALT`, but `rea preflight` itself was missing the check —
58
+ // direct invocations and the minimal `templates/pre-push.local-first.sh`
59
+ // body bypassed the kill-switch entirely. The HALT check runs BEFORE
60
+ // `mode === 'off'` so a halted repo cannot push even when local-review
61
+ // enforcement is opted-out.
62
+ const halt = readHalt(baseDir);
63
+ if (halt.halted) {
64
+ return {
65
+ outcome: {
66
+ status: 'refuse',
67
+ reason: `REA HALT: ${halt.reason ?? 'unknown'}`,
68
+ exitCode: 2,
69
+ details: {
70
+ halt: true,
71
+ halt_reason: halt.reason ?? 'unknown',
72
+ },
73
+ },
74
+ policy,
75
+ };
76
+ }
77
+ // Step 1: mode === 'off' → no-op clean exit.
78
+ const mode = policy?.review?.local_review?.mode ?? 'enforced';
79
+ if (mode === 'off') {
80
+ return {
81
+ outcome: {
82
+ status: 'clean',
83
+ reason: 'policy.review.local_review.mode is off',
84
+ exitCode: 0,
85
+ details: { mode: 'off' },
86
+ },
87
+ policy,
88
+ };
89
+ }
90
+ const headSha = resolveHeadSha(baseDir);
91
+ // 0.26.0 helix-026 finding-1: compute the current tree-token. Coverage
92
+ // is matched on this in step 4 — `head_sha` is only used for forensics
93
+ // (and as a back-compat fallback for legacy `codex.review` entries that
94
+ // were written before content_token existed).
95
+ const contentToken = computeTreeToken(baseDir);
96
+ const bypassEnvVar = policy?.review?.local_review?.bypass_env_var ?? DEFAULT_BYPASS_ENV_VAR;
97
+ const bypassReason = (env[bypassEnvVar] ?? '').trim();
98
+ // Step 2: bypass env-var → audit + clean exit.
99
+ if (bypassReason.length > 0) {
100
+ const meta = {
101
+ head_sha: headSha,
102
+ reason: bypassReason,
103
+ bypass_env_var: bypassEnvVar,
104
+ };
105
+ await safeAudit(baseDir, LOCAL_REVIEW_SKIPPED_OVERRIDE_TOOL_NAME, InvocationStatus.Allowed, meta, policy);
106
+ return {
107
+ outcome: {
108
+ status: 'clean',
109
+ reason: `${bypassEnvVar} set (audited)`,
110
+ exitCode: 0,
111
+ details: { bypass_env_var: bypassEnvVar, reason: bypassReason },
112
+ },
113
+ policy,
114
+ };
115
+ }
116
+ // Step 3: --no-review-check escape hatch (audit-logged).
117
+ let reviewCheckSkipped = false;
118
+ if (options.noReviewCheck === true) {
119
+ reviewCheckSkipped = true;
120
+ await safeAudit(baseDir, LOCAL_REVIEW_PREFLIGHT_SKIPPED_TOOL_NAME, InvocationStatus.Allowed, { head_sha: headSha, reason: '--no-review-check flag' }, policy);
121
+ }
122
+ // Step 4: audit-log lookup (skipped under --no-review-check).
123
+ const maxAgeSeconds = policy?.review?.local_review?.max_age_seconds ?? DEFAULT_MAX_AGE_SECONDS;
124
+ if (!reviewCheckSkipped) {
125
+ const lookup = findRecentLocalReview(baseDir, headSha, maxAgeSeconds, new Date(), contentToken);
126
+ if (!lookup.found) {
127
+ return {
128
+ outcome: {
129
+ status: 'refuse',
130
+ reason: 'no recent local-review audit entry covers HEAD',
131
+ exitCode: 2,
132
+ details: {
133
+ head_sha: headSha,
134
+ content_token: contentToken,
135
+ max_age_seconds: maxAgeSeconds,
136
+ bypass_env_var: bypassEnvVar,
137
+ policy_off_switch: 'policy.review.local_review.mode: off',
138
+ },
139
+ },
140
+ policy,
141
+ };
142
+ }
143
+ }
144
+ // Step 5: commit-count check.
145
+ const warnAt = policy?.commit_hygiene?.warn_at_commits ?? DEFAULT_WARN_AT_COMMITS;
146
+ const refuseAt = policy?.commit_hygiene?.refuse_at_commits ?? DEFAULT_REFUSE_AT_COMMITS;
147
+ const commitCount = countCommitsAheadOfBase(baseDir);
148
+ if (commitCount !== null) {
149
+ if (commitCount > refuseAt) {
150
+ return {
151
+ outcome: {
152
+ status: 'refuse',
153
+ reason: `commit count ${commitCount} > refuse_at_commits=${refuseAt} — squash before pushing`,
154
+ exitCode: 2,
155
+ details: {
156
+ commit_count: commitCount,
157
+ warn_at_commits: warnAt,
158
+ refuse_at_commits: refuseAt,
159
+ },
160
+ },
161
+ policy,
162
+ };
163
+ }
164
+ if (commitCount > warnAt) {
165
+ const elevated = options.strict === true;
166
+ return {
167
+ outcome: {
168
+ status: elevated ? 'refuse' : 'warn',
169
+ reason: `commit count ${commitCount} > warn_at_commits=${warnAt}${elevated ? ' (strict)' : ''}`,
170
+ exitCode: elevated ? 2 : 1,
171
+ details: {
172
+ commit_count: commitCount,
173
+ warn_at_commits: warnAt,
174
+ refuse_at_commits: refuseAt,
175
+ strict: elevated,
176
+ },
177
+ },
178
+ policy,
179
+ };
180
+ }
181
+ }
182
+ return {
183
+ outcome: {
184
+ status: 'clean',
185
+ reason: reviewCheckSkipped
186
+ ? 'review check skipped, commit-hygiene clean'
187
+ : 'recent local-review audit entry covers HEAD',
188
+ exitCode: 0,
189
+ details: { head_sha: headSha, content_token: contentToken, commit_count: commitCount },
190
+ },
191
+ policy,
192
+ };
193
+ }
194
+ export async function runPreflight(options) {
195
+ const baseDir = process.cwd();
196
+ const { outcome } = await computePreflight(baseDir, options);
197
+ if (options.json === true) {
198
+ process.stdout.write(JSON.stringify({ ...outcome }) + '\n');
199
+ }
200
+ else {
201
+ if (outcome.exitCode === 0) {
202
+ log(`preflight clean — ${outcome.reason}`);
203
+ }
204
+ else if (outcome.exitCode === 1) {
205
+ console.warn(`[rea] preflight WARN — ${outcome.reason}`);
206
+ }
207
+ else {
208
+ err(`preflight refuse — ${outcome.reason}`);
209
+ console.error('');
210
+ console.error(' To unblock, do ONE of:');
211
+ console.error(' 1. Run `rea review` — write a fresh local-review audit entry');
212
+ console.error(' 2. Set REA_SKIP_LOCAL_REVIEW="<reason>"');
213
+ console.error(' — per-invocation override (audited)');
214
+ console.error(' 3. Edit .rea/policy.yaml — set:');
215
+ console.error(' review:');
216
+ console.error(' local_review:');
217
+ console.error(' mode: off');
218
+ console.error(' (use this if your team does not have codex/claude installed)');
219
+ console.error('');
220
+ }
221
+ }
222
+ process.exit(outcome.exitCode);
223
+ }
224
+ // ---------------------------------------------------------------------------
225
+ // Implementation helpers
226
+ // ---------------------------------------------------------------------------
227
+ async function tryLoadPolicy(baseDir) {
228
+ try {
229
+ return await loadPolicyAsync(baseDir);
230
+ }
231
+ catch {
232
+ return undefined;
233
+ }
234
+ }
235
+ function resolveHeadSha(baseDir) {
236
+ const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: baseDir, encoding: 'utf8' });
237
+ if (r.status !== 0) {
238
+ // Round-27 F2 fix: unborn-HEAD repos return EMPTY_TREE_SHA so the
239
+ // reader stays symmetric with `rea review`'s writer (which uses the
240
+ // same constant when HEAD can't be resolved — see review.ts). Pre-fix
241
+ // the reader returned '' and the both-empty guard in
242
+ // `findRecentLocalReview` rejected the just-written audit entry,
243
+ // deadlocking `git init → rea review → git commit` under
244
+ // `refuse_at: both`.
245
+ return EMPTY_TREE_SHA;
246
+ }
247
+ const sha = (r.stdout ?? '').toString().trim();
248
+ return sha.length > 0 ? sha : EMPTY_TREE_SHA;
249
+ }
250
+ /**
251
+ * Resolve the divergence base for commit-counting. The intent is "how
252
+ * many commits will this push deliver to TRUNK" — not "how many commits
253
+ * are unpushed on this branch". Order trunk-equivalent refs first.
254
+ *
255
+ * Order (0.26.0 helix-026 finding-3):
256
+ * 1. `origin/HEAD` (the default branch on origin — usually `main`)
257
+ * 2. `origin/main`
258
+ * 3. `origin/master`
259
+ * 4. `@{upstream}` (LAST RESORT — see warning below)
260
+ *
261
+ * # Why `@{upstream}` is last
262
+ *
263
+ * After `git push -u origin <branch>`, `@{upstream}` resolves to
264
+ * `origin/<branch>` — the branch's OWN remote tip, NOT trunk. If
265
+ * preflight's commit-count check were keyed to that, a 50-commit
266
+ * feature branch would always count "0" once pushed, defeating
267
+ * `refuse_at_commits` on the very long-lived branches the policy was
268
+ * designed to discourage.
269
+ *
270
+ * `@{upstream}` is preserved as the absolute fallback for repos with no
271
+ * `origin` (forks, mirrors, weird CI clones). When `@{upstream}` is the
272
+ * only resolvable base AND it points to a non-trunk ref, preflight
273
+ * accepts the no-op cost — the audit-log review check is the primary
274
+ * gate; commit-count is best-effort.
275
+ *
276
+ * Additional guard: when `@{upstream}` resolves to a ref under
277
+ * `refs/remotes/origin/` other than the default branch, we skip it.
278
+ * This catches the typical `git push -u origin <feature>` case while
279
+ * still allowing `@{upstream}` -> `origin/main` to work for branches
280
+ * whose upstream IS trunk.
281
+ *
282
+ * Returns null when none resolve — `rea preflight` then skips the
283
+ * commit-count check (best-effort; the audit-log check is the primary
284
+ * gate).
285
+ */
286
+ function resolveCommitCountBase(baseDir) {
287
+ // Trunk-equivalent refs first. `@{upstream}` is held back as a
288
+ // last-resort because it can resolve to the branch's own remote tip
289
+ // and turn the gate into a no-op — see the docblock above.
290
+ const primary = ['origin/HEAD', 'origin/main', 'origin/master'];
291
+ for (const ref of primary) {
292
+ if (resolveRef(baseDir, ref).length > 0)
293
+ return ref;
294
+ }
295
+ // `@{upstream}` LAST. We additionally probe what it resolves to —
296
+ // if it's a remote feature-branch ref under `refs/remotes/origin/`
297
+ // (not a primary trunk ref we already tried), the candidate is
298
+ // useless for commit-counting and we skip it rather than silently
299
+ // turn the check into a no-op.
300
+ const upstreamSymbolic = resolveSymbolicRef(baseDir, '@{upstream}');
301
+ if (upstreamSymbolic.length > 0) {
302
+ // `git rev-parse --abbrev-ref @{upstream}` returns e.g.
303
+ // `origin/main` or `origin/feat/foo`. If the resolved ref matches
304
+ // origin/<branch> for any branch we DIDN'T already try as a primary
305
+ // candidate, it's a feature-tracking upstream — skip.
306
+ const isFeatureUpstream = upstreamSymbolic.startsWith('origin/') &&
307
+ !primary.includes(upstreamSymbolic);
308
+ if (!isFeatureUpstream) {
309
+ // Upstream IS a trunk-equivalent ref (origin/main / origin/master /
310
+ // a non-origin remote). Use it.
311
+ if (resolveRef(baseDir, '@{upstream}').length > 0)
312
+ return '@{upstream}';
313
+ }
314
+ // Feature-tracking upstream: deliberately skipped to avoid the
315
+ // "50-commit branch counts 0" no-op. Fall through.
316
+ }
317
+ return null;
318
+ }
319
+ function resolveRef(baseDir, ref) {
320
+ const r = spawnSync('git', ['rev-parse', '--verify', '--quiet', ref], {
321
+ cwd: baseDir,
322
+ encoding: 'utf8',
323
+ });
324
+ if (r.status !== 0)
325
+ return '';
326
+ return (r.stdout ?? '').toString().trim();
327
+ }
328
+ function resolveSymbolicRef(baseDir, ref) {
329
+ const r = spawnSync('git', ['rev-parse', '--abbrev-ref', ref], {
330
+ cwd: baseDir,
331
+ encoding: 'utf8',
332
+ });
333
+ if (r.status !== 0)
334
+ return '';
335
+ return (r.stdout ?? '').toString().trim();
336
+ }
337
+ function countCommitsAheadOfBase(baseDir) {
338
+ const base = resolveCommitCountBase(baseDir);
339
+ if (base === null)
340
+ return null;
341
+ const r = spawnSync('git', ['rev-list', '--count', `${base}..HEAD`], {
342
+ cwd: baseDir,
343
+ encoding: 'utf8',
344
+ });
345
+ if (r.status !== 0)
346
+ return null;
347
+ const n = Number((r.stdout ?? '').toString().trim());
348
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
349
+ }
350
+ export function findRecentLocalReview(baseDir, headSha, maxAgeSeconds, now = new Date(), contentToken = '') {
351
+ // 0.26.0 helix-026 finding-1: callers can match by content_token,
352
+ // head_sha, or both. We need at least ONE non-empty key — without
353
+ // either the function would match every record indiscriminately.
354
+ if (headSha.length === 0 && contentToken.length === 0)
355
+ return { found: false };
356
+ const auditPath = path.join(baseDir, '.rea', 'audit.jsonl');
357
+ if (!fs.existsSync(auditPath))
358
+ return { found: false };
359
+ let raw;
360
+ try {
361
+ raw = fs.readFileSync(auditPath, 'utf8');
362
+ }
363
+ catch {
364
+ return { found: false };
365
+ }
366
+ const lines = raw.split(/\r?\n/);
367
+ const cutoffMs = now.getTime() - maxAgeSeconds * 1000;
368
+ // Walk in reverse — most recent first.
369
+ for (let i = lines.length - 1; i >= 0; i--) {
370
+ const line = lines[i];
371
+ if (line === undefined || line.length === 0)
372
+ continue;
373
+ let record;
374
+ try {
375
+ record = JSON.parse(line);
376
+ }
377
+ catch {
378
+ continue;
379
+ }
380
+ const toolName = typeof record.tool_name === 'string' ? record.tool_name : '';
381
+ if (toolName !== LOCAL_REVIEW_TOOL_NAME && toolName !== CODEX_REVIEW_TOOL_NAME) {
382
+ continue;
383
+ }
384
+ const status = typeof record.status === 'string' ? record.status : '';
385
+ // Skipped/error variants are not coverage. `denied` (blocking verdict)
386
+ // is also not coverage — preflight's job is to ensure a successful
387
+ // recent review, not just any review.
388
+ if (status !== 'allowed')
389
+ continue;
390
+ const metadata = (record.metadata ?? {});
391
+ const recordedSha = typeof metadata.head_sha === 'string' ? metadata.head_sha : '';
392
+ const recordedToken = typeof metadata.content_token === 'string' ? metadata.content_token : '';
393
+ // Coverage match: prefer content_token (stable across --amend), fall
394
+ // back to head_sha for legacy entries / providers that can't compute
395
+ // a token. See block-comment above for the full hierarchy.
396
+ //
397
+ // Round-27 F3 fix: when BOTH sides have a content_token but they
398
+ // DISAGREE, the entry is stale — the working-tree content has changed
399
+ // since the review was written. Pre-fix the `else if` ran whenever
400
+ // the first branch failed, INCLUDING real token mismatch, which
401
+ // silently fell back to head_sha matching. PoC: `rea review` writes
402
+ // T1, operator edits one tracked file (no commit), `git commit`
403
+ // under `refuse_at: commit` → preflight approves the commit because
404
+ // HEAD hasn't moved, defeating the whole content-token path.
405
+ //
406
+ // Fix: when both tokens are present, the comparison is authoritative —
407
+ // mismatch means stale, no fallback. Only when the entry is missing
408
+ // a content_token (legacy `codex.review`) OR the caller's contentToken
409
+ // is empty (non-git directory) do we fall through to head_sha.
410
+ let matchKind = null;
411
+ if (recordedToken.length > 0 && contentToken.length > 0) {
412
+ // Both sides have a token — token comparison is AUTHORITATIVE.
413
+ if (recordedToken === contentToken)
414
+ matchKind = 'content_token';
415
+ // Token mismatch: this entry is stale. Do NOT fall back.
416
+ }
417
+ else if (recordedSha.length > 0 && headSha.length > 0 && recordedSha === headSha) {
418
+ // No token on this entry (or caller). Legacy / non-git fallback.
419
+ matchKind = 'head_sha';
420
+ }
421
+ if (matchKind === null)
422
+ continue;
423
+ const verdict = typeof metadata.verdict === 'string' ? metadata.verdict : '';
424
+ if (verdict === 'error' || verdict === 'blocking')
425
+ continue;
426
+ const timestamp = typeof record.timestamp === 'string' ? record.timestamp : '';
427
+ if (timestamp.length > 0) {
428
+ const ts = Date.parse(timestamp);
429
+ if (Number.isFinite(ts) && ts < cutoffMs) {
430
+ // Older than max_age_seconds — keep walking; a more recent valid
431
+ // record may exist further back? No: we walk newest-to-oldest so
432
+ // anything older from here on is also stale. Stop early.
433
+ return { found: false };
434
+ }
435
+ }
436
+ return {
437
+ found: true,
438
+ metadata,
439
+ timestamp,
440
+ tool_name: toolName,
441
+ match_kind: matchKind,
442
+ };
443
+ }
444
+ return { found: false };
445
+ }
446
+ async function safeAudit(baseDir, toolName, status, metadata, policy) {
447
+ try {
448
+ const cleanMeta = {};
449
+ for (const [k, v] of Object.entries(metadata)) {
450
+ if (v !== undefined)
451
+ cleanMeta[k] = v;
452
+ }
453
+ await appendAuditRecord(baseDir, {
454
+ tool_name: toolName,
455
+ server_name: LOCAL_REVIEW_SERVER_NAME,
456
+ tier: Tier.Read,
457
+ status,
458
+ ...(Object.keys(cleanMeta).length > 0 ? { metadata: cleanMeta } : {}),
459
+ ...(policy !== undefined ? { policy } : {}),
460
+ });
461
+ }
462
+ catch (e) {
463
+ const msg = e instanceof Error ? e.message : String(e);
464
+ process.stderr.write(`rea: audit append failed (${toolName}): ${msg}\n`);
465
+ }
466
+ }
467
+ /**
468
+ * Attach `rea preflight` to a commander Program.
469
+ */
470
+ export function registerPreflightCommand(program) {
471
+ program
472
+ .command('preflight')
473
+ .description('Local-first enforcement workhorse. Refuses (exit 2) when no recent `rea.local_review` audit entry covers HEAD, when commit-hygiene thresholds are exceeded, or when the kill-switch is active. Exit 0 (clean) / 1 (warn) / 2 (refuse). Husky pre-push and the Bash-tier `local-review-gate.sh` hook both delegate here.')
474
+ .option('--strict', 'treat commit-hygiene warns as refusals (exit 2 instead of 1). Always set by husky pre-push.')
475
+ .option('--no-review-check', 'skip the audit-log lookup (still runs commit-hygiene). Audit-logged escape hatch — different from the per-invocation env-var override.')
476
+ .option('--json', 'emit a single-line JSON outcome instead of human-readable output')
477
+ .action(async (opts) => {
478
+ // Commander negation: --no-review-check sets opts.reviewCheck = false.
479
+ // We invert to noReviewCheck for clarity in the runner.
480
+ const noReviewCheck = opts.reviewCheck === false;
481
+ await runPreflight({
482
+ ...(opts.strict === true ? { strict: true } : {}),
483
+ ...(noReviewCheck ? { noReviewCheck: true } : {}),
484
+ ...(opts.json === true ? { json: true } : {}),
485
+ });
486
+ });
487
+ }
@@ -0,0 +1,56 @@
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 type { Command } from 'commander';
34
+ export interface RunReviewOptions {
35
+ /** Optional explicit base ref. Defaults to upstream-ladder resolution. */
36
+ base?: string;
37
+ /**
38
+ * Verdict floor that turns into a non-zero exit. `'concerns'` exits 1
39
+ * on concerns; `'blocking'` (default) exits 0 on concerns and 2 only
40
+ * on blocking. Aligns with the push-gate's `concerns_blocks` knob.
41
+ */
42
+ strictFailOn?: 'concerns' | 'blocking';
43
+ /** Emit a single JSON line on stdout instead of pretty output. */
44
+ json?: boolean;
45
+ }
46
+ /**
47
+ * Public runner — exposed so tests can drive the function in-process and
48
+ * the commander binding can stay thin. Throws via `process.exit` (CLI
49
+ * convention across `src/cli/`).
50
+ */
51
+ export declare function runReview(options: RunReviewOptions): Promise<void>;
52
+ /**
53
+ * Attach `rea review` to a commander Program.
54
+ */
55
+ export declare function registerReviewCommand(program: Command): void;
56
+ export declare const REA_AUDIT_RELATIVE: string;