@bookedsolid/rea 0.11.0 → 0.13.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.
@@ -46,6 +46,18 @@ export declare function checkFingerprintStore(baseDir: string): Promise<CheckRes
46
46
  * NOT a trust boundary. Do not key security decisions on the return value.
47
47
  */
48
48
  export declare function isGitRepo(baseDir: string): boolean;
49
+ /**
50
+ * Hard-fail when `policy.review.codex_required: true` but the `codex`
51
+ * binary is not on PATH. Pre-0.12.0 this prereq surfaced only at first
52
+ * push, by which point the consumer had cloned, run `pnpm install`,
53
+ * authored a commit, and tried to push — only then to learn that they
54
+ * needed a separate install. Fix C of 0.12.0 surfaces it during install.
55
+ *
56
+ * Returns `pass` when codex resolves; `fail` when missing. Operators who
57
+ * want to disable the gate can flip `policy.review.codex_required: false`
58
+ * (the doctor then short-circuits past every Codex check).
59
+ */
60
+ export declare function checkCodexBinaryOnPath(): CheckResult;
49
61
  /**
50
62
  * Translate a `CodexProbeState` into two doctor CheckResults: one for
51
63
  * responsiveness (pass/warn) and one informational line about the last
@@ -403,6 +403,67 @@ function checkPrePushHook(state) {
403
403
  'Run `rea init` to install the fallback.',
404
404
  };
405
405
  }
406
+ /**
407
+ * Detect and list extension-hook fragments under `.husky/commit-msg.d/` and
408
+ * `.husky/pre-push.d/`. Informational only — fragments are an opt-in feature
409
+ * (added in 0.13.0); their presence is something operators should know about
410
+ * but never a hard fail. Non-executable files in the directories are
411
+ * surfaced as a warning since they are silently skipped at hook-fire time
412
+ * (executable bit is the consumer's opt-in).
413
+ */
414
+ function checkExtensionFragments(baseDir) {
415
+ const dirs = [
416
+ { name: 'commit-msg.d', path: path.join(baseDir, '.husky', 'commit-msg.d') },
417
+ { name: 'pre-push.d', path: path.join(baseDir, '.husky', 'pre-push.d') },
418
+ ];
419
+ const found = [];
420
+ const inert = [];
421
+ for (const d of dirs) {
422
+ if (!fs.existsSync(d.path))
423
+ continue;
424
+ let entries;
425
+ try {
426
+ entries = fs.readdirSync(d.path, { withFileTypes: true });
427
+ }
428
+ catch {
429
+ continue;
430
+ }
431
+ for (const e of entries) {
432
+ if (!e.isFile())
433
+ continue;
434
+ const abs = path.join(d.path, e.name);
435
+ try {
436
+ const st = fs.statSync(abs);
437
+ if ((st.mode & 0o111) !== 0) {
438
+ found.push(`${d.name}/${e.name}`);
439
+ }
440
+ else {
441
+ inert.push(`${d.name}/${e.name}`);
442
+ }
443
+ }
444
+ catch {
445
+ // unreadable — skip, will be surfaced at hook-fire time
446
+ }
447
+ }
448
+ }
449
+ if (found.length === 0 && inert.length === 0) {
450
+ return {
451
+ label: 'extension hook fragments',
452
+ status: 'info',
453
+ detail: 'none — drop executables into .husky/{commit-msg,pre-push}.d/ to chain custom checks',
454
+ };
455
+ }
456
+ if (inert.length > 0) {
457
+ const detail = `executable: ${found.length === 0 ? 'none' : found.join(', ')}; ` +
458
+ `non-executable (silently skipped): ${inert.join(', ')} — chmod +x to enable`;
459
+ return { label: 'extension hook fragments', status: 'warn', detail };
460
+ }
461
+ return {
462
+ label: 'extension hook fragments',
463
+ status: 'info',
464
+ detail: `${found.length} executable fragment(s): ${found.join(', ')}`,
465
+ };
466
+ }
406
467
  function checkCodexAgent(baseDir) {
407
468
  const agentPath = path.join(baseDir, '.claude', 'agents', 'codex-adversarial.md');
408
469
  if (fs.existsSync(agentPath))
@@ -423,6 +484,95 @@ function checkCodexCommand(baseDir) {
423
484
  detail: `missing: ${cmdPath}`,
424
485
  };
425
486
  }
