@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/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
  {
@@ -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 {};