@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 +17 -0
- package/dist/hooks/push-gate/index.js +80 -1
- package/dist/hooks/push-gate/policy.d.ts +17 -0
- package/dist/hooks/push-gate/policy.js +13 -0
- package/dist/hooks/push-gate/verdict-cache.d.ts +98 -0
- package/dist/hooks/push-gate/verdict-cache.js +190 -0
- package/dist/policy/loader.d.ts +13 -0
- package/dist/policy/loader.js +9 -0
- package/dist/policy/profiles.d.ts +34 -0
- package/dist/policy/profiles.js +15 -0
- package/dist/policy/types.d.ts +11 -0
- package/package.json +1 -1
- package/profiles/bst-internal.yaml +8 -0
- package/scripts/postinstall.mjs +39 -1
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
|
-
//
|
|
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
|
+
}
|
package/dist/policy/loader.d.ts
CHANGED
|
@@ -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;
|
package/dist/policy/loader.js
CHANGED
|
@@ -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. */
|
package/dist/policy/profiles.js
CHANGED
|
@@ -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. */
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -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.
|
|
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: {}
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -116,13 +116,51 @@ try {
|
|
|
116
116
|
|
|
117
117
|
if (manifestVersion === installedVersion) process.exit(0);
|
|
118
118
|
|
|
119
|
-
//
|
|
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.
|