@bookedsolid/rea 0.18.0 → 0.19.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/dist/cli/init.js CHANGED
@@ -297,6 +297,23 @@ function writePolicyYaml(targetDir, config, layered) {
297
297
  lines.push(` max_bash_output_lines: ${cp.max_bash_output_lines}`);
298
298
  }
299
299
  }
300
+ // 0.18.1+ helixir #9: emit audit.rotation when the layered profile
301
+ // declared it. Empty `rotation: {}` opts in to documented defaults
302
+ // (50 MiB / 30 days); explicit values override.
303
+ if (layered.audit !== undefined) {
304
+ lines.push(`audit:`);
305
+ if (layered.audit.rotation !== undefined) {
306
+ const rot = layered.audit.rotation;
307
+ const hasFields = rot.max_bytes !== undefined || rot.max_age_days !== undefined;
308
+ lines.push(hasFields ? ` rotation:` : ` rotation: {}`);
309
+ if (rot.max_bytes !== undefined) {
310
+ lines.push(` max_bytes: ${rot.max_bytes}`);
311
+ }
312
+ if (rot.max_age_days !== undefined) {
313
+ lines.push(` max_age_days: ${rot.max_age_days}`);
314
+ }
315
+ }
316
+ }
300
317
  // G11.4: always emit the review block explicitly. Making the value
301
318
  // visible in the generated file helps the operator notice what was
302
319
  // chosen at init time and simplifies switching modes later (edit a
@@ -30,6 +30,7 @@ import { resolveBaseRef } from './base.js';
30
30
  import { createRealGitExecutor, runCodexReview, CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, } from './codex-runner.js';
31
31
  import { summarizeReview } from './findings.js';
32
32
  import { renderBanner, writeLastReview } from './report.js';