487
+ /**
488
+ * Resolve the absolute path of `codex` on PATH (cross-platform). Returns
489
+ * `null` when codex is not installed. We walk `process.env.PATH`
490
+ * directly rather than shelling out — earlier iterations spawned
491
+ * `sh -c "command -v codex"` which gave false negatives in sanitized
492
+ * POSIX environments where `/bin` is omitted from PATH (CI runners,
493
+ * hardened dev shells) but the `codex` binary lives at a project-bin
494
+ * path that IS on PATH. Codex [P2] 2026-04-29.
495
+ *
496
+ * On Windows we iterate `PATHEXT` (default `.COM;.EXE;.BAT;.CMD`) so
497
+ * `codex.cmd` (the typical npm shim) is discovered. POSIX checks the
498
+ * bare name and accepts any file with an execute bit set.
499
+ */
500
+ function resolveCodexBinary() {
501
+ const isWindows = process.platform === 'win32';
502
+ const pathEnv = process.env.PATH ?? process.env.Path ?? '';
503
+ if (pathEnv.length === 0)
504
+ return null;
505
+ const sep = isWindows ? ';' : ':';
506
+ const entries = pathEnv.split(sep).filter((p) => p.length > 0);
507
+ if (isWindows) {
508
+ const pathExt = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';');
509
+ for (const dir of entries) {
510
+ for (const ext of pathExt) {
511
+ const candidate = path.join(dir, `codex${ext}`);
512
+ try {
513
+ const st = fs.statSync(candidate);
514
+ if (st.isFile())
515
+ return candidate;
516
+ }
517
+ catch {
518
+ // not present in this PATH entry — keep walking
519
+ }
520
+ }
521
+ // also check the bare name in case PATHEXT is unusual
522
+ const bare = path.join(dir, 'codex');
523
+ try {
524
+ const st = fs.statSync(bare);
525
+ if (st.isFile())
526
+ return bare;
527
+ }
528
+ catch {
529
+ // not present — keep walking
530
+ }
531
+ }
532
+ return null;
533
+ }
534
+ // POSIX: check executable bit on the file mode.
535
+ for (const dir of entries) {
536
+ const candidate = path.join(dir, 'codex');
537
+ try {
538
+ const st = fs.statSync(candidate);
539
+ if (st.isFile() && (st.mode & 0o111) !== 0)
540
+ return candidate;
541
+ }
542
+ catch {
543
+ // not present in this PATH entry — keep walking
544
+ }
545
+ }
546
+ return null;
547
+ }
548
+ /**
549
+ * Hard-fail when `policy.review.codex_required: true` but the `codex`
550
+ * binary is not on PATH. Pre-0.12.0 this prereq surfaced only at first
551
+ * push, by which point the consumer had cloned, run `pnpm install`,
552
+ * authored a commit, and tried to push — only then to learn that they
553
+ * needed a separate install. Fix C of 0.12.0 surfaces it during install.
554
+ *
555
+ * Returns `pass` when codex resolves; `fail` when missing. Operators who
556
+ * want to disable the gate can flip `policy.review.codex_required: false`
557
+ * (the doctor then short-circuits past every Codex check).
558
+ */
559
+ export function checkCodexBinaryOnPath() {
560
+ const resolved = resolveCodexBinary();
561
+ if (resolved !== null) {
562
+ return {
563
+ label: 'codex CLI on PATH',
564
+ status: 'pass',
565
+ detail: resolved,
566
+ };
567
+ }
568
+ return {
569
+ label: 'codex CLI on PATH',
570
+ status: 'fail',
571
+ detail: 'codex not found on PATH. policy.review.codex_required: true requires the codex binary. ' +
572
+ 'Install: https://github.com/openai/codex (e.g. `npm i -g @openai/codex`). ' +
573
+ 'To disable the push-gate instead, set policy.review.codex_required: false in .rea/policy.yaml.',
574
+ };
575
+ }
426
576
  /**
427
577
  * Translate a `CodexProbeState` into two doctor CheckResults: one for
428
578
  * responsiveness (pass/warn) and one informational line about the last
@@ -512,6 +662,7 @@ export function collectChecks(baseDir, codexProbeState, prePushState) {
512
662
  if (prePushState !== undefined) {
513
663
  checks.push(checkPrePushHook(prePushState));
514
664
  }
665
+ checks.push(checkExtensionFragments(baseDir));
515
666
  }
516
667
  else {
517
668
  checks.push({
@@ -521,7 +672,7 @@ export function collectChecks(baseDir, codexProbeState, prePushState) {
521
672
  });
522
673
  }
523
674
  if (codexRequiredFromPolicy(baseDir)) {
524
- checks.push(checkCodexAgent(baseDir), checkCodexCommand(baseDir));
675
+ checks.push(checkCodexAgent(baseDir), checkCodexCommand(baseDir), checkCodexBinaryOnPath());
525
676
  if (codexProbeState !== undefined) {
526
677
  checks.push(...checksFromProbeState(codexProbeState));
527
678
  }
@@ -31,6 +31,13 @@
31
31
  import type { Command } from 'commander';
32
32
  export interface HookPushGateOptions {
33
33
  base?: string;
34
+ /**
35
+ * Diff against `HEAD~N` instead of running the upstream ladder. Mirrors
36
+ * `policy.review.last_n_commits`; the CLI flag wins when both are set.
37
+ * `--base` always wins over both. Validated as a positive integer; the
38
+ * CLI rejects non-numeric input before reaching `runPushGate`.
39
+ */
40
+ lastNCommits?: number;
34
41
  }
