@bookedsolid/rea 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/hook.js CHANGED
@@ -30,6 +30,7 @@
30
30
  */
31
31
  import fs from 'node:fs';
32
32
  import path from 'node:path';
33
+ import { parse as parseYaml } from 'yaml';
33
34
  import { parsePrePushStdin, runPushGate } from '../hooks/push-gate/index.js';
34
35
  import { runBlockedScan, runProtectedScan } from '../hooks/bash-scanner/index.js';
35
36
  import { loadPolicy } from '../policy/loader.js';
@@ -250,6 +251,74 @@ export async function runHookScanBash(options) {
250
251
  }
251
252
  process.exit(0);
252
253
  }
254
+ export async function runHookPolicyGet(options) {
255
+ // 0.27.0+: validate the key shape so a malformed dot-path can't be
256
+ // exploited by a misbehaving caller. Allow only POSIX identifier
257
+ // segments separated by single dots; reject empty segments, slashes,
258
+ // shell metacharacters, etc.
259
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(options.key)) {
260
+ process.stderr.write(`rea hook policy-get: invalid key ${JSON.stringify(options.key)}\n`);
261
+ process.exit(1);
262
+ }
263
+ const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
264
+ const policyPath = path.join(reaRoot, '.rea', 'policy.yaml');
265
+ const finishMissing = () => {
266
+ if (options.json === true)
267
+ process.stdout.write('null');
268
+ process.exit(0);
269
+ };
270
+ if (!fs.existsSync(policyPath)) {
271
+ finishMissing();
272
+ }
273
+ let parsed;
274
+ try {
275
+ const raw = fs.readFileSync(policyPath, 'utf8');
276
+ parsed = parseYaml(raw);
277
+ }
278
+ catch {
279
+ // Unparseable YAML — emit empty / null and exit 1 so the bash caller
280
+ // can distinguish "no value" from "actual parse failure" if it
281
+ // wants to (the local-review-gate caller swallows exit codes).
282
+ if (options.json === true)
283
+ process.stdout.write('null');
284
+ process.exit(1);
285
+ }
286
+ if (parsed === null || typeof parsed !== 'object') {
287
+ finishMissing();
288
+ }
289
+ // Walk the dotted path. Bail (empty stdout / null) at any non-object
290
+ // intermediate.
291
+ const segments = options.key.split('.');
292
+ let cursor = parsed;
293
+ for (const seg of segments) {
294
+ if (cursor === null || typeof cursor !== 'object' || Array.isArray(cursor)) {
295
+ finishMissing();
296
+ }
297
+ cursor = cursor[seg];
298
+ if (cursor === undefined) {
299
+ finishMissing();
300
+ }
301
+ }
302
+ if (options.json === true) {
303
+ // Emit JSON for scalar/object/array/null. Objects + arrays serialize
304
+ // recursively. Bash callers parse via jq.
305
+ process.stdout.write(JSON.stringify(cursor ?? null));
306
+ process.exit(0);
307
+ }
308
+ // Scalar mode: only print scalar leaves. Objects/arrays print empty
309
+ // (legacy behavior from initial F2 implementation).
310
+ if (cursor === null) {
311
+ process.exit(0);
312
+ }
313
+ if (typeof cursor === 'string') {
314
+ process.stdout.write(cursor);
315
+ }
316
+ else if (typeof cursor === 'number' || typeof cursor === 'boolean') {
317
+ process.stdout.write(String(cursor));
318
+ }
319
+ // Object/Array → no output (caller treats as unset).
320
+ process.exit(0);
321
+ }
253
322
  /**
254
323
  * Attach the `rea hook` subcommand tree to a commander Program. Two
255
324
  * subcommands today: `push-gate` and `scan-bash`. New hooks should land
@@ -297,4 +366,12 @@ export function registerHookCommand(program) {
297
366
  ...(opts.lastNCommits !== undefined ? { lastNCommits: opts.lastNCommits } : {}),
298
367
  });
299
368
  });
369
+ hook
370
+ .command('policy-get')
371
+ .description('Read a value from `.rea/policy.yaml` via the canonical YAML parser. Used by bash-tier hooks (`hooks/_lib/policy-read.sh::policy_nested_scalar`) so inline AND block YAML forms agree at a single source of truth. Default scalar mode: prints raw value or empty. With `--json`: emits JSON (scalar or object/array; missing path → `null`). Unparseable YAML → empty / null, exit 1.')
372
+ .argument('<key>', 'dotted path, e.g. `review.local_review.mode`. POSIX-identifier segments only.')
373
+ .option('--json', 'emit JSON instead of a scalar — supports object/array leaves. Bash callers can then parse with jq.')
374
+ .action(async (key, opts) => {
375
+ await runHookPolicyGet({ key, ...(opts.json === true ? { json: true } : {}) });
376
+ });
300
377
  }
package/dist/cli/index.js CHANGED
@@ -6,6 +6,8 @@ import { registerHookCommand } from './hook.js';
6
6
  import { runDoctor } from './doctor.js';
7
7
  import { runFreeze, runUnfreeze } from './freeze.js';
8
8
  import { runInit } from './init.js';
9
+ import { registerPreflightCommand } from './preflight.js';
10
+ import { registerReviewCommand } from './review.js';
9
11
  import { runServe } from './serve.js';
10
12
  import { runStatus } from './status.js';
11
13
  import { runTofuAccept, runTofuList } from './tofu.js';
@@ -106,6 +108,13 @@ async function main() {
106
108
  // Register `rea hook push-gate` — the stateless pre-push Codex gate
107
109
  // called by `.husky/pre-push` and `.git/hooks/pre-push`.
108
110
  registerHookCommand(program);
111
+ // 0.26.0 local-first enforcement (CTO directive 2026-05-05). Two new
112
+ // top-level CLIs: `rea review` writes `rea.local_review` audit entries;
113
+ // `rea preflight` reads them and refuses pushes/commits without a
114
+ // recent matching entry. The husky pre-push template + Bash-tier
115
+ // `local-review-gate.sh` hook both delegate to `rea preflight --strict`.
116
+ registerReviewCommand(program);
117
+ registerPreflightCommand(program);
109
118
  const tofu = program
110
119
  .command('tofu')
111
120
  .description('TOFU fingerprint operations (G7) — inspect and rebase `.rea/fingerprints.json` when a legitimate registry edit has triggered drift fail-close. Emits audit records.');
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
- const out = {};
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 v4";
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 v4";
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-v4";
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 v4';
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 v4';
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-v4';
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 === LEGACY_FALLBACK_MARKER_V3 ||
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, LEGACY_HUSKY_GATE_MARKER_V3, LEGACY_HUSKY_GATE_BODY_MARKER_V3) ||
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
  {