33
+ import { isFlip, lookupVerdict, writeVerdict, } from './verdict-cache.js';
33
34
  /**
34
35
  * Parse the raw pre-push stdin text into refspecs. Each line is four
35
36
  * whitespace-separated fields. Blank lines and malformed lines are
@@ -72,6 +73,8 @@ const EVT_DISABLED = 'rea.push_gate.disabled';
72
73
  const EVT_SKIPPED = 'rea.push_gate.skipped';
73
74
  const EVT_EMPTY = 'rea.push_gate.empty_diff';
74
75
  const EVT_ERROR = 'rea.push_gate.error';
76
+ const EVT_CACHE_HIT = 'rea.push_gate.cache_hit';
77
+ const EVT_VERDICT_FLIP = 'rea.push_gate.verdict_flip';
75
78
  // ---------------------------------------------------------------------------
76
79
  // Composer
77
80
  // ---------------------------------------------------------------------------
@@ -335,7 +338,46 @@ export async function runPushGate(deps) {
335
338
  headSha,
336
339
  };
337
340
  }
338
- // 6. Run Codex. Typed errors translate to exit 2 with distinct stderr.
341
+ // 6a. Verdict cache lookup (0.18.1 helixir #1, #4, #7, #8). Same-SHA
342
+ // pushes within the configured TTL skip the codex invocation and
343
+ // reuse the cached verdict — durable PASS. Cache is bypassed when
344
+ // policy.review.cache_ttl_ms is 0. Cache miss / expired falls
345
+ // through to the codex call below.
346
+ const cacheLookup = policy.cache_ttl_ms > 0 ? lookupVerdict(deps.baseDir, headSha) : { hit: false };
347
+ if (cacheLookup.hit && cacheLookup.entry !== undefined) {
348
+ const cached = cacheLookup.entry;
349
+ const cachedBlocked = cached.verdict === 'blocking'
350
+ || (cached.verdict === 'concerns' && policy.concerns_blocks && !isConcernsOverrideSet(env));
351
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_CACHE_HIT, {
352
+ verdict: cached.verdict,
353
+ finding_count: cached.finding_count,
354
+ base_ref: base.ref,
355
+ base_source: base.source,
356
+ head_sha: headSha,
357
+ cached_reviewed_at: cached.reviewed_at,
358
+ cached_model: cached.model,
359
+ cached_reasoning_effort: cached.reasoning_effort,
360
+ blocked: cachedBlocked,
361
+ });
362
+ return {
363
+ status: cachedBlocked
364
+ ? cached.verdict === 'blocking'
365
+ ? 'blocking'
366
+ : 'concerns'
367
+ : cached.verdict === 'blocking'
368
+ ? 'blocking'
369
+ : cached.verdict === 'concerns'
370
+ ? 'concerns'
371
+ : 'pass',
372
+ exitCode: cachedBlocked ? 2 : 0,
373
+ summary: `${cached.verdict}: ${cached.finding_count} finding(s) (cached)`,
374
+ verdict: cached.verdict,
375
+ findingCount: cached.finding_count,
376
+ baseRef: base.ref,
377
+ headSha,
378
+ };
379
+ }
380
+ // 6b. Run Codex. Typed errors translate to exit 2 with distinct stderr.
339
381
  try {
340
382
  const codexResult = await runCodexFn({
341
383
  baseRef: base.ref,
@@ -372,6 +414,40 @@ export async function runPushGate(deps) {
372
414
  blocked,
373
415
  lastReviewPath,
374
416
  }));
417
+ // 0.18.1 verdict cache write + flip detection. The lookup at step
418
+ // 6a already returned miss/expired; if `cacheLookup.entry` is set,
419
+ // a stale entry existed — compare its verdict to the fresh one and
420
+ // emit a flip event when they differ. Operators can grep
421
+ // `rea.push_gate.verdict_flip` in the audit log to detect codex
422
+ // non-determinism (helixir #8).
423
+ if (policy.cache_ttl_ms > 0) {
424
+ const flipped = isFlip(cacheLookup.entry, summary.verdict);
425
+ if (flipped && cacheLookup.entry !== undefined) {
426
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_VERDICT_FLIP, {
427
+ head_sha: headSha,
428
+ prior_verdict: cacheLookup.entry.verdict,
429
+ fresh_verdict: summary.verdict,
430
+ prior_reviewed_at: cacheLookup.entry.reviewed_at,
431
+ base_ref: base.ref,
432
+ });
433
+ }
434
+ const entry = {
435
+ verdict: summary.verdict,
436
+ finding_count: summary.findings.length,
437
+ reviewed_at: deps.now !== undefined ? deps.now().toISOString() : new Date().toISOString(),
438
+ model: policy.codex_model ?? 'gpt-5.4',
439
+ reasoning_effort: policy.codex_reasoning_effort ?? 'high',
440
+ ttl_ms: policy.cache_ttl_ms,
441
+ };
442
+ try {
443
+ writeVerdict(deps.baseDir, headSha, entry);
444
+ }
445
+ catch {
446
+ // Cache writes are best-effort. A failure here must NOT
447
+ // affect the verdict — log to stderr (already done by the
448
+ // caller via banner) and proceed.
449
+ }
450
+ }
375
451
  await safeAppend(appendAuditFn, deps.baseDir, EVT_REVIEWED, {
376
452
  verdict: summary.verdict,
377
453
  finding_count: summary.findings.length,
@@ -386,6 +462,9 @@ export async function runPushGate(deps) {
386
462
  last_n_commits_requested: base.lastNCommitsRequested,
387
463
  auto_narrowed: autoNarrowed ? true : undefined,
388
464
  original_commit_count: originalCommitCount !== null ? originalCommitCount : undefined,
465
+ flipped: cacheLookup.entry !== undefined && isFlip(cacheLookup.entry, summary.verdict)
466
+ ? true
467
+ : undefined,
389
468
  });
390
469
  if (blocked) {
391
470
  return {
@@ -56,6 +56,12 @@ export interface ResolvedReviewPolicy {
56
56
  * codex's own default (currently `medium`).
57
57
  */
58
58
  codex_reasoning_effort: 'low' | 'medium' | 'high' | undefined;
59
+ /**
60
+ * Verdict cache TTL in milliseconds (0.18.1+). `0` disables caching;
61
+ * positive values enable the same-SHA short-circuit. Default 86_400_000
62
+ * (24 hours) when policy.review.cache_ttl_ms is unset.
63
+ */
64
+ cache_ttl_ms: number;
59
65
  /** `true` when `.rea/policy.yaml` was absent; defaults apply. */
60
66
  policyMissing: boolean;
61
67
  }
@@ -97,6 +103,17 @@ export declare const PUSH_GATE_DEFAULT_CODEX_MODEL = "gpt-5.4";
97
103
  * `.rea/policy.yaml` for cost-bounded environments.
98
104
  */
99
105
  export declare const PUSH_GATE_DEFAULT_CODEX_REASONING_EFFORT: 'low' | 'medium' | 'high';
106
+ /**
107
+ * Default verdict-cache TTL in milliseconds (0.18.1+). 24 hours: long
108
+ * enough to amortize multi-push iteration of the same SHA (push, push
109
+ * --force-with-lease after a quick fixup, push again post-rebase),
110
+ * short enough that a stale cache from yesterday doesn't suppress
111
+ * review of code whose context (env, dependencies, .rea/policy.yaml)
112
+ * has changed. Operators can shorten to a few minutes for tighter
113
+ * loops or extend via `policy.review.cache_ttl_ms`. `0` disables
114
+ * caching — every push re-invokes codex (pre-0.18.1 behavior).
115
+ */
116
+ export declare const PUSH_GATE_DEFAULT_CACHE_TTL_MS: number;
100
117
  /**
101
118
  * Resolve the push-gate policy for `baseDir`. Never throws — a malformed
102
119
  * policy file surfaces as a typed error via the underlying zod validator,
@@ -66,6 +66,17 @@ export const PUSH_GATE_DEFAULT_CODEX_MODEL = 'gpt-5.4';
66
66
  * `.rea/policy.yaml` for cost-bounded environments.
67
67
  */