35
42
  /**
36
43
  * Public runner, exposed so integration tests and the commander binding can
package/dist/cli/hook.js CHANGED
@@ -55,6 +55,7 @@ export async function runHookPushGate(options) {
55
55
  stderr,
56
56
  refspecs,
57
57
  ...(options.base !== undefined && options.base.length > 0 ? { explicitBase: options.base } : {}),
58
+ ...(options.lastNCommits !== undefined ? { lastNCommits: options.lastNCommits } : {}),
58
59
  });
59
60
  process.exit(result.exitCode);
60
61
  }
@@ -121,7 +122,17 @@ export function registerHookCommand(program) {
121
122
  .argument('[gitArgs...]', 'positional args forwarded by git (remote name, URL); ignored')
122
123
  .description('Run `codex exec review` against the current diff and block on blocking findings. Exits 0/1/2: pass/HALT/blocked. No cache — every push runs Codex afresh.')
123
124
  .option('--base <ref>', 'explicit base ref to diff against (e.g. origin/main). Defaults to @{upstream} → origin/HEAD → main/master → empty-tree.')
125
+ .option('--last-n-commits <n>', 'narrow review to the last N commits (diff against HEAD~N). Useful for large feature branches. Loses to --base when both are set; mirrors policy.review.last_n_commits.', (raw) => {
126
+ const n = Number(raw);
127
+ if (!Number.isInteger(n) || n <= 0) {
128
+ throw new Error(`--last-n-commits must be a positive integer, got ${JSON.stringify(raw)}`);
129
+ }
130
+ return n;
131
+ })
124
132
  .action(async (_gitArgs, opts) => {
125
- await runHookPushGate({ ...(opts.base !== undefined ? { base: opts.base } : {}) });
133
+ await runHookPushGate({
134
+ ...(opts.base !== undefined ? { base: opts.base } : {}),
135
+ ...(opts.lastNCommits !== undefined ? { lastNCommits: opts.lastNCommits } : {}),
136
+ });
126
137
  });
127
138
  }
@@ -17,6 +17,40 @@
17
17
  * `.rea/policy.yaml`, so it is safe to install unconditionally — see the
18
18
  * header of `.husky/commit-msg` for the opt-in check.
19
19
  */
