@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.
- package/README.md +10 -7
- package/agents/codex-adversarial.md +4 -0
- package/agents/rea-orchestrator.md +9 -0
- package/commands/codex-review.md +4 -0
- package/dist/audit/append.d.ts +1 -0
- package/dist/audit/append.js +1 -0
- package/dist/audit/content-token.d.ts +98 -0
- package/dist/audit/content-token.js +136 -0
- package/dist/audit/local-review-event.d.ts +136 -0
- package/dist/audit/local-review-event.js +43 -0
- package/dist/cli/doctor.js +17 -0
- package/dist/cli/hook.d.ts +44 -0
- package/dist/cli/hook.js +77 -0
- package/dist/cli/index.js +9 -0
- package/dist/cli/init.js +197 -46
- package/dist/cli/install/pre-push.d.ts +15 -3
- package/dist/cli/install/pre-push.js +55 -5
- package/dist/cli/install/settings-merge.js +13 -0
- package/dist/cli/preflight.d.ts +120 -0
- package/dist/cli/preflight.js +487 -0
- package/dist/cli/review.d.ts +56 -0
- package/dist/cli/review.js +325 -0
- package/dist/policy/loader.d.ts +65 -0
- package/dist/policy/loader.js +33 -0
- package/dist/policy/types.d.ts +89 -0
- package/hooks/_lib/cmd-segments.sh +140 -2
- package/hooks/_lib/policy-read.sh +255 -0
- package/hooks/local-review-gate.sh +460 -0
- package/package.json +1 -1
- package/templates/CLAUDE.md.local-first.md +87 -0
- package/templates/pre-push.local-first.sh +65 -0
|
@@ -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');
|
package/dist/policy/loader.d.ts
CHANGED
|
@@ -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.
|
package/dist/policy/loader.js
CHANGED
|
@@ -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;
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -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
|
}
|