68
68
  export const PUSH_GATE_DEFAULT_CODEX_REASONING_EFFORT = 'high';
69
+ /**
70
+ * Default verdict-cache TTL in milliseconds (0.18.1+). 24 hours: long
71
+ * enough to amortize multi-push iteration of the same SHA (push, push
72
+ * --force-with-lease after a quick fixup, push again post-rebase),
73
+ * short enough that a stale cache from yesterday doesn't suppress
74
+ * review of code whose context (env, dependencies, .rea/policy.yaml)
75
+ * has changed. Operators can shorten to a few minutes for tighter
76
+ * loops or extend via `policy.review.cache_ttl_ms`. `0` disables
77
+ * caching — every push re-invokes codex (pre-0.18.1 behavior).
78
+ */
79
+ export const PUSH_GATE_DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
69
80
  /**
70
81
  * Resolve the push-gate policy for `baseDir`. Never throws — a malformed
71
82
  * policy file surfaces as a typed error via the underlying zod validator,
@@ -87,6 +98,7 @@ export async function resolvePushGatePolicy(baseDir) {
87
98
  auto_narrow_threshold: PUSH_GATE_DEFAULT_AUTO_NARROW_THRESHOLD,
88
99
  codex_model: PUSH_GATE_DEFAULT_CODEX_MODEL,
89
100
  codex_reasoning_effort: PUSH_GATE_DEFAULT_CODEX_REASONING_EFFORT,
101
+ cache_ttl_ms: PUSH_GATE_DEFAULT_CACHE_TTL_MS,
90
102
  policyMissing: true,
91
103
  };
92
104
  }
@@ -100,6 +112,7 @@ export async function resolvePushGatePolicy(baseDir) {
100
112
  auto_narrow_threshold: review.auto_narrow_threshold ?? PUSH_GATE_DEFAULT_AUTO_NARROW_THRESHOLD,
101
113
  codex_model: review.codex_model ?? PUSH_GATE_DEFAULT_CODEX_MODEL,
102
114
  codex_reasoning_effort: review.codex_reasoning_effort ?? PUSH_GATE_DEFAULT_CODEX_REASONING_EFFORT,
115
+ cache_ttl_ms: review.cache_ttl_ms ?? PUSH_GATE_DEFAULT_CACHE_TTL_MS,
103
116
  policyMissing: false,
104
117
  };
105
118
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Durable verdict cache for the push-gate (helixir #1, #4, #7, #8 / 0.18.1).
3
+ *
4
+ * Pre-0.18.1 the push-gate was strictly stateless: every push of the same
5
+ * `head_sha` invoked `codex exec review` afresh. helixir round 82 reproduced
6
+ * the failure mode — push #1 of `9fbdfb63` returned PASS, push #2 of the
7
+ * IDENTICAL commit returned CONCERNS — 1 P2. The verdict instability is
8
+ * a property of codex's stochastic decoding at `reasoning_effort: high`;
9
+ * rea cannot eliminate it, but rea CAN make a clean PASS DURABLE so the
10
+ * second push of the same SHA doesn't roll the dice again.
11
+ *
12
+ * Design:
13
+ *
14
+ * .rea/last-review.cache.json
15
+ * {
16
+ * schema_version: 2,
17
+ * entries: {
18
+ * "<head_sha>": {
19
+ * verdict: "pass" | "concerns" | "blocking",
20
+ * finding_count: number,
21
+ * reviewed_at: ISO8601,
22
+ * model: string,
23
+ * reasoning_effort: "low" | "medium" | "high",
24
+ * ttl_ms: number, // policy.review.cache_ttl_ms at write time
25
+ * },
26
+ * ...
27
+ * }
28
+ * }
29
+ *
30
+ * - Hit (within TTL): emit `rea.push_gate.cache_hit` audit event, exit
31
+ * with the cached verdict + finding count; codex is NOT invoked.
32
+ * - Miss or expired: invoke codex; on success, write the new entry.
33
+ * - Flip detection: if a new codex result on the same SHA produces a
34
+ * verdict different from the cached one, set `last-review.json.flip_flag = true`,
35
+ * emit `rea.push_gate.verdict_flip`, and overwrite the cache with
36
+ * the fresh result. Operators can detect non-determinism from the
37
+ * audit log alone (helixir #8).
38
+ * - REA_SKIP_CODEX_REVIEW short-circuits BEFORE cache lookup (unchanged).
39
+ *
40
+ * The cache is OPTIONAL by design: existing callers that don't pass a
41
+ * `cacheImpl` get the legacy stateless path. Tests inject a fake.
42
+ */
43
+ import type { Verdict as ReviewVerdict } from './findings.js';
44
+ export declare const VERDICT_CACHE_FILE = "last-review.cache.json";
45
+ export declare const VERDICT_CACHE_SCHEMA_VERSION: 2;
46
+ export declare const DEFAULT_CACHE_TTL_MS: number;
47
+ export interface VerdictCacheEntry {
48
+ verdict: ReviewVerdict;
49
+ finding_count: number;
50
+ reviewed_at: string;
51
+ model: string;
52
+ reasoning_effort: 'low' | 'medium' | 'high';
53
+ ttl_ms: number;
54
+ }
55
+ export interface VerdictCacheLookupResult {
56
+ /** True if a non-expired entry exists for this SHA. */
57
+ hit: boolean;
58
+ /** The entry, present on both hit and miss-of-stale-entry. Used for flip detection. */
59
+ entry?: VerdictCacheEntry;
60
+ /** True if the entry exists but is past TTL. */
61
+ expired?: boolean;
62
+ }
63
+ /**
64
+ * Read the cache file and look up `head_sha`. Missing file, malformed
65
+ * JSON, missing entry, and unsupported schema_version all resolve to a
66
+ * miss with `entry: undefined` — the caller proceeds to codex.
67
+ */
68
+ export declare function lookupVerdict(baseDir: string, headSha: string, now?: Date): VerdictCacheLookupResult;
69
+ /**
70
+ * Write a fresh verdict entry. Atomic via tmp-file + rename. Unrecognized
71
+ * pre-existing entries are preserved (forward-compat for v3+).
72
+ */
73
+ export declare function writeVerdict(baseDir: string, headSha: string, entry: VerdictCacheEntry): void;
74
+ /**
75
+ * Detect whether a new verdict contradicts a previously-cached verdict
76
+ * on the same SHA. Used by `runPushGate` to set the flip-flag on
77
+ * last-review.json and emit the `verdict_flip` audit event.
78
+ */
79
+ export declare function isFlip(prior: VerdictCacheEntry | undefined, fresh: ReviewVerdict): boolean;
80
+ /**
81
+ * Remove a single SHA from the cache. Returns true if the entry existed.
82
+ */
83
+ export declare function clearVerdict(baseDir: string, headSha: string): boolean;
84
+ /**
85
+ * Remove ALL entries from the cache. Returns the count of removed entries.
86
+ */
87
+ export declare function clearAll(baseDir: string): number;
88
+ /**
89
+ * Remove entries whose `reviewed_at` is older than `olderThanMs` from `now`.
90
+ * Returns the count of removed entries.
91
+ */
92
+ export declare function pruneOlderThan(baseDir: string, olderThanMs: number, now?: Date): number;
93
+ /**
94
+ * Read all entries (used by `rea cache stats` / `rea cache show`).
95
+ * Returns empty object on any read error (missing file, malformed JSON,
96
+ * unsupported schema_version).
97
+ */
98
+ export declare function listEntries(baseDir: string): Record<string, VerdictCacheEntry>;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Durable verdict cache for the push-gate (helixir #1, #4, #7, #8 / 0.18.1).
3
+ *
4
+ * Pre-0.18.1 the push-gate was strictly stateless: every push of the same
5
+ * `head_sha` invoked `codex exec review` afresh. helixir round 82 reproduced
6
+ * the failure mode — push #1 of `9fbdfb63` returned PASS, push #2 of the
7
+ * IDENTICAL commit returned CONCERNS — 1 P2. The verdict instability is
8
+ * a property of codex's stochastic decoding at `reasoning_effort: high`;
9
+ * rea cannot eliminate it, but rea CAN make a clean PASS DURABLE so the
10
+ * second push of the same SHA doesn't roll the dice again.
11
+ *
12
+ * Design:
13
+ *
14
+ * .rea/last-review.cache.json
15
+ * {
16
+ * schema_version: 2,
17
+ * entries: {
18
+ * "<head_sha>": {
19
+ * verdict: "pass" | "concerns" | "blocking",
20
+ * finding_count: number,
21
+ * reviewed_at: ISO8601,
22
+ * model: string,
23
+ * reasoning_effort: "low" | "medium" | "high",
24
+ * ttl_ms: number, // policy.review.cache_ttl_ms at write time
25
+ * },
26
+ * ...
27
+ * }
28
+ * }
29
+ *
30
+ * - Hit (within TTL): emit `rea.push_gate.cache_hit` audit event, exit
31
+ * with the cached verdict + finding count; codex is NOT invoked.
32
+ * - Miss or expired: invoke codex; on success, write the new entry.
33
+ * - Flip detection: if a new codex result on the same SHA produces a
34
+ * verdict different from the cached one, set `last-review.json.flip_flag = true`,
35
+ * emit `rea.push_gate.verdict_flip`, and overwrite the cache with
36
+ * the fresh result. Operators can detect non-determinism from the
37
+ * audit log alone (helixir #8).
38
+ * - REA_SKIP_CODEX_REVIEW short-circuits BEFORE cache lookup (unchanged).
39
+ *
40
+ * The cache is OPTIONAL by design: existing callers that don't pass a
41
+ * `cacheImpl` get the legacy stateless path. Tests inject a fake.
42
+ */
43
+ import fs from 'node:fs';
44
+ import path from 'node:path';
45
+ export const VERDICT_CACHE_FILE = 'last-review.cache.json';
46
+ export const VERDICT_CACHE_SCHEMA_VERSION = 2;
47
+ export const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1_000; // 24h
48
+ /**
49
+ * Read the cache file and look up `head_sha`. Missing file, malformed
50
+ * JSON, missing entry, and unsupported schema_version all resolve to a
51
+ * miss with `entry: undefined` — the caller proceeds to codex.
52
+ */
53
+ export function lookupVerdict(baseDir, headSha, now = new Date()) {
54
+ const file = readCacheFile(baseDir);
55
+ if (file === undefined)
56
+ return { hit: false };
57
+ const entry = file.entries[headSha];
58
+ if (entry === undefined)
59
+ return { hit: false };
60
+ const reviewedAtMs = Date.parse(entry.reviewed_at);
61
+ if (Number.isNaN(reviewedAtMs))
62
+ return { hit: false, entry };
63
+ const ageMs = now.getTime() - reviewedAtMs;
64
+ if (ageMs >= entry.ttl_ms) {
65
+ return { hit: false, entry, expired: true };
66
+ }
67
+ return { hit: true, entry };
68
+ }
69
+ /**
70
+ * Write a fresh verdict entry. Atomic via tmp-file + rename. Unrecognized
71
+ * pre-existing entries are preserved (forward-compat for v3+).
72
+ */
73
+ export function writeVerdict(baseDir, headSha, entry) {
74
+ const reaDir = path.join(baseDir, '.rea');
75
+ if (!fs.existsSync(reaDir)) {
76
+ fs.mkdirSync(reaDir, { recursive: true });
77
+ }
78
+ const cachePath = path.join(reaDir, VERDICT_CACHE_FILE);
79
+ const existing = readCacheFile(baseDir);
80
+ const next = {
81
+ schema_version: VERDICT_CACHE_SCHEMA_VERSION,
82
+ entries: { ...(existing?.entries ?? {}), [headSha]: entry },
83
+ };
84
+ const tmp = `${cachePath}.tmp.${process.pid}`;
85
+ fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
86
+ fs.renameSync(tmp, cachePath);
87
+ }
88
+ /**
89
+ * Detect whether a new verdict contradicts a previously-cached verdict
90
+ * on the same SHA. Used by `runPushGate` to set the flip-flag on
91
+ * last-review.json and emit the `verdict_flip` audit event.
92
+ */
93
+ export function isFlip(prior, fresh) {
94
+ if (prior === undefined)
95
+ return false;
96
+ return prior.verdict !== fresh;
97
+ }
98
+ /**
99
+ * Remove a single SHA from the cache. Returns true if the entry existed.
100
+ */
101
+ export function clearVerdict(baseDir, headSha) {
102
+ const file = readCacheFile(baseDir);
103
+ if (file === undefined || file.entries[headSha] === undefined)
104
+ return false;
105
+ const next = {
106
+ schema_version: VERDICT_CACHE_SCHEMA_VERSION,
107
+ entries: { ...file.entries },
108
+ };
109
+ delete next.entries[headSha];
110
+ const cachePath = path.join(baseDir, '.rea', VERDICT_CACHE_FILE);
111
+ fs.writeFileSync(cachePath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
112
+ return true;
113
+ }
114
+ /**
115
+ * Remove ALL entries from the cache. Returns the count of removed entries.
116
+ */
117
+ export function clearAll(baseDir) {
118
+ const file = readCacheFile(baseDir);
119
+ const cachePath = path.join(baseDir, '.rea', VERDICT_CACHE_FILE);
120
+ const count = file === undefined ? 0 : Object.keys(file.entries).length;
121
+ const empty = {
122
+ schema_version: VERDICT_CACHE_SCHEMA_VERSION,
123
+ entries: {},
124
+ };
125
+ if (!fs.existsSync(path.dirname(cachePath))) {
126
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
127
+ }
128
+ fs.writeFileSync(cachePath, `${JSON.stringify(empty, null, 2)}\n`, 'utf8');
129
+ return count;
130
+ }
131
+ /**
132
+ * Remove entries whose `reviewed_at` is older than `olderThanMs` from `now`.
133
+ * Returns the count of removed entries.
134
+ */
135
+ export function pruneOlderThan(baseDir, olderThanMs, now = new Date()) {
136
+ const file = readCacheFile(baseDir);
137
+ if (file === undefined)
138
+ return 0;
139
+ const cutoff = now.getTime() - olderThanMs;
140
+ const surviving = {};
141
+ let removed = 0;
142
+ for (const [sha, entry] of Object.entries(file.entries)) {
143
+ const reviewedAtMs = Date.parse(entry.reviewed_at);
144
+ if (Number.isNaN(reviewedAtMs) || reviewedAtMs >= cutoff) {
145
+ surviving[sha] = entry;
146
+ }
147
+ else {
148
+ removed += 1;
149
+ }
150
+ }
151
+ if (removed === 0)
152
+ return 0;
153
+ const next = {
154
+ schema_version: VERDICT_CACHE_SCHEMA_VERSION,
155
+ entries: surviving,
156
+ };
157
+ const cachePath = path.join(baseDir, '.rea', VERDICT_CACHE_FILE);
158
+ fs.writeFileSync(cachePath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
159
+ return removed;
160
+ }
161
+ /**
162
+ * Read all entries (used by `rea cache stats` / `rea cache show`).
163
+ * Returns empty object on any read error (missing file, malformed JSON,
164
+ * unsupported schema_version).
165
+ */
166
+ export function listEntries(baseDir) {
167
+ const file = readCacheFile(baseDir);
168
+ return file?.entries ?? {};
169
+ }
170
+ function readCacheFile(baseDir) {
171
+ const cachePath = path.join(baseDir, '.rea', VERDICT_CACHE_FILE);
172
+ if (!fs.existsSync(cachePath))
173
+ return undefined;
174
+ try {
175
+ const raw = fs.readFileSync(cachePath, 'utf8');
176
+ const parsed = JSON.parse(raw);
177
+ if (typeof parsed !== 'object' ||
178
+ parsed === null ||
179
+ parsed.schema_version !== VERDICT_CACHE_SCHEMA_VERSION) {
180
+ return undefined;
181
+ }
182
+ const entries = parsed.entries;
183
+ if (typeof entries !== 'object' || entries === null)
184
+ return undefined;
185
+ return parsed;
186
+ }
187
+ catch {
188
+ return undefined;
189
+ }
190
+ }
@@ -81,6 +81,15 @@ declare const PolicySchema: z.ZodObject<{
81
81
  * matters less than throughput.
82
82
  */
83
83
  codex_reasoning_effort: z.ZodOptional<z.ZodEnum<["low", "medium", "high"]>>;
84
+ /**
85
+ * Verdict cache TTL in milliseconds (0.18.1+ helixir #1, #4, #7, #8).
86
+ * Default 86_400_000 (24 hours). When a push of `head_sha` produces
87
+ * a non-blocking verdict, the result is written to
88
+ * `.rea/last-review.cache.json`. Subsequent pushes of the same SHA
89
+ * within the TTL skip the codex invocation and reuse the cached
90
+ * verdict. Set to 0 to disable caching (every push re-invokes codex).
91
+ */
92
+ cache_ttl_ms: z.ZodOptional<z.ZodNumber>;
84
93
  }, "strict", z.ZodTypeAny, {
85
94
  codex_required?: boolean | undefined;
86
95
  concerns_blocks?: boolean | undefined;
@@ -89,6 +98,7 @@ declare const PolicySchema: z.ZodObject<{
89
98
  auto_narrow_threshold?: number | undefined;
90
99
  codex_model?: string | undefined;
91
100
  codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
101
+ cache_ttl_ms?: number | undefined;
92
102
  }, {
93
103
  codex_required?: boolean | undefined;
94
104
  concerns_blocks?: boolean | undefined;
@@ -97,6 +107,7 @@ declare const PolicySchema: z.ZodObject<{
97
107
  auto_narrow_threshold?: number | undefined;
98
108
  codex_model?: string | undefined;
99
109
  codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
110
+ cache_ttl_ms?: number | undefined;
100
111
  }>>;
101
112
  redact: z.ZodOptional<z.ZodObject<{
102
113
  match_timeout_ms: z.ZodOptional<z.ZodNumber>;
@@ -196,6 +207,7 @@ declare const PolicySchema: z.ZodObject<{
196
207
  auto_narrow_threshold?: number | undefined;
197
208
  codex_model?: string | undefined;
198
209
  codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
210
+ cache_ttl_ms?: number | undefined;
199
211
  } | undefined;
200
212
  redact?: {
201
213
  match_timeout_ms?: number | undefined;
@@ -245,6 +257,7 @@ declare const PolicySchema: z.ZodObject<{
245
257
  auto_narrow_threshold?: number | undefined;
246
258
  codex_model?: string | undefined;
247
259
  codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
260
+ cache_ttl_ms?: number | undefined;
248
261
  } | undefined;
249
262
  redact?: {
250
263
  match_timeout_ms?: number | undefined;
@@ -72,6 +72,15 @@ const ReviewPolicySchema = z
72
72
  * matters less than throughput.
73
73
  */
74
74
  codex_reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),
75
+ /**
76
+ * Verdict cache TTL in milliseconds (0.18.1+ helixir #1, #4, #7, #8).
77
+ * Default 86_400_000 (24 hours). When a push of `head_sha` produces
78
+ * a non-blocking verdict, the result is written to
79
+ * `.rea/last-review.cache.json`. Subsequent pushes of the same SHA
80
+ * within the TTL skip the codex invocation and reuse the cached
81
+ * verdict. Set to 0 to disable caching (every push re-invokes codex).
82
+ */
83
+ cache_ttl_ms: z.number().int().nonnegative().optional(),
75
84
  })
76
85
  .strict();
77
86
  /**
@@ -47,6 +47,28 @@ export declare const ProfileSchema: z.ZodObject<{
47
47
  delegate_to_subagent?: string[] | undefined;
48
48
  max_bash_output_lines?: number | undefined;
49
49
  }>>;
50
+ audit: z.ZodOptional<z.ZodObject<{
51
+ rotation: z.ZodOptional<z.ZodObject<{
52
+ max_bytes: z.ZodOptional<z.ZodNumber>;
53
+ max_age_days: z.ZodOptional<z.ZodNumber>;
54
+ }, "strip", z.ZodTypeAny, {
55
+ max_bytes?: number | undefined;
56
+ max_age_days?: number | undefined;
57
+ }, {
58
+ max_bytes?: number | undefined;
59
+ max_age_days?: number | undefined;
60
+ }>>;
61
+ }, "strip", z.ZodTypeAny, {
62
+ rotation?: {
63
+ max_bytes?: number | undefined;
64
+ max_age_days?: number | undefined;
65
+ } | undefined;
66
+ }, {
67
+ rotation?: {
68
+ max_bytes?: number | undefined;
69
+ max_age_days?: number | undefined;
70
+ } | undefined;
71
+ }>>;
50
72
  }, "strict", z.ZodTypeAny, {
51
73
  autonomy_level?: AutonomyLevel | undefined;
52
74
  max_autonomy_level?: AutonomyLevel | undefined;
@@ -64,6 +86,12 @@ export declare const ProfileSchema: z.ZodObject<{
64
86
  delegate_to_subagent?: string[] | undefined;
65
87
  max_bash_output_lines?: number | undefined;
66
88
  } | undefined;
89
+ audit?: {
90
+ rotation?: {
91
+ max_bytes?: number | undefined;
92
+ max_age_days?: number | undefined;
93
+ } | undefined;
94
+ } | undefined;
67
95
  }, {
68
96
  autonomy_level?: AutonomyLevel | undefined;
69
97
  max_autonomy_level?: AutonomyLevel | undefined;
@@ -81,6 +109,12 @@ export declare const ProfileSchema: z.ZodObject<{
81
109
  delegate_to_subagent?: string[] | undefined;
82
110
  max_bash_output_lines?: number | undefined;
83
111
  } | undefined;
112
+ audit?: {
113
+ rotation?: {
114
+ max_bytes?: number | undefined;
115
+ max_age_days?: number | undefined;
116
+ } | undefined;
117
+ } | undefined;
84
118
  }>;
85
119
  export type Profile = z.infer<typeof ProfileSchema>;
86
120
  /** Hard defaults applied before any profile or wizard answer. */
@@ -54,6 +54,21 @@ export const ProfileSchema = z
54
54
  injection_detection: z.enum(['block', 'warn']).optional(),
55
55
  injection: InjectionProfileSchema.optional(),
56
56
  context_protection: ContextProtectionProfileSchema.optional(),
57
+ // 0.18.1+ helixir #9: profiles can ship audit-rotation defaults.
58
+ // The full audit policy block validates at load time via
59
+ // `AuditPolicySchema` in loader.ts; profiles only need to declare
60
+ // the rotation knob (most consumer profiles will leave this empty
61
+ // — the default 50 MiB / 30 days are sane).
62
+ audit: z
63
+ .object({
64
+ rotation: z
65
+ .object({
66
+ max_bytes: z.number().int().positive().optional(),
67
+ max_age_days: z.number().int().positive().optional(),
68
+ })
69
+ .optional(),
70
+ })
71
+ .optional(),
57
72
  })
58
73
  .strict();
59
74
  /** Hard defaults applied before any profile or wizard answer. */
@@ -158,6 +158,17 @@ export interface ReviewPolicy {
158
158
  * throughput.
159
159
  */
160
160
  codex_reasoning_effort?: 'low' | 'medium' | 'high';
161
+ /**
162
+ * Verdict cache TTL in milliseconds (0.18.1+ helixir #1, #4, #7, #8).
163
+ * Default 86_400_000 (24 hours). When a push of `head_sha` produces a
164
+ * non-blocking verdict, the result is written to
165
+ * `.rea/last-review.cache.json`. Subsequent pushes of the same SHA
166
+ * within the TTL skip the codex invocation and reuse the cached
167
+ * verdict. Set to `0` to disable caching (every push re-invokes
168
+ * codex — pre-0.18.1 behavior). Verdict flips on the same SHA emit
169
+ * a `rea.push_gate.verdict_flip` audit event and overwrite the cache.
170
+ */
171
+ cache_ttl_ms?: number;
161
172
  }
162
173
  /**
163
174
  * 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.18.0",
3
+ "version": "0.19.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)",
@@ -28,3 +28,11 @@ context_protection:
28
28
  - pnpm run test
29
29
  - pnpm run lint
30
30
  max_bash_output_lines: 100
31
+ # 0.18.1+ helixir #9: enable audit log rotation by default for
32
+ # bst-internal. Long sessions accumulate 100s of push_gate.reviewed
33
+ # entries; without rotation the audit file grows unbounded. The empty
34
+ # `rotation: {}` block opts in to the documented defaults — 50 MiB
35
+ # OR 30 days, whichever arrives first. Rotation marker preserves the
36
+ # hash chain across the boundary.
37
+ audit:
38
+ rotation: {}
@@ -116,13 +116,51 @@ try {
116
116
 
117
117
  if (manifestVersion === installedVersion) process.exit(0);
118
118
 
119
- // Package-manager-agnostic message. Any of `npx rea upgrade`,
119
+ // 0.18.1+ helixir #3: opt-in auto-upgrade. Pre-fix the drift was
120
+ // detected and a "run rea upgrade" nudge printed, but consumers had
121
+ // to run the upgrade by hand on every install. With
122
+ // `REA_AUTO_UPGRADE=1` (or `--yes` semantics inferred from a
123
+ // package.json field), the postinstall runs `rea upgrade --yes`
124
+ // for them. Defaults to PRINT-ONLY for back-compat — silent
125
+ // mutation of the consumer's `.claude/` / `.husky/` on every
126
+ // install would surprise existing users.
127
+ const autoUpgrade =
128
+ process.env.REA_AUTO_UPGRADE === '1' ||
129
+ process.env.REA_AUTO_UPGRADE === 'true';
130
+
131
+ if (autoUpgrade) {
132
+ // Best-effort: invoke `rea upgrade --yes`. Failures fall through to
133
+ // the print path so the consumer still sees the drift advisory.
134
+ try {
135
+ const reaCli = path.join(consumerRoot, 'node_modules', '.bin', 'rea');
136
+ if (fs.existsSync(reaCli)) {
137
+ const { spawnSync } = await import('node:child_process');
138
+ const res = spawnSync(reaCli, ['upgrade', '--yes'], {
139
+ cwd: consumerRoot,
140
+ stdio: 'inherit',
141
+ env: process.env,
142
+ });
143
+ if (res.status === 0) {
144
+ NOTE([
145
+ `@bookedsolid/rea: auto-upgraded from v${manifestVersion} to v${installedVersion}.`,
146
+ `(REA_AUTO_UPGRADE=1; set REA_AUTO_UPGRADE=0 to opt out.)`,
147
+ ]);
148
+ process.exit(0);
149
+ }
150
+ }
151
+ } catch {
152
+ // Fall through to the manual-nudge path below.
153
+ }
154
+ }
155
+
156
+ // Package-manager-agnostic nudge. Any of `npx rea upgrade`,
120
157
  // `pnpm exec rea upgrade`, or `yarn rea upgrade` works; recommending `npx`
121
158
  // covers the widest audience without privileging pnpm in error output.
122
159
  NOTE([
123
160
  `@bookedsolid/rea v${installedVersion} installed; manifest at v${manifestVersion}.`,
124
161
  `Run \`npx rea upgrade\` to sync .claude/, .husky/, and managed fragments.`,
125
162
  `(Or \`npx rea doctor --drift\` to preview without changes.)`,
163
+ `(Set \`REA_AUTO_UPGRADE=1\` to auto-run upgrade on future installs.)`,
126
164
  ]);
127
165
  } catch {
128
166
  // Any uncaught failure → silent success. Never break the consumer's install.