@bookedsolid/rea 0.24.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/data-architect.md +181 -0
- package/agents/devex-architect.md +172 -0
- package/agents/platform-architect.md +171 -0
- package/agents/rea-orchestrator.md +18 -3
- 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
package/dist/cli/init.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import * as p from '@clack/prompts';
|
|
5
|
+
import { parse as parseYaml } from 'yaml';
|
|
5
6
|
import { AutonomyLevel } from '../policy/types.js';
|
|
6
7
|
import { HARD_DEFAULTS, loadProfile, mergeProfiles } from '../policy/profiles.js';
|
|
7
8
|
import { copyArtifacts } from './install/copy.js';
|
|
@@ -196,6 +197,29 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
|
|
|
196
197
|
blockedPaths: layeredBase.blocked_paths ?? ['.env', '.env.*'],
|
|
197
198
|
notificationChannel: layeredBase.notification_channel ?? '',
|
|
198
199
|
codexRequired,
|
|
200
|
+
// Round-27 F6: the wizard does NOT prompt for the 0.26.0 knobs (they
|
|
201
|
+
// are advanced config — most teams accept defaults). But when the
|
|
202
|
+
// existing on-disk policy carries them, forward them verbatim so a
|
|
203
|
+
// re-run preserves operator edits exactly the same way the --yes
|
|
204
|
+
// path does.
|
|
205
|
+
...(existingPolicy?.localReviewMode !== undefined
|
|
206
|
+
? { localReviewMode: existingPolicy.localReviewMode }
|
|
207
|
+
: {}),
|
|
208
|
+
...(existingPolicy?.localReviewRefuseAt !== undefined
|
|
209
|
+
? { localReviewRefuseAt: existingPolicy.localReviewRefuseAt }
|
|
210
|
+
: {}),
|
|
211
|
+
...(existingPolicy?.localReviewBypassEnvVar !== undefined
|
|
212
|
+
? { localReviewBypassEnvVar: existingPolicy.localReviewBypassEnvVar }
|
|
213
|
+
: {}),
|
|
214
|
+
...(existingPolicy?.localReviewMaxAgeSeconds !== undefined
|
|
215
|
+
? { localReviewMaxAgeSeconds: existingPolicy.localReviewMaxAgeSeconds }
|
|
216
|
+
: {}),
|
|
217
|
+
...(existingPolicy?.commitHygieneWarnAtCommits !== undefined
|
|
218
|
+
? { commitHygieneWarnAtCommits: existingPolicy.commitHygieneWarnAtCommits }
|
|
219
|
+
: {}),
|
|
220
|
+
...(existingPolicy?.commitHygieneRefuseAtCommits !== undefined
|
|
221
|
+
? { commitHygieneRefuseAtCommits: existingPolicy.commitHygieneRefuseAtCommits }
|
|
222
|
+
: {}),
|
|
199
223
|
fromReagent,
|
|
200
224
|
reagentPolicyPath,
|
|
201
225
|
reagentNotices: [],
|
|
@@ -259,62 +283,132 @@ async function printCodexInstallAssist() {
|
|
|
259
283
|
* VALUES are still preserved. Operators who want full reset pass
|
|
260
284
|
* `--force` to bypass the file-existence check entirely.
|
|
261
285
|
*/
|
|
286
|
+
/**
|
|
287
|
+
* Round-30 F3 (structural): read the existing policy via the canonical
|
|
288
|
+
* YAML parser instead of regex-scraping the raw text.
|
|
289
|
+
*
|
|
290
|
+
* Pre-fix the preservation reader used independent line-anchored regexes
|
|
291
|
+
* (`^\s+mode:`, `^\s+warn_at_commits:`, etc.) that ONLY matched
|
|
292
|
+
* block-form scalars. The TS loader (and `policy_nested_scalar` in the
|
|
293
|
+
* bash hooks) accept inline mappings — `local_review: { mode: off }` —
|
|
294
|
+
* but the regex preservation slipped them through, leaving the values
|
|
295
|
+
* `undefined` after re-read. The writer then skipped emission, and the
|
|
296
|
+
* inline block vanished entirely on a `rea init` re-run. Round-trip
|
|
297
|
+
* lossy across the inline/block divergence.
|
|
298
|
+
*
|
|
299
|
+
* Structural fix: parse the YAML once, walk the resulting object tree,
|
|
300
|
+
* and read each preservation key by dotted path. Inline AND block forms
|
|
301
|
+
* agree at the parsed layer — the parser folds both into the same
|
|
302
|
+
* object shape — so this fix closes the inline/block divergence for
|
|
303
|
+
* EVERY preservation key (the round-29 cross-cutting observation), not
|
|
304
|
+
* just the 6 round-28 fields.
|
|
305
|
+
*
|
|
306
|
+
* Failure modes handled:
|
|
307
|
+
* - Policy file missing — returns undefined (caller falls back to
|
|
308
|
+
* profile defaults; same behavior as pre-fix).
|
|
309
|
+
* - YAML malformed — returns undefined (same as pre-fix; the regex
|
|
310
|
+
* reader returned undefined on any thrown read error).
|
|
311
|
+
* - YAML parses but is null / not an object — returns an empty
|
|
312
|
+
* ExistingPolicyValues (no fields to preserve; profile defaults
|
|
313
|
+
* fill in).
|
|
314
|
+
* - Individual fields wrong type — silently dropped (permissive
|
|
315
|
+
* contract, same as the previous regex reader).
|
|
316
|
+
*/
|
|
262
317
|
function readExistingPolicyForPreservation(targetDir) {
|
|
263
318
|
const policyPath = path.join(targetDir, REA_DIR, POLICY_FILE);
|
|
264
319
|
if (!fs.existsSync(policyPath))
|
|
265
320
|
return undefined;
|
|
321
|
+
let parsed;
|
|
266
322
|
try {
|
|
267
323
|
const raw = fs.readFileSync(policyPath, 'utf8');
|
|
268
|
-
|
|
269
|
-
// Profile (informational; used for stderr advisory).
|
|
270
|
-
const pm = raw.match(/^profile:\s*['"]?([a-z0-9-]+)['"]?\s*$/m);
|
|
271
|
-
if (pm)
|
|
272
|
-
out.profile = pm[1];
|
|
273
|
-
// Autonomy + ceiling (enum).
|
|
274
|
-
const am = raw.match(/^autonomy_level:\s*(L[0-3])\s*$/m);
|
|
275
|
-
const amVal = am?.[1];
|
|
276
|
-
if (amVal !== undefined && Object.values(AutonomyLevel).includes(amVal)) {
|
|
277
|
-
out.autonomyLevel = amVal;
|
|
278
|
-
}
|
|
279
|
-
const mm = raw.match(/^max_autonomy_level:\s*(L[0-3])\s*$/m);
|
|
280
|
-
const mmVal = mm?.[1];
|
|
281
|
-
if (mmVal !== undefined && Object.values(AutonomyLevel).includes(mmVal)) {
|
|
282
|
-
out.maxAutonomyLevel = mmVal;
|
|
283
|
-
}
|
|
284
|
-
// block_ai_attribution.
|
|
285
|
-
const bm = raw.match(/^block_ai_attribution:\s*(true|false)\s*$/m);
|
|
286
|
-
if (bm?.[1] !== undefined)
|
|
287
|
-
out.blockAiAttribution = bm[1] === 'true';
|
|
288
|
-
// blocked_paths block-sequence — line-by-line scan.
|
|
289
|
-
const bpStart = raw.match(/^blocked_paths:\s*$/m);
|
|
290
|
-
if (bpStart) {
|
|
291
|
-
const after = raw.slice((bpStart.index ?? 0) + bpStart[0].length + 1);
|
|
292
|
-
const lines = after.split('\n');
|
|
293
|
-
const collected = [];
|
|
294
|
-
for (const line of lines) {
|
|
295
|
-
const m2 = line.match(/^\s*-\s+(?:['"]([^'"]+)['"]|(\S.*?))\s*$/);
|
|
296
|
-
if (!m2)
|
|
297
|
-
break;
|
|
298
|
-
const v = m2[1] ?? m2[2];
|
|
299
|
-
if (v !== undefined)
|
|
300
|
-
collected.push(v);
|
|
301
|
-
}
|
|
302
|
-
if (collected.length > 0)
|
|
303
|
-
out.blockedPaths = collected;
|
|
304
|
-
}
|
|
305
|
-
// notification_channel.
|
|
306
|
-
const nm = raw.match(/^notification_channel:\s*['"]?([^'"\n]*)['"]?\s*$/m);
|
|
307
|
-
if (nm?.[1] !== undefined)
|
|
308
|
-
out.notificationChannel = nm[1];
|
|
309
|
-
// review.codex_required (under nested `review:` block).
|
|
310
|
-
const cm = raw.match(/^\s+codex_required:\s*(true|false)\s*$/m);
|
|
311
|
-
if (cm?.[1] !== undefined)
|
|
312
|
-
out.codexRequired = cm[1] === 'true';
|
|
313
|
-
return out;
|
|
324
|
+
parsed = parseYaml(raw);
|
|
314
325
|
}
|
|
315
326
|
catch {
|
|
316
327
|
return undefined;
|
|
317
328
|
}
|
|
329
|
+
if (parsed === null || typeof parsed !== 'object') {
|
|
330
|
+
// Empty / non-mapping document — nothing to preserve, but signal
|
|
331
|
+
// the caller that the file did exist (pre-fix returned `out` even
|
|
332
|
+
// for a fully-empty file because every regex missed without
|
|
333
|
+
// throwing). Returning `{}` matches that pre-fix shape.
|
|
334
|
+
return {};
|
|
335
|
+
}
|
|
336
|
+
const policy = parsed;
|
|
337
|
+
const out = {};
|
|
338
|
+
// Top-level scalars.
|
|
339
|
+
const profile = policy['profile'];
|
|
340
|
+
if (typeof profile === 'string' && /^[a-z0-9-]+$/.test(profile)) {
|
|
341
|
+
out.profile = profile;
|
|
342
|
+
}
|
|
343
|
+
const autonomyLevel = policy['autonomy_level'];
|
|
344
|
+
if (typeof autonomyLevel === 'string' &&
|
|
345
|
+
Object.values(AutonomyLevel).includes(autonomyLevel)) {
|
|
346
|
+
out.autonomyLevel = autonomyLevel;
|
|
347
|
+
}
|
|
348
|
+
const maxAutonomyLevel = policy['max_autonomy_level'];
|
|
349
|
+
if (typeof maxAutonomyLevel === 'string' &&
|
|
350
|
+
Object.values(AutonomyLevel).includes(maxAutonomyLevel)) {
|
|
351
|
+
out.maxAutonomyLevel = maxAutonomyLevel;
|
|
352
|
+
}
|
|
353
|
+
const blockAiAttribution = policy['block_ai_attribution'];
|
|
354
|
+
if (typeof blockAiAttribution === 'boolean')
|
|
355
|
+
out.blockAiAttribution = blockAiAttribution;
|
|
356
|
+
// blocked_paths is an array of strings. Pre-fix only preserved a
|
|
357
|
+
// non-empty list (an explicit `blocked_paths: []` fell through to
|
|
358
|
+
// profile defaults). Match that contract: skip the assignment when
|
|
359
|
+
// the parsed value is empty / wrong shape.
|
|
360
|
+
const blockedPaths = policy['blocked_paths'];
|
|
361
|
+
if (Array.isArray(blockedPaths)) {
|
|
362
|
+
const collected = blockedPaths.filter((v) => typeof v === 'string');
|
|
363
|
+
if (collected.length > 0)
|
|
364
|
+
out.blockedPaths = collected;
|
|
365
|
+
}
|
|
366
|
+
const notificationChannel = policy['notification_channel'];
|
|
367
|
+
if (typeof notificationChannel === 'string')
|
|
368
|
+
out.notificationChannel = notificationChannel;
|
|
369
|
+
// Nested review.* knobs. Inline form `review: { codex_required: true }`
|
|
370
|
+
// and block form both fold to the same object at the parser layer.
|
|
371
|
+
const review = policy['review'];
|
|
372
|
+
if (review !== null && typeof review === 'object') {
|
|
373
|
+
const r = review;
|
|
374
|
+
if (typeof r['codex_required'] === 'boolean')
|
|
375
|
+
out.codexRequired = r['codex_required'];
|
|
376
|
+
// local_review.* — round-28 F6 + round-30 F3 fields.
|
|
377
|
+
const localReview = r['local_review'];
|
|
378
|
+
if (localReview !== null && typeof localReview === 'object') {
|
|
379
|
+
const lr = localReview;
|
|
380
|
+
const mode = lr['mode'];
|
|
381
|
+
if (mode === 'enforced' || mode === 'off')
|
|
382
|
+
out.localReviewMode = mode;
|
|
383
|
+
const refuseAt = lr['refuse_at'];
|
|
384
|
+
if (refuseAt === 'push' || refuseAt === 'commit' || refuseAt === 'both') {
|
|
385
|
+
out.localReviewRefuseAt = refuseAt;
|
|
386
|
+
}
|
|
387
|
+
const bypassEnvVar = lr['bypass_env_var'];
|
|
388
|
+
if (typeof bypassEnvVar === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(bypassEnvVar)) {
|
|
389
|
+
out.localReviewBypassEnvVar = bypassEnvVar;
|
|
390
|
+
}
|
|
391
|
+
const maxAge = lr['max_age_seconds'];
|
|
392
|
+
if (typeof maxAge === 'number' && Number.isFinite(maxAge) && maxAge > 0) {
|
|
393
|
+
out.localReviewMaxAgeSeconds = maxAge;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// commit_hygiene.* — top-level (NOT nested under review). Inline form
|
|
398
|
+
// `commit_hygiene: { warn_at_commits: 3 }` and block form both work.
|
|
399
|
+
const commitHygiene = policy['commit_hygiene'];
|
|
400
|
+
if (commitHygiene !== null && typeof commitHygiene === 'object') {
|
|
401
|
+
const ch = commitHygiene;
|
|
402
|
+
const warnAt = ch['warn_at_commits'];
|
|
403
|
+
if (typeof warnAt === 'number' && Number.isFinite(warnAt) && warnAt >= 0) {
|
|
404
|
+
out.commitHygieneWarnAtCommits = warnAt;
|
|
405
|
+
}
|
|
406
|
+
const refuseAt = ch['refuse_at_commits'];
|
|
407
|
+
if (typeof refuseAt === 'number' && Number.isFinite(refuseAt) && refuseAt >= 0) {
|
|
408
|
+
out.commitHygieneRefuseAtCommits = refuseAt;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
318
412
|
}
|
|
319
413
|
function readExistingInstalledAt(policyPath) {
|
|
320
414
|
try {
|
|
@@ -413,6 +507,42 @@ function writePolicyYaml(targetDir, config, layered) {
|
|
|
413
507
|
// single line, no need to understand the default semantics).
|
|
414
508
|
lines.push(`review:`);
|
|
415
509
|
lines.push(` codex_required: ${config.codexRequired ? 'true' : 'false'}`);
|
|
510
|
+
// Round-27 F6: emit `review.local_review` and top-level
|
|
511
|
+
// `commit_hygiene` blocks ONLY when the operator (or the prior on-disk
|
|
512
|
+
// policy) set them. Pre-fix re-running `rea init` silently dropped any
|
|
513
|
+
// 0.26.0 knobs the operator had configured — `mode: off` reverted to
|
|
514
|
+
// the documented `enforced` default, etc. We deliberately do NOT emit
|
|
515
|
+
// a block when nothing was set, so consumers reading `policy.yaml` see
|
|
516
|
+
// a clean file that documents only the operator's explicit choices.
|
|
517
|
+
const hasLocalReview = config.localReviewMode !== undefined ||
|
|
518
|
+
config.localReviewRefuseAt !== undefined ||
|
|
519
|
+
config.localReviewBypassEnvVar !== undefined ||
|
|
520
|
+
config.localReviewMaxAgeSeconds !== undefined;
|
|
521
|
+
if (hasLocalReview) {
|
|
522
|
+
lines.push(` local_review:`);
|
|
523
|
+
if (config.localReviewMode !== undefined) {
|
|
524
|
+
lines.push(` mode: ${config.localReviewMode}`);
|
|
525
|
+
}
|
|
526
|
+
if (config.localReviewRefuseAt !== undefined) {
|
|
527
|
+
lines.push(` refuse_at: ${config.localReviewRefuseAt}`);
|
|
528
|
+
}
|
|
529
|
+
if (config.localReviewBypassEnvVar !== undefined) {
|
|
530
|
+
lines.push(` bypass_env_var: ${JSON.stringify(config.localReviewBypassEnvVar)}`);
|
|
531
|
+
}
|
|
532
|
+
if (config.localReviewMaxAgeSeconds !== undefined) {
|
|
533
|
+
lines.push(` max_age_seconds: ${config.localReviewMaxAgeSeconds}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (config.commitHygieneWarnAtCommits !== undefined ||
|
|
537
|
+
config.commitHygieneRefuseAtCommits !== undefined) {
|
|
538
|
+
lines.push(`commit_hygiene:`);
|
|
539
|
+
if (config.commitHygieneWarnAtCommits !== undefined) {
|
|
540
|
+
lines.push(` warn_at_commits: ${config.commitHygieneWarnAtCommits}`);
|
|
541
|
+
}
|
|
542
|
+
if (config.commitHygieneRefuseAtCommits !== undefined) {
|
|
543
|
+
lines.push(` refuse_at_commits: ${config.commitHygieneRefuseAtCommits}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
416
546
|
lines.push(``);
|
|
417
547
|
fs.writeFileSync(policyPath, lines.join('\n'), 'utf8');
|
|
418
548
|
return policyPath;
|
|
@@ -590,6 +720,27 @@ export async function runInit(options) {
|
|
|
590
720
|
blockedPaths: existingPolicy?.blockedPaths ?? layeredBase.blocked_paths ?? ['.env', '.env.*'],
|
|
591
721
|
notificationChannel: existingPolicy?.notificationChannel ?? layeredBase.notification_channel ?? '',
|
|
592
722
|
codexRequired,
|
|
723
|
+
// Round-27 F6: forward the existing 0.26.0 knobs verbatim. Any field
|
|
724
|
+
// not set on disk stays undefined, and the writer omits it from the
|
|
725
|
+
// emitted YAML.
|
|
726
|
+
...(existingPolicy?.localReviewMode !== undefined
|
|
727
|
+
? { localReviewMode: existingPolicy.localReviewMode }
|
|
728
|
+
: {}),
|
|
729
|
+
...(existingPolicy?.localReviewRefuseAt !== undefined
|
|
730
|
+
? { localReviewRefuseAt: existingPolicy.localReviewRefuseAt }
|
|
731
|
+
: {}),
|
|
732
|
+
...(existingPolicy?.localReviewBypassEnvVar !== undefined
|
|
733
|
+
? { localReviewBypassEnvVar: existingPolicy.localReviewBypassEnvVar }
|
|
734
|
+
: {}),
|
|
735
|
+
...(existingPolicy?.localReviewMaxAgeSeconds !== undefined
|
|
736
|
+
? { localReviewMaxAgeSeconds: existingPolicy.localReviewMaxAgeSeconds }
|
|
737
|
+
: {}),
|
|
738
|
+
...(existingPolicy?.commitHygieneWarnAtCommits !== undefined
|
|
739
|
+
? { commitHygieneWarnAtCommits: existingPolicy.commitHygieneWarnAtCommits }
|
|
740
|
+
: {}),
|
|
741
|
+
...(existingPolicy?.commitHygieneRefuseAtCommits !== undefined
|
|
742
|
+
? { commitHygieneRefuseAtCommits: existingPolicy.commitHygieneRefuseAtCommits }
|
|
743
|
+
: {}),
|
|
593
744
|
fromReagent,
|
|
594
745
|
reagentPolicyPath,
|
|
595
746
|
reagentNotices,
|
|
@@ -53,6 +53,12 @@
|
|
|
53
53
|
* classification. Bump the version suffix whenever the body semantics
|
|
54
54
|
* change so upgrades can migrate old installs cleanly.
|
|
55
55
|
*
|
|
56
|
+
* v5 — 0.26.0 local-first enforcement: body runs `rea preflight --strict`
|
|
57
|
+
* BEFORE the push-gate dispatch. `rea preflight` refuses the push
|
|
58
|
+
* when no recent `rea.local_review` audit entry covers HEAD; the
|
|
59
|
+
* legacy push-gate then runs as the second layer (codex on push).
|
|
60
|
+
* Honors `policy.review.local_review.mode: off` and
|
|
61
|
+
* `REA_SKIP_LOCAL_REVIEW=<reason>` for opt-out / per-push override.
|
|
56
62
|
* v4 — 0.13.0 extension-hook chaining: rea body sources `.husky/pre-push.d/*`
|
|
57
63
|
* fragments after its own work and before the final `exec`, in lex
|
|
58
64
|
* order. Non-zero fragment exit fails the hook.
|
|
@@ -62,7 +68,9 @@
|
|
|
62
68
|
* v2 — 0.11.0 stateless push-gate body (no bash core, no audit grep).
|
|
63
69
|
* v1 — 0.10.x and prior, delegated to `.claude/hooks/push-review-gate.sh`.
|
|
64
70
|
*/
|
|
65
|
-
export declare const FALLBACK_MARKER = "# rea:pre-push-fallback
|
|
71
|
+
export declare const FALLBACK_MARKER = "# rea:pre-push-fallback v5";
|
|
72
|
+
/** Legacy v4 marker (0.13.x – 0.25.x bodies). Refresh-on-upgrade. */
|
|
73
|
+
export declare const LEGACY_FALLBACK_MARKER_V4 = "# rea:pre-push-fallback v4";
|
|
66
74
|
/** Legacy v3 marker (0.12.x bodies). Refresh-on-upgrade. */
|
|
67
75
|
export declare const LEGACY_FALLBACK_MARKER_V3 = "# rea:pre-push-fallback v3";
|
|
68
76
|
/** Legacy v2 marker (0.11.x bodies). Refresh-on-upgrade. */
|
|
@@ -75,7 +83,9 @@ export declare const LEGACY_FALLBACK_MARKER_V1 = "# rea:pre-push-fallback v1";
|
|
|
75
83
|
* detects it to refresh in-place. Bump the suffix whenever the body
|
|
76
84
|
* changes; pre-0.13 markers live in `LEGACY_HUSKY_GATE_MARKER_V{1,2,3}`.
|
|
77
85
|
*/
|
|
78
|
-
export declare const HUSKY_GATE_MARKER = "# rea:husky-pre-push-gate
|
|
86
|
+
export declare const HUSKY_GATE_MARKER = "# rea:husky-pre-push-gate v5";
|
|
87
|
+
/** Legacy v4 husky marker (0.13.x – 0.25.x bodies). Refresh-on-upgrade. */
|
|
88
|
+
export declare const LEGACY_HUSKY_GATE_MARKER_V4 = "# rea:husky-pre-push-gate v4";
|
|
79
89
|
/** Legacy v3 husky marker (0.12.x bodies). Refresh-on-upgrade. */
|
|
80
90
|
export declare const LEGACY_HUSKY_GATE_MARKER_V3 = "# rea:husky-pre-push-gate v3";
|
|
81
91
|
/** Legacy v2 husky marker (0.11.x bodies). Refresh-on-upgrade. */
|
|
@@ -87,7 +97,9 @@ export declare const LEGACY_HUSKY_GATE_MARKER_V1 = "# rea:husky-pre-push-gate v1
|
|
|
87
97
|
* empty body (stubbed out by a consumer) is NOT classified as rea-managed.
|
|
88
98
|
* A real rea hook always carries both markers.
|
|
89
99
|
*/
|
|
90
|
-
export declare const HUSKY_GATE_BODY_MARKER = "# rea:gate-body-
|
|
100
|
+
export declare const HUSKY_GATE_BODY_MARKER = "# rea:gate-body-v5";
|
|
101
|
+
/** Legacy v4 body marker (0.13.x – 0.25.x bodies). Refresh-on-upgrade. */
|
|
102
|
+
export declare const LEGACY_HUSKY_GATE_BODY_MARKER_V4 = "# rea:gate-body-v4";
|
|
91
103
|
/** Legacy v3 body marker (0.12.x bodies). Refresh-on-upgrade. */
|
|
92
104
|
export declare const LEGACY_HUSKY_GATE_BODY_MARKER_V3 = "# rea:gate-body-v3";
|
|
93
105
|
/** Legacy v2 body marker (0.11.x bodies). Refresh-on-upgrade. */
|
|
@@ -65,6 +65,12 @@ const execFileAsync = promisify(execFile);
|
|
|
65
65
|
* classification. Bump the version suffix whenever the body semantics
|
|
66
66
|
* change so upgrades can migrate old installs cleanly.
|
|
67
67
|
*
|
|
68
|
+
* v5 — 0.26.0 local-first enforcement: body runs `rea preflight --strict`
|
|
69
|
+
* BEFORE the push-gate dispatch. `rea preflight` refuses the push
|
|
70
|
+
* when no recent `rea.local_review` audit entry covers HEAD; the
|
|
71
|
+
* legacy push-gate then runs as the second layer (codex on push).
|
|
72
|
+
* Honors `policy.review.local_review.mode: off` and
|
|
73
|
+
* `REA_SKIP_LOCAL_REVIEW=<reason>` for opt-out / per-push override.
|
|
68
74
|
* v4 — 0.13.0 extension-hook chaining: rea body sources `.husky/pre-push.d/*`
|
|
69
75
|
* fragments after its own work and before the final `exec`, in lex
|
|
70
76
|
* order. Non-zero fragment exit fails the hook.
|
|
@@ -74,9 +80,14 @@ const execFileAsync = promisify(execFile);
|
|
|
74
80
|
* v2 — 0.11.0 stateless push-gate body (no bash core, no audit grep).
|
|
75
81
|
* v1 — 0.10.x and prior, delegated to `.claude/hooks/push-review-gate.sh`.
|
|
76
82
|
*/
|
|
77
|
-
export const FALLBACK_MARKER = '# rea:pre-push-fallback
|
|
83
|
+
export const FALLBACK_MARKER = '# rea:pre-push-fallback v5';
|
|
84
|
+
/** Legacy v4 marker (0.13.x – 0.25.x bodies). Refresh-on-upgrade. */
|
|
85
|
+
export const LEGACY_FALLBACK_MARKER_V4 = '# rea:pre-push-fallback v4';
|
|
78
86
|
/** Legacy v3 marker (0.12.x bodies). Refresh-on-upgrade. */
|
|
79
87
|
export const LEGACY_FALLBACK_MARKER_V3 = '# rea:pre-push-fallback v3';
|
|
88
|
+
// Legacy v4 marker is declared above next to the v5 (current) marker so
|
|
89
|
+
// the canonical/current pair sits together. Keep this comment as an
|
|
90
|
+
// anchor — `LEGACY_FALLBACK_MARKER_V4` is exported above.
|
|
80
91
|
/** Legacy v2 marker (0.11.x bodies). Refresh-on-upgrade. */
|
|
81
92
|
export const LEGACY_FALLBACK_MARKER_V2 = '# rea:pre-push-fallback v2';
|
|
82
93
|
/** Legacy v1 marker — used by upgrade migration to detect old installs. */
|
|
@@ -87,7 +98,9 @@ export const LEGACY_FALLBACK_MARKER_V1 = '# rea:pre-push-fallback v1';
|
|
|
87
98
|
* detects it to refresh in-place. Bump the suffix whenever the body
|
|
88
99
|
* changes; pre-0.13 markers live in `LEGACY_HUSKY_GATE_MARKER_V{1,2,3}`.
|
|
89
100
|
*/
|
|
90
|
-
export const HUSKY_GATE_MARKER = '# rea:husky-pre-push-gate
|
|
101
|
+
export const HUSKY_GATE_MARKER = '# rea:husky-pre-push-gate v5';
|
|
102
|
+
/** Legacy v4 husky marker (0.13.x – 0.25.x bodies). Refresh-on-upgrade. */
|
|
103
|
+
export const LEGACY_HUSKY_GATE_MARKER_V4 = '# rea:husky-pre-push-gate v4';
|
|
91
104
|
/** Legacy v3 husky marker (0.12.x bodies). Refresh-on-upgrade. */
|
|
92
105
|
export const LEGACY_HUSKY_GATE_MARKER_V3 = '# rea:husky-pre-push-gate v3';
|
|
93
106
|
/** Legacy v2 husky marker (0.11.x bodies). Refresh-on-upgrade. */
|
|
@@ -99,7 +112,9 @@ export const LEGACY_HUSKY_GATE_MARKER_V1 = '# rea:husky-pre-push-gate v1';
|
|
|
99
112
|
* empty body (stubbed out by a consumer) is NOT classified as rea-managed.
|
|
100
113
|
* A real rea hook always carries both markers.
|
|
101
114
|
*/
|
|
102
|
-
export const HUSKY_GATE_BODY_MARKER = '# rea:gate-body-
|
|
115
|
+
export const HUSKY_GATE_BODY_MARKER = '# rea:gate-body-v5';
|
|
116
|
+
/** Legacy v4 body marker (0.13.x – 0.25.x bodies). Refresh-on-upgrade. */
|
|
117
|
+
export const LEGACY_HUSKY_GATE_BODY_MARKER_V4 = '# rea:gate-body-v4';
|
|
103
118
|
/** Legacy v3 body marker (0.12.x bodies). Refresh-on-upgrade. */
|
|
104
119
|
export const LEGACY_HUSKY_GATE_BODY_MARKER_V3 = '# rea:gate-body-v3';
|
|
105
120
|
/** Legacy v2 body marker (0.11.x bodies). Refresh-on-upgrade. */
|
|
@@ -161,6 +176,37 @@ fi
|
|
|
161
176
|
# the subshell sees those as its initial \$@, appends them inside each
|
|
162
177
|
# \`set --\` arm, and the parent's \$@ is preserved.
|
|
163
178
|
|
|
179
|
+
# 0.26.0 local-first enforcement (CTO directive 2026-05-05). Run
|
|
180
|
+
# \`rea preflight --strict\` BEFORE the push-gate dispatch. Preflight
|
|
181
|
+
# refuses (exit 2) when no recent \`rea.local_review\` audit entry
|
|
182
|
+
# covers HEAD, when commit-hygiene thresholds are exceeded, or when
|
|
183
|
+
# the kill-switch is active. The legacy push-gate then runs as the
|
|
184
|
+
# SECOND layer (codex on the diff). Honors:
|
|
185
|
+
# - policy.review.local_review.mode: off → preflight is no-op
|
|
186
|
+
# - REA_SKIP_LOCAL_REVIEW="<reason>" → bypass + audit
|
|
187
|
+
# We resolve the rea binary the same way the dispatch below does.
|
|
188
|
+
if (
|
|
189
|
+
if [ -x "\${REA_ROOT}/node_modules/.bin/rea" ]; then
|
|
190
|
+
"\${REA_ROOT}/node_modules/.bin/rea" preflight --strict
|
|
191
|
+
elif [ -f "\${REA_ROOT}/dist/cli/index.js" ] && [ -f "\${REA_ROOT}/package.json" ] && grep -q '"name": *"@bookedsolid/rea"' "\${REA_ROOT}/package.json" 2>/dev/null; then
|
|
192
|
+
node "\${REA_ROOT}/dist/cli/index.js" preflight --strict
|
|
193
|
+
elif command -v rea >/dev/null 2>&1; then
|
|
194
|
+
rea preflight --strict
|
|
195
|
+
elif command -v npx >/dev/null 2>&1; then
|
|
196
|
+
npx --no-install @bookedsolid/rea preflight --strict
|
|
197
|
+
else
|
|
198
|
+
printf 'rea: cannot locate the rea CLI for preflight. Install locally (\`pnpm add -D @bookedsolid/rea\`) or set policy.review.local_review.mode=off.\\n' >&2
|
|
199
|
+
exit 2
|
|
200
|
+
fi
|
|
201
|
+
); then
|
|
202
|
+
preflight_status=0
|
|
203
|
+
else
|
|
204
|
+
preflight_status=\$?
|
|
205
|
+
fi
|
|
206
|
+
if [ "\$preflight_status" -ne 0 ]; then
|
|
207
|
+
exit "\$preflight_status"
|
|
208
|
+
fi
|
|
209
|
+
|
|
164
210
|
if (
|
|
165
211
|
if [ -x "\${REA_ROOT}/node_modules/.bin/rea" ]; then
|
|
166
212
|
set -- "\${REA_ROOT}/node_modules/.bin/rea" hook push-gate "\$@"
|
|
@@ -290,7 +336,8 @@ export function isLegacyReaManagedFallback(content) {
|
|
|
290
336
|
if (secondLineEnd < 0)
|
|
291
337
|
return false;
|
|
292
338
|
const secondLine = content.slice(10, secondLineEnd);
|
|
293
|
-
return (secondLine ===
|
|
339
|
+
return (secondLine === LEGACY_FALLBACK_MARKER_V4 ||
|
|
340
|
+
secondLine === LEGACY_FALLBACK_MARKER_V3 ||
|
|
294
341
|
secondLine === LEGACY_FALLBACK_MARKER_V2 ||
|
|
295
342
|
secondLine === LEGACY_FALLBACK_MARKER_V1);
|
|
296
343
|
}
|
|
@@ -315,7 +362,8 @@ export function isReaManagedHuskyGate(content) {
|
|
|
315
362
|
* upgrade migration.
|
|
316
363
|
*/
|
|
317
364
|
export function isLegacyReaManagedHuskyGate(content) {
|
|
318
|
-
return (hasHeaderMarkers(content,
|
|
365
|
+
return (hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V4, LEGACY_HUSKY_GATE_BODY_MARKER_V4) ||
|
|
366
|
+
hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V3, LEGACY_HUSKY_GATE_BODY_MARKER_V3) ||
|
|
319
367
|
hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V2, LEGACY_HUSKY_GATE_BODY_MARKER_V2) ||
|
|
320
368
|
hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V1, LEGACY_HUSKY_GATE_BODY_MARKER_V1));
|
|
321
369
|
}
|
|
@@ -677,6 +725,8 @@ async function cleanupStaleTempFiles(dst) {
|
|
|
677
725
|
return;
|
|
678
726
|
if (!body.includes(FALLBACK_MARKER) &&
|
|
679
727
|
!body.includes(HUSKY_GATE_MARKER) &&
|
|
728
|
+
!body.includes(LEGACY_FALLBACK_MARKER_V4) &&
|
|
729
|
+
!body.includes(LEGACY_HUSKY_GATE_MARKER_V4) &&
|
|
680
730
|
!body.includes(LEGACY_FALLBACK_MARKER_V3) &&
|
|
681
731
|
!body.includes(LEGACY_HUSKY_GATE_MARKER_V3) &&
|
|
682
732
|
!body.includes(LEGACY_FALLBACK_MARKER_V2) &&
|
|
@@ -303,6 +303,19 @@ export function defaultDesiredHooks() {
|
|
|
303
303
|
timeout: 5000,
|
|
304
304
|
statusMessage: 'Checking for AI attribution...',
|
|
305
305
|
},
|
|
306
|
+
// 0.26.0 local-first enforcement (CTO directive 2026-05-05). The
|
|
307
|
+
// Bash-tier gate refuses `git push` (and optionally `git commit`)
|
|
308
|
+
// when no recent `rea.local_review` audit entry covers HEAD. Honors
|
|
309
|
+
// `policy.review.local_review.mode: off` for teams without
|
|
310
|
+
// codex/claude installed and `REA_SKIP_LOCAL_REVIEW=<reason>` for
|
|
311
|
+
// per-invocation overrides. 60s timeout because the gate may
|
|
312
|
+
// shell out to `rea preflight` which itself loads policy.
|
|
313
|
+
{
|
|
314
|
+
type: 'command',
|
|
315
|
+
command: `${base}/local-review-gate.sh`,
|
|
316
|
+
timeout: 60000,
|
|
317
|
+
statusMessage: 'Checking local-first review status...',
|
|
318
|
+
},
|
|
306
319
|
],
|
|
307
320
|
},
|
|
308
321
|
{
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rea preflight` — local-first enforcement workhorse (0.26.0+).
|
|
3
|
+
*
|
|
4
|
+
* Called by:
|
|
5
|
+
* - The husky pre-push template (`exec rea preflight --strict`)
|
|
6
|
+
* - The Bash-tier `local-review-gate.sh` PreToolUse hook
|
|
7
|
+
* - Operators directly (`rea preflight` to check status)
|
|
8
|
+
*
|
|
9
|
+
* Decision flow:
|
|
10
|
+
*
|
|
11
|
+
* 1. `policy.review.local_review.mode === 'off'` → exit 0 (no-op)
|
|
12
|
+
* 2. `<bypass_env_var>` is set (default REA_SKIP_LOCAL_REVIEW) → audit
|
|
13
|
+
* `rea.local_review.skipped_override` with the reason; exit 0
|
|
14
|
+
* 3. `--no-review-check` flag → audit `rea.preflight.review_skipped`;
|
|
15
|
+
* proceed to commit-count check only
|
|
16
|
+
* 4. Tail `.rea/audit.jsonl` for a `rea.local_review` (or back-compat
|
|
17
|
+
* `codex.review`) entry with `metadata.head_sha === <git HEAD>`
|
|
18
|
+
* AND `now - timestamp < max_age_seconds`. Found → exit 0.
|
|
19
|
+
* Missing → exit 2 with helpful message.
|
|
20
|
+
* 5. Commit-count check (independent of step 4):
|
|
21
|
+
* `git rev-list --count <base>..HEAD` against thresholds
|
|
22
|
+
* from `policy.commit_hygiene`.
|
|
23
|
+
*
|
|
24
|
+
* Exit codes:
|
|
25
|
+
*
|
|
26
|
+
* 0 — clean (mode=off, recent review found, or override set)
|
|
27
|
+
* 1 — warn (commit count > warn_at_commits but ≤ refuse_at_commits)
|
|
28
|
+
* 2 — refuse (no recent review covering HEAD, OR commit count >
|
|
29
|
+
* refuse_at_commits, OR --strict elevated a warn to refuse)
|
|
30
|
+
*/
|
|
31
|
+
import type { Command } from 'commander';
|
|
32
|
+
import { type Policy } from '../policy/types.js';
|
|
33
|
+
/** Default max age for a local-review audit entry (24h). */
|
|
34
|
+
export declare const DEFAULT_MAX_AGE_SECONDS = 86400;
|
|
35
|
+
/** Default bypass env-var name. */
|
|
36
|
+
export declare const DEFAULT_BYPASS_ENV_VAR = "REA_SKIP_LOCAL_REVIEW";
|
|
37
|
+
/** Default commit-hygiene thresholds. */
|
|
38
|
+
export declare const DEFAULT_WARN_AT_COMMITS = 1;
|
|
39
|
+
export declare const DEFAULT_REFUSE_AT_COMMITS = 5;
|
|
40
|
+
export interface RunPreflightOptions {
|
|
41
|
+
/**
|
|
42
|
+
* Treat warn-tier commit-hygiene findings as refusals. Husky pre-push
|
|
43
|
+
* always sets this — a warn that doesn't refuse is a useless warning
|
|
44
|
+
* at the terminal layer.
|
|
45
|
+
*/
|
|
46
|
+
strict?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Skip the audit-log check. The commit-count check still runs. Used
|
|
49
|
+
* by operators who explicitly want to defer review (audit-logged so
|
|
50
|
+
* the deferral is forensically visible).
|
|
51
|
+
*/
|
|
52
|
+
noReviewCheck?: boolean;
|
|
53
|
+
/** Emit a single JSON line on stdout instead of pretty output. */
|
|
54
|
+
json?: boolean;
|
|
55
|
+
}
|
|
56
|
+
interface PreflightOutcome {
|
|
57
|
+
status: 'clean' | 'warn' | 'refuse';
|
|
58
|
+
reason: string;
|
|
59
|
+
exitCode: 0 | 1 | 2;
|
|
60
|
+
details: Record<string, unknown>;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Run preflight in-process. Tests drive this directly. The CLI binding
|
|
64
|
+
* exits via `process.exit` at the end of `runPreflight()`.
|
|
65
|
+
*/
|
|
66
|
+
export declare function computePreflight(baseDir: string, options: RunPreflightOptions, env?: NodeJS.ProcessEnv): Promise<{
|
|
67
|
+
outcome: PreflightOutcome;
|
|
68
|
+
policy: Policy | undefined;
|
|
69
|
+
}>;
|
|
70
|
+
export declare function runPreflight(options: RunPreflightOptions): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Tail `.rea/audit.jsonl` for the most recent matching local-review
|
|
73
|
+
* entry. We accept BOTH `rea.local_review` (canonical) and
|
|
74
|
+
* `codex.review` (back-compat from pre-0.26.0 audit data) so existing
|
|
75
|
+
* users with prior reviews don't have to re-review on upgrade.
|
|
76
|
+
*
|
|
77
|
+
* Streaming approach: read the whole file (audit logs are typically
|
|
78
|
+
* < 10 MB even after months of use) and walk lines from the end. The
|
|
79
|
+
* audit log is append-only and timestamps are monotonic per writer.
|
|
80
|
+
*
|
|
81
|
+
* # Coverage matching (0.26.0 helix-026 finding-1)
|
|
82
|
+
*
|
|
83
|
+
* The first valid `metadata.content_token` on each record wins:
|
|
84
|
+
*
|
|
85
|
+
* 1. Record has `content_token` AND caller supplied `contentToken` →
|
|
86
|
+
* exact-string match. Stable across `--amend` / fixup rebases.
|
|
87
|
+
* 2. Record has NO `content_token` (legacy `codex.review` entry, or
|
|
88
|
+
* a future provider that can't compute one) → fall back to
|
|
89
|
+
* exact-string `head_sha` match. Pre-0.26.0 reviews still cover.
|
|
90
|
+
* 3. Record has `content_token` but caller's `contentToken` is empty
|
|
91
|
+
* (preflight on a non-git directory or detached state) → fall back
|
|
92
|
+
* to `head_sha` match. The content path is the additive layer; the
|
|
93
|
+
* head-sha layer remains as the floor.
|
|
94
|
+
*
|
|
95
|
+
* Hierarchy invariant: an entry is valid coverage when EITHER the token
|
|
96
|
+
* matches OR the head_sha matches. The two are not AND-ed — that would
|
|
97
|
+
* make legacy entries un-matchable and would break the local-first loop
|
|
98
|
+
* back to the old "commit first, then review" inversion.
|
|
99
|
+
*/
|
|
100
|
+
export interface LocalReviewLookupResult {
|
|
101
|
+
found: boolean;
|
|
102
|
+
/** Audit-record metadata payload, when found. */
|
|
103
|
+
metadata?: Record<string, unknown>;
|
|
104
|
+
/** ISO timestamp on the matching record. */
|
|
105
|
+
timestamp?: string;
|
|
106
|
+
/** Tool name that matched (canonical or legacy). */
|
|
107
|
+
tool_name?: string;
|
|
108
|
+
/**
|
|
109
|
+
* Which match-path validated this entry. Useful for tests and for the
|
|
110
|
+
* `--json` outcome: `'content_token'` (preferred), `'head_sha'`
|
|
111
|
+
* (back-compat / fallback).
|
|
112
|
+
*/
|
|
113
|
+
match_kind?: 'content_token' | 'head_sha';
|
|
114
|
+
}
|
|
115
|
+
export declare function findRecentLocalReview(baseDir: string, headSha: string, maxAgeSeconds: number, now?: Date, contentToken?: string): LocalReviewLookupResult;
|
|
116
|
+
/**
|
|
117
|
+
* Attach `rea preflight` to a commander Program.
|
|
118
|
+
*/
|
|
119
|
+
export declare function registerPreflightCommand(program: Command): void;
|
|
120
|
+
export {};
|