20
+ /**
21
+ * Marker baked into every rea-installed commit-msg hook. Anchored on line 2
22
+ * of the file (immediately after the shebang) for classification. Bump the
23
+ * version suffix whenever the body semantics change so upgrades can migrate
24
+ * old installs cleanly.
25
+ *
26
+ * v1 — 0.13.0: first version of the commit-msg marker. Pre-0.13 installs
27
+ * shipped the same file content but without a marker line — those
28
+ * classify as `unmarked` and are upgraded in place.
29
+ */
30
+ export declare const COMMIT_MSG_MARKER = "# rea:commit-msg v1";
31
+ /**
32
+ * Classify an existing `commit-msg` file. The classifier is permissive on
33
+ * upgrades (treat `unmarked` as legacy rea body) and conservative on
34
+ * foreign hooks (do not stomp).
35
+ */
36
+ export type CommitMsgClassification = {
37
+ kind: 'absent';
38
+ } | {
39
+ kind: 'rea-managed';
40
+ version: string;
41
+ } | {
42
+ kind: 'unmarked';
43
+ } | {
44
+ kind: 'foreign';
45
+ reason: string;
46
+ };
47
+ /**
48
+ * Inspect `hookPath` and decide whether it is rea-authored, a pre-marker
49
+ * legacy rea body, or a foreign user hook. The pre-0.13 commit-msg shipped
50
+ * with no marker line; we detect that shape via the literal "block_ai_attribution"
51
+ * grep — every rea body has consulted that policy field since 0.1.0.
52
+ */
53
+ export declare function classifyCommitMsgHook(hookPath: string): Promise<CommitMsgClassification>;
20
54
  export interface CommitMsgInstallResult {
21
55
  gitHook?: string;
22
56
  huskyHook?: string;
@@ -24,6 +24,66 @@ import path from 'node:path';
24
24
  import { promisify } from 'node:util';
25
25
  import { PKG_ROOT, warn } from '../utils.js';
26
26
  const execFileAsync = promisify(execFile);
27
+ /**
28
+ * Marker baked into every rea-installed commit-msg hook. Anchored on line 2
29
+ * of the file (immediately after the shebang) for classification. Bump the
30
+ * version suffix whenever the body semantics change so upgrades can migrate
31
+ * old installs cleanly.
32
+ *
33
+ * v1 — 0.13.0: first version of the commit-msg marker. Pre-0.13 installs
34
+ * shipped the same file content but without a marker line — those
35
+ * classify as `unmarked` and are upgraded in place.
36
+ */
37
+ export const COMMIT_MSG_MARKER = '# rea:commit-msg v1';
38
+ /**
39
+ * Inspect `hookPath` and decide whether it is rea-authored, a pre-marker
40
+ * legacy rea body, or a foreign user hook. The pre-0.13 commit-msg shipped
41
+ * with no marker line; we detect that shape via the literal "block_ai_attribution"
42
+ * grep — every rea body has consulted that policy field since 0.1.0.
43
+ */
44
+ export async function classifyCommitMsgHook(hookPath) {
45
+ let stat;
46
+ try {
47
+ stat = await fsPromises.lstat(hookPath);
48
+ }
49
+ catch {
50
+ return { kind: 'absent' };
51
+ }
52
+ if (stat.isDirectory())
53
+ return { kind: 'foreign', reason: 'is-directory' };
54
+ if (stat.isSymbolicLink())
55
+ return { kind: 'foreign', reason: 'is-symlink' };
56
+ if (!stat.isFile())
57
+ return { kind: 'foreign', reason: 'not-regular-file' };
58
+ let content;
59
+ try {
60
+ content = await fsPromises.readFile(hookPath, 'utf8');
61
+ }
62
+ catch (e) {
63
+ return {
64
+ kind: 'foreign',
65
+ reason: `read-error: ${e instanceof Error ? e.message : String(e)}`,
66
+ };
67
+ }
68
+ // Anchor the marker on the second line — substring match would be tricked
69
+ // by a foreign hook that mentions the marker in a comment.
70
+ if (content.startsWith('#!/bin/sh\n')) {
71
+ const secondLineEnd = content.indexOf('\n', 10);
72
+ if (secondLineEnd > 0) {
73
+ const secondLine = content.slice(10, secondLineEnd);
74
+ if (secondLine === COMMIT_MSG_MARKER) {
75
+ return { kind: 'rea-managed', version: 'v1' };
76
+ }
77
+ }
78
+ }
79
+ // Pre-0.13 rea body had no marker but always contained the attribution
80
+ // grep. Treat that shape as upgradeable rather than foreign.
81
+ if (content.includes('block_ai_attribution') &&
82
+ content.includes('AI attribution detected')) {
83
+ return { kind: 'unmarked' };
84
+ }
85
+ return { kind: 'foreign', reason: 'no-marker' };
86
+ }
27
87
  /**
28
88
  * Read `core.hooksPath` via `git config --get`. This is the only correct way
29
89
  * to consult git config: regex-matching `.git/config` (finding #9) is
@@ -53,19 +53,33 @@
53
53
  * classification. Bump the version suffix whenever the body semantics
54
54
  * change so upgrades can migrate old installs cleanly.
55
55
  *
56
+ * v4 — 0.13.0 extension-hook chaining: rea body sources `.husky/pre-push.d/*`
57
+ * fragments after its own work and before the final `exec`, in lex
58
+ * order. Non-zero fragment exit fails the hook.
59
+ * v3 — 0.12.0 BODY_TEMPLATE rewrite: positional-args dispatch via `case`
60
+ * arms with `set --`, removing the `exec $REA_BIN` word-splitting bug
61
+ * that broke pushes from repo paths containing spaces.
56
62
  * v2 — 0.11.0 stateless push-gate body (no bash core, no audit grep).
57
63
  * v1 — 0.10.x and prior, delegated to `.claude/hooks/push-review-gate.sh`.
58
64
  */
59
- export declare const FALLBACK_MARKER = "# rea:pre-push-fallback v2";
65
+ export declare const FALLBACK_MARKER = "# rea:pre-push-fallback v4";
66
+ /** Legacy v3 marker (0.12.x bodies). Refresh-on-upgrade. */
67
+ export declare const LEGACY_FALLBACK_MARKER_V3 = "# rea:pre-push-fallback v3";
68
+ /** Legacy v2 marker (0.11.x bodies). Refresh-on-upgrade. */
69
+ export declare const LEGACY_FALLBACK_MARKER_V2 = "# rea:pre-push-fallback v2";
60
70
  /** Legacy v1 marker — used by upgrade migration to detect old installs. */
61
71
  export declare const LEGACY_FALLBACK_MARKER_V1 = "# rea:pre-push-fallback v1";
62
72
  /**
63
73
  * Marker present in the shipped `.husky/pre-push` governance gate. The
64
74
  * second line of the shipped husky hook is this marker — rea upgrade
65
75
  * detects it to refresh in-place. Bump the suffix whenever the body
66
- * changes; pre-0.11 markers live in `LEGACY_HUSKY_GATE_MARKER_V1`.
76
+ * changes; pre-0.13 markers live in `LEGACY_HUSKY_GATE_MARKER_V{1,2,3}`.
67
77
  */
68
- export declare const HUSKY_GATE_MARKER = "# rea:husky-pre-push-gate v2";
78
+ export declare const HUSKY_GATE_MARKER = "# rea:husky-pre-push-gate v4";
79
+ /** Legacy v3 husky marker (0.12.x bodies). Refresh-on-upgrade. */
80
+ export declare const LEGACY_HUSKY_GATE_MARKER_V3 = "# rea:husky-pre-push-gate v3";
81
+ /** Legacy v2 husky marker (0.11.x bodies). Refresh-on-upgrade. */
82
+ export declare const LEGACY_HUSKY_GATE_MARKER_V2 = "# rea:husky-pre-push-gate v2";
69
83
  /** Legacy v1 husky marker for migration. */
70
84
  export declare const LEGACY_HUSKY_GATE_MARKER_V1 = "# rea:husky-pre-push-gate v1";
71
85
  /**
@@ -73,7 +87,11 @@ export declare const LEGACY_HUSKY_GATE_MARKER_V1 = "# rea:husky-pre-push-gate v1
73
87
  * empty body (stubbed out by a consumer) is NOT classified as rea-managed.
74
88
  * A real rea hook always carries both markers.
75
89
  */
76
- export declare const HUSKY_GATE_BODY_MARKER = "# rea:gate-body-v2";
90
+ export declare const HUSKY_GATE_BODY_MARKER = "# rea:gate-body-v4";
91
+ /** Legacy v3 body marker (0.12.x bodies). Refresh-on-upgrade. */
92
+ export declare const LEGACY_HUSKY_GATE_BODY_MARKER_V3 = "# rea:gate-body-v3";
93
+ /** Legacy v2 body marker (0.11.x bodies). Refresh-on-upgrade. */
94
+ export declare const LEGACY_HUSKY_GATE_BODY_MARKER_V2 = "# rea:gate-body-v2";
77
95
  /** Legacy body marker — used by upgrade migration detection. */
78
96
  export declare const LEGACY_HUSKY_GATE_BODY_MARKER_V1 = "# rea:gate-body-v1";
79
97
  /** Fallback hook body — `.git/hooks/pre-push` in vanilla-git installs. */
@@ -87,10 +105,12 @@ export declare function huskyHookContent(): string;
87
105
  */
88
106
  export declare function isReaManagedFallback(content: string): boolean;
89
107
  /**
90
- * True when `content` is the legacy v1 fallback (`.git/hooks/pre-push`
91
- * that delegated to `.claude/hooks/push-review-gate.sh`). Used by `rea
92
- * upgrade` to migrate we overwrite these unconditionally because we
93
- * control the entire body shape.
108
+ * True when `content` is a legacy fallback hook authored by an earlier rea
109
+ * release: v3 (0.12.x — pre-extension body), v2 (0.11.x broken
110
+ * `exec $REA_BIN` body), or v1 (0.10.x delegated to
111
+ * `.claude/hooks/push-review-gate.sh`). Used by `rea upgrade` to migrate
112
+ * — we overwrite these unconditionally because we control the entire
113
+ * body shape.
94
114
  */
95
115
  export declare function isLegacyReaManagedFallback(content: string): boolean;
96
116
  /**
@@ -106,8 +126,10 @@ export declare function isLegacyReaManagedFallback(content: string): boolean;
106
126
  */
107
127
  export declare function isReaManagedHuskyGate(content: string): boolean;
108
128
  /**
109
- * True when `content` is the legacy v1 Husky gate (`.husky/pre-push` from
110
- * 0.10.x and earlier). Used to trigger the upgrade migration.
129
+ * True when `content` is a legacy Husky gate from an earlier rea release:
130
+ * v3 (0.12.x pre-extension body), v2 (0.11.x broken `exec $REA_BIN`
131
+ * body), or v1 (0.10.x — bash core delegating). Used to trigger the
132
+ * upgrade migration.
111
133
  */
112
134
  export declare function isLegacyReaManagedHuskyGate(content: string): boolean;
113
135
  /**
@@ -65,19 +65,33 @@ 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
+ * v4 — 0.13.0 extension-hook chaining: rea body sources `.husky/pre-push.d/*`
69
+ * fragments after its own work and before the final `exec`, in lex
70
+ * order. Non-zero fragment exit fails the hook.
71
+ * v3 — 0.12.0 BODY_TEMPLATE rewrite: positional-args dispatch via `case`
72
+ * arms with `set --`, removing the `exec $REA_BIN` word-splitting bug
73
+ * that broke pushes from repo paths containing spaces.
68
74
  * v2 — 0.11.0 stateless push-gate body (no bash core, no audit grep).
69
75
  * v1 — 0.10.x and prior, delegated to `.claude/hooks/push-review-gate.sh`.
70
76
  */
71
- export const FALLBACK_MARKER = '# rea:pre-push-fallback v2';
77
+ export const FALLBACK_MARKER = '# rea:pre-push-fallback v4';
78
+ /** Legacy v3 marker (0.12.x bodies). Refresh-on-upgrade. */
79
+ export const LEGACY_FALLBACK_MARKER_V3 = '# rea:pre-push-fallback v3';
80
+ /** Legacy v2 marker (0.11.x bodies). Refresh-on-upgrade. */
81
+ export const LEGACY_FALLBACK_MARKER_V2 = '# rea:pre-push-fallback v2';
72
82
  /** Legacy v1 marker — used by upgrade migration to detect old installs. */
73
83
  export const LEGACY_FALLBACK_MARKER_V1 = '# rea:pre-push-fallback v1';
74
84
  /**
75
85
  * Marker present in the shipped `.husky/pre-push` governance gate. The
76
86
  * second line of the shipped husky hook is this marker — rea upgrade
77
87
  * detects it to refresh in-place. Bump the suffix whenever the body
78
- * changes; pre-0.11 markers live in `LEGACY_HUSKY_GATE_MARKER_V1`.
88
+ * changes; pre-0.13 markers live in `LEGACY_HUSKY_GATE_MARKER_V{1,2,3}`.
79
89
  */
80
- export const HUSKY_GATE_MARKER = '# rea:husky-pre-push-gate v2';
90
+ export const HUSKY_GATE_MARKER = '# rea:husky-pre-push-gate v4';
91
+ /** Legacy v3 husky marker (0.12.x bodies). Refresh-on-upgrade. */
92
+ export const LEGACY_HUSKY_GATE_MARKER_V3 = '# rea:husky-pre-push-gate v3';
93
+ /** Legacy v2 husky marker (0.11.x bodies). Refresh-on-upgrade. */
94
+ export const LEGACY_HUSKY_GATE_MARKER_V2 = '# rea:husky-pre-push-gate v2';
81
95
  /** Legacy v1 husky marker for migration. */
82
96
  export const LEGACY_HUSKY_GATE_MARKER_V1 = '# rea:husky-pre-push-gate v1';
83
97
  /**
@@ -85,7 +99,11 @@ export const LEGACY_HUSKY_GATE_MARKER_V1 = '# rea:husky-pre-push-gate v1';
85
99
  * empty body (stubbed out by a consumer) is NOT classified as rea-managed.
86
100
  * A real rea hook always carries both markers.
87
101
  */
88
- export const HUSKY_GATE_BODY_MARKER = '# rea:gate-body-v2';
102
+ export const HUSKY_GATE_BODY_MARKER = '# rea:gate-body-v4';
103
+ /** Legacy v3 body marker (0.12.x bodies). Refresh-on-upgrade. */
104
+ export const LEGACY_HUSKY_GATE_BODY_MARKER_V3 = '# rea:gate-body-v3';
105
+ /** Legacy v2 body marker (0.11.x bodies). Refresh-on-upgrade. */
106
+ export const LEGACY_HUSKY_GATE_BODY_MARKER_V2 = '# rea:gate-body-v2';
89
107
  /** Legacy body marker — used by upgrade migration detection. */
90
108
  export const LEGACY_HUSKY_GATE_BODY_MARKER_V1 = '# rea:gate-body-v1';
91
109
  // ---------------------------------------------------------------------------
@@ -107,7 +125,8 @@ const BODY_TEMPLATE = `set -eu
107
125
  # invocation, verdict inference, audit write — lives in
108
126
  # \`src/hooks/push-gate/\` and is invoked via \`rea hook push-gate\`.
109
127
  # This stub only short-circuits on the kill-switch and resolves the rea
110
- # binary (in priority: project node_modules/.bin/rea → PATH npx).
128
+ # binary (in priority: project node_modules/.bin/rea → dogfood dist
129
+ # PATH → npx).
111
130
  #
112
131
  # The 0.10.x hooks assumed rea was on PATH. Consumers who bootstrap via
113
132
  # \`npx @bookedsolid/rea init\` have no persistent global rea install, so
@@ -123,35 +142,83 @@ if [ -f "\${REA_ROOT}/.rea/HALT" ]; then
123
142
  exit 1
124
143
  fi
125
144
 
126
- # The pre-push stdin carries one line per refspec (local_ref local_sha
127
- # remote_ref remote_sha). Forward stdin verbatim via process substitution
128
- # the \`rea hook push-gate\` CLI reads it via process.stdin to pick up
129
- # the actual push base. Empty stdin (direct invocation, CI, etc.) is
130
- # handled by the CLI falling back to upstream origin/HEAD resolution.
145
+ # Dispatch the rea CLI as a positional-args list rather than a string
146
+ # (the 0.11.x \`exec \$REA_BIN ...\` pattern broke when REA_ROOT contained
147
+ # whitespace because the unquoted \$REA_BIN expansion underwent word
148
+ # splitting; \`/Users/jane/My Projects/repo\` produced four argv tokens
149
+ # instead of two). The \`set --\` form below preserves spaces verbatim.
150
+ #
151
+ # The pre-push stdin carries one line per refspec; \`exec\` inherits stdin
152
+ # unchanged. \$@ on entry carries git's <remote-name> <remote-url>; we
153
+ # preserve those by appending "\$@" inside each \`set --\` arm.
131
154
 
132
- REA_BIN=""
133
155
  if [ -x "\${REA_ROOT}/node_modules/.bin/rea" ]; then
134
- REA_BIN="\${REA_ROOT}/node_modules/.bin/rea"
135
- elif [ -f "\${REA_ROOT}/dist/cli/index.js" ]; then
156
+ set -- "\${REA_ROOT}/node_modules/.bin/rea" hook push-gate "\$@"
157
+ 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
136
158
  # rea's own repo (dogfood) — the package is not installed under
137
159
  # node_modules here because we ARE the package. The built CLI
138
160
  # entry point lives at dist/cli/index.js; node runs it directly.
139
- REA_BIN="node \${REA_ROOT}/dist/cli/index.js"
161
+ # Gate this branch on \`package.json\` declaring \`@bookedsolid/rea\` so a
162
+ # consumer repo that happens to ship its own \`dist/cli/index.js\` does
163
+ # not get this hook executing the consumer's unrelated build.
164
+ set -- node "\${REA_ROOT}/dist/cli/index.js" hook push-gate "\$@"
140
165
  elif command -v rea >/dev/null 2>&1; then
141
- REA_BIN="rea"
166
+ set -- rea hook push-gate "\$@"
142
167
  elif command -v npx >/dev/null 2>&1; then
143
168
  # Last resort: npx will resolve the package from npm or the cache.
144
169
  # Pass \`--no-install\` so a rare cache-cold machine surfaces a clear
145
170
  # error instead of silently downloading at push time.
146
- REA_BIN="npx --no-install @bookedsolid/rea"
171
+ set -- npx --no-install @bookedsolid/rea hook push-gate "\$@"
147
172
  else
148
173
  printf 'rea: cannot locate the rea CLI. Install locally (\`pnpm add -D @bookedsolid/rea\`) or globally (\`npm i -g @bookedsolid/rea\`).\\n' >&2
149
174
  exit 2
150
175
  fi
151
176
 
152
- # \$@ carries the pre-push arguments (git passes <remote-name> <remote-url>).
153
- # Stdin is inherited by \`exec\` the CLI sees it unchanged.
154
- exec \$REA_BIN hook push-gate "\$@"
177
+ # Run the rea push-gate FIRST. We capture its exit and explicitly propagate
178
+ # instead of \`exec\`-ing extension fragments must only run after rea's own
179
+ # governance work succeeds. The fragments are user code; surfacing them
180
+ # AFTER rea's body (HALT check, Codex review, audit write) preserves the
181
+ # governance contract while letting consumers chain their own checks (e.g.
182
+ # commitlint, branch-policy linters) without losing rea coverage.
183
+ "\$@"
184
+ rea_status=\$?
185
+ if [ "\$rea_status" -ne 0 ]; then
186
+ exit "\$rea_status"
187
+ fi
188
+
189
+ # Extension-hook chaining: source every executable file under
190
+ # \`.husky/pre-push.d/\` in lexical order. Missing directory = no-op
191
+ # (backward compatible). Each fragment receives the original positional
192
+ # args from git's \`<remote-name> <remote-url>\` invocation. Non-zero exit
193
+ # from any fragment fails the push; this matches husky's normal hook
194
+ # chaining semantics.
195
+ #
196
+ # The git pre-push contract delivers refspecs on stdin. Once rea's body has
197
+ # consumed it (\`exec rea hook push-gate "\$@"\` reads stdin during \`runPushGate\`),
198
+ # subsequent fragments cannot replay it — that's by design: agents that need
199
+ # refspec data should run before rea, not after. Fragments that need ambient
200
+ # repo state can call \`git rev-parse\` themselves.
201
+ ext_dir="\${REA_ROOT}/.husky/pre-push.d"
202
+ if [ -d "\$ext_dir" ]; then
203
+ # Collect fragments deterministically. POSIX sort orders the same way on
204
+ # macOS (BSD sort) and Linux (GNU sort) for ASCII filenames; consumers who
205
+ # name their fragments \`10-foo\` / \`20-bar\` get predictable ordering.
206
+ for frag in "\$ext_dir"/*; do
207
+ # Glob expands to itself when no matches — guard with -e.
208
+ [ -e "\$frag" ] || continue
209
+ # Skip non-files (directories, sockets) and non-executables. The
210
+ # executable bit is the consumer's opt-in: dropping a non-executable
211
+ # README into the dir does not become a hook.
212
+ [ -f "\$frag" ] || continue
213
+ [ -x "\$frag" ] || continue
214
+ # Each fragment runs in its own subprocess with the original git args.
215
+ # Failures propagate via \`set -eu\` above (loop body inherits, so any
216
+ # non-zero exit blocks the push immediately).
217
+ "\$frag" "\$@"
218
+ done
219
+ fi
220
+
221
+ exit 0
155
222
  `;
156
223
  /** Fallback hook body — `.git/hooks/pre-push` in vanilla-git installs. */
157
224
  export function fallbackHookContent() {
@@ -202,10 +269,12 @@ export function isReaManagedFallback(content) {
202
269
  return secondLine === FALLBACK_MARKER;
203
270
  }
204
271
  /**
205
- * True when `content` is the legacy v1 fallback (`.git/hooks/pre-push`
206
- * that delegated to `.claude/hooks/push-review-gate.sh`). Used by `rea
207
- * upgrade` to migrate we overwrite these unconditionally because we
208
- * control the entire body shape.
272
+ * True when `content` is a legacy fallback hook authored by an earlier rea
273
+ * release: v3 (0.12.x — pre-extension body), v2 (0.11.x broken
274
+ * `exec $REA_BIN` body), or v1 (0.10.x delegated to
275
+ * `.claude/hooks/push-review-gate.sh`). Used by `rea upgrade` to migrate
276
+ * — we overwrite these unconditionally because we control the entire
277
+ * body shape.
209
278
  */
210
279
  export function isLegacyReaManagedFallback(content) {
211
280
  if (!content.startsWith('#!/bin/sh\n'))
@@ -214,7 +283,9 @@ export function isLegacyReaManagedFallback(content) {
214
283
  if (secondLineEnd < 0)
215
284
  return false;
216
285
  const secondLine = content.slice(10, secondLineEnd);
217
- return secondLine === LEGACY_FALLBACK_MARKER_V1;
286
+ return (secondLine === LEGACY_FALLBACK_MARKER_V3 ||
287
+ secondLine === LEGACY_FALLBACK_MARKER_V2 ||
288
+ secondLine === LEGACY_FALLBACK_MARKER_V1);
218
289
  }
219
290
  /**
220
291
  * True when `content` carries the rea Husky gate markers in the canonical
@@ -231,11 +302,15 @@ export function isReaManagedHuskyGate(content) {
231
302
  return hasHeaderMarkers(content, HUSKY_GATE_MARKER, HUSKY_GATE_BODY_MARKER);
232
303
  }
233
304
  /**
234
- * True when `content` is the legacy v1 Husky gate (`.husky/pre-push` from
235
- * 0.10.x and earlier). Used to trigger the upgrade migration.
305
+ * True when `content` is a legacy Husky gate from an earlier rea release:
306
+ * v3 (0.12.x pre-extension body), v2 (0.11.x broken `exec $REA_BIN`
307
+ * body), or v1 (0.10.x — bash core delegating). Used to trigger the
308
+ * upgrade migration.
236
309
  */
237
310
  export function isLegacyReaManagedHuskyGate(content) {
238
- return hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V1, LEGACY_HUSKY_GATE_BODY_MARKER_V1);
311
+ return (hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V3, LEGACY_HUSKY_GATE_BODY_MARKER_V3) ||
312
+ hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V2, LEGACY_HUSKY_GATE_BODY_MARKER_V2) ||
313
+ hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V1, LEGACY_HUSKY_GATE_BODY_MARKER_V1));
239
314
  }
240
315
  function hasHeaderMarkers(content, header, body) {
241
316
  if (!content.startsWith('#!/bin/sh\n'))
@@ -523,6 +598,10 @@ async function cleanupStaleTempFiles(dst) {
523
598
  return;
524
599
  if (!body.includes(FALLBACK_MARKER) &&
525
600
  !body.includes(HUSKY_GATE_MARKER) &&
601
+ !body.includes(LEGACY_FALLBACK_MARKER_V3) &&
602
+ !body.includes(LEGACY_HUSKY_GATE_MARKER_V3) &&
603
+ !body.includes(LEGACY_FALLBACK_MARKER_V2) &&
604
+ !body.includes(LEGACY_HUSKY_GATE_MARKER_V2) &&
526
605
  !body.includes(LEGACY_FALLBACK_MARKER_V1) &&
527
606
  !body.includes(LEGACY_HUSKY_GATE_MARKER_V1)) {
528
607
  return;