@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.
- package/.husky/commit-msg +32 -0
- package/.husky/pre-push +88 -13
- package/README.md +914 -550
- package/dist/cli/doctor.d.ts +12 -0
- package/dist/cli/doctor.js +152 -1
- package/dist/cli/hook.d.ts +7 -0
- package/dist/cli/hook.js +12 -1
- package/dist/cli/install/commit-msg.d.ts +34 -0
- package/dist/cli/install/commit-msg.js +60 -0
- package/dist/cli/install/pre-push.d.ts +32 -10
- package/dist/cli/install/pre-push.js +106 -27
- package/dist/hooks/push-gate/base.d.ts +48 -1
- package/dist/hooks/push-gate/base.js +121 -0
- package/dist/hooks/push-gate/codex-runner.d.ts +8 -0
- package/dist/hooks/push-gate/codex-runner.js +13 -0
- package/dist/hooks/push-gate/index.d.ts +8 -0
- package/dist/hooks/push-gate/index.js +162 -22
- package/dist/hooks/push-gate/policy.d.ts +39 -4
- package/dist/hooks/push-gate/policy.js +29 -4
- package/dist/policy/loader.d.ts +19 -0
- package/dist/policy/loader.js +11 -0
- package/dist/policy/types.d.ts +72 -2
- package/package.json +1 -1
- package/scripts/tarball-smoke.sh +7 -2
package/dist/cli/doctor.d.ts
CHANGED
|
@@ -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
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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
|
}
|
package/dist/cli/hook.d.ts
CHANGED
|
@@ -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({
|
|
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
|
|
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.
|
|
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
|
|
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-
|
|
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
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
110
|
-
* 0.
|
|
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
|
|
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.
|
|
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
|
|
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-
|
|
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 →
|
|
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
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
153
|
-
#
|
|
154
|
-
|
|
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
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
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 ===
|
|
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
|
|
235
|
-
* 0.
|
|
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,
|
|
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;
|