@bookedsolid/rea 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/agents/codex-adversarial.md +4 -0
- package/agents/rea-orchestrator.md +9 -0
- package/commands/codex-review.md +4 -0
- package/dist/audit/append.d.ts +1 -0
- package/dist/audit/append.js +1 -0
- package/dist/audit/content-token.d.ts +98 -0
- package/dist/audit/content-token.js +136 -0
- package/dist/audit/local-review-event.d.ts +136 -0
- package/dist/audit/local-review-event.js +43 -0
- package/dist/cli/doctor.js +17 -0
- package/dist/cli/hook.d.ts +44 -0
- package/dist/cli/hook.js +77 -0
- package/dist/cli/index.js +9 -0
- package/dist/cli/init.js +197 -46
- package/dist/cli/install/pre-push.d.ts +15 -3
- package/dist/cli/install/pre-push.js +55 -5
- package/dist/cli/install/settings-merge.js +13 -0
- package/dist/cli/preflight.d.ts +120 -0
- package/dist/cli/preflight.js +487 -0
- package/dist/cli/review.d.ts +56 -0
- package/dist/cli/review.js +325 -0
- package/dist/policy/loader.d.ts +65 -0
- package/dist/policy/loader.js +33 -0
- package/dist/policy/types.d.ts +89 -0
- package/hooks/_lib/cmd-segments.sh +140 -2
- package/hooks/_lib/policy-read.sh +255 -0
- package/hooks/local-review-gate.sh +460 -0
- package/package.json +1 -1
- package/templates/CLAUDE.md.local-first.md +87 -0
- package/templates/pre-push.local-first.sh +65 -0
|
@@ -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 {};
|
|
@@ -0,0 +1,487 @@
|
|
|
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 fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { spawnSync } from 'node:child_process';
|
|
34
|
+
import { appendAuditRecord } from '../audit/append.js';
|
|
35
|
+
import { LOCAL_REVIEW_TOOL_NAME, LOCAL_REVIEW_SKIPPED_OVERRIDE_TOOL_NAME, LOCAL_REVIEW_PREFLIGHT_SKIPPED_TOOL_NAME, LOCAL_REVIEW_SERVER_NAME, } from '../audit/local-review-event.js';
|
|
36
|
+
import { CODEX_REVIEW_TOOL_NAME } from '../audit/codex-event.js';
|
|
37
|
+
import { computeTreeToken, EMPTY_TREE_SHA } from '../audit/content-token.js';
|
|
38
|
+
import { readHalt } from '../hooks/push-gate/halt.js';
|
|
39
|
+
import { Tier, InvocationStatus } from '../policy/types.js';
|
|
40
|
+
import { loadPolicyAsync } from '../policy/loader.js';
|
|
41
|
+
import { err, log } from './utils.js';
|
|
42
|
+
/** Default max age for a local-review audit entry (24h). */
|
|
43
|
+
export const DEFAULT_MAX_AGE_SECONDS = 86_400;
|
|
44
|
+
/** Default bypass env-var name. */
|
|
45
|
+
export const DEFAULT_BYPASS_ENV_VAR = 'REA_SKIP_LOCAL_REVIEW';
|
|
46
|
+
/** Default commit-hygiene thresholds. */
|
|
47
|
+
export const DEFAULT_WARN_AT_COMMITS = 1;
|
|
48
|
+
export const DEFAULT_REFUSE_AT_COMMITS = 5;
|
|
49
|
+
/**
|
|
50
|
+
* Run preflight in-process. Tests drive this directly. The CLI binding
|
|
51
|
+
* exits via `process.exit` at the end of `runPreflight()`.
|
|
52
|
+
*/
|
|
53
|
+
export async function computePreflight(baseDir, options, env = process.env) {
|
|
54
|
+
const policy = await tryLoadPolicy(baseDir);
|
|
55
|
+
// Round-27 F4 fix: HALT check BEFORE every other path. The Bash-tier
|
|
56
|
+
// `local-review-gate.sh` and the canonical husky BODY_TEMPLATE both
|
|
57
|
+
// honor `.rea/HALT`, but `rea preflight` itself was missing the check —
|
|
58
|
+
// direct invocations and the minimal `templates/pre-push.local-first.sh`
|
|
59
|
+
// body bypassed the kill-switch entirely. The HALT check runs BEFORE
|
|
60
|
+
// `mode === 'off'` so a halted repo cannot push even when local-review
|
|
61
|
+
// enforcement is opted-out.
|
|
62
|
+
const halt = readHalt(baseDir);
|
|
63
|
+
if (halt.halted) {
|
|
64
|
+
return {
|
|
65
|
+
outcome: {
|
|
66
|
+
status: 'refuse',
|
|
67
|
+
reason: `REA HALT: ${halt.reason ?? 'unknown'}`,
|
|
68
|
+
exitCode: 2,
|
|
69
|
+
details: {
|
|
70
|
+
halt: true,
|
|
71
|
+
halt_reason: halt.reason ?? 'unknown',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
policy,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// Step 1: mode === 'off' → no-op clean exit.
|
|
78
|
+
const mode = policy?.review?.local_review?.mode ?? 'enforced';
|
|
79
|
+
if (mode === 'off') {
|
|
80
|
+
return {
|
|
81
|
+
outcome: {
|
|
82
|
+
status: 'clean',
|
|
83
|
+
reason: 'policy.review.local_review.mode is off',
|
|
84
|
+
exitCode: 0,
|
|
85
|
+
details: { mode: 'off' },
|
|
86
|
+
},
|
|
87
|
+
policy,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const headSha = resolveHeadSha(baseDir);
|
|
91
|
+
// 0.26.0 helix-026 finding-1: compute the current tree-token. Coverage
|
|
92
|
+
// is matched on this in step 4 — `head_sha` is only used for forensics
|
|
93
|
+
// (and as a back-compat fallback for legacy `codex.review` entries that
|
|
94
|
+
// were written before content_token existed).
|
|
95
|
+
const contentToken = computeTreeToken(baseDir);
|
|
96
|
+
const bypassEnvVar = policy?.review?.local_review?.bypass_env_var ?? DEFAULT_BYPASS_ENV_VAR;
|
|
97
|
+
const bypassReason = (env[bypassEnvVar] ?? '').trim();
|
|
98
|
+
// Step 2: bypass env-var → audit + clean exit.
|
|
99
|
+
if (bypassReason.length > 0) {
|
|
100
|
+
const meta = {
|
|
101
|
+
head_sha: headSha,
|
|
102
|
+
reason: bypassReason,
|
|
103
|
+
bypass_env_var: bypassEnvVar,
|
|
104
|
+
};
|
|
105
|
+
await safeAudit(baseDir, LOCAL_REVIEW_SKIPPED_OVERRIDE_TOOL_NAME, InvocationStatus.Allowed, meta, policy);
|
|
106
|
+
return {
|
|
107
|
+
outcome: {
|
|
108
|
+
status: 'clean',
|
|
109
|
+
reason: `${bypassEnvVar} set (audited)`,
|
|
110
|
+
exitCode: 0,
|
|
111
|
+
details: { bypass_env_var: bypassEnvVar, reason: bypassReason },
|
|
112
|
+
},
|
|
113
|
+
policy,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// Step 3: --no-review-check escape hatch (audit-logged).
|
|
117
|
+
let reviewCheckSkipped = false;
|
|
118
|
+
if (options.noReviewCheck === true) {
|
|
119
|
+
reviewCheckSkipped = true;
|
|
120
|
+
await safeAudit(baseDir, LOCAL_REVIEW_PREFLIGHT_SKIPPED_TOOL_NAME, InvocationStatus.Allowed, { head_sha: headSha, reason: '--no-review-check flag' }, policy);
|
|
121
|
+
}
|
|
122
|
+
// Step 4: audit-log lookup (skipped under --no-review-check).
|
|
123
|
+
const maxAgeSeconds = policy?.review?.local_review?.max_age_seconds ?? DEFAULT_MAX_AGE_SECONDS;
|
|
124
|
+
if (!reviewCheckSkipped) {
|
|
125
|
+
const lookup = findRecentLocalReview(baseDir, headSha, maxAgeSeconds, new Date(), contentToken);
|
|
126
|
+
if (!lookup.found) {
|
|
127
|
+
return {
|
|
128
|
+
outcome: {
|
|
129
|
+
status: 'refuse',
|
|
130
|
+
reason: 'no recent local-review audit entry covers HEAD',
|
|
131
|
+
exitCode: 2,
|
|
132
|
+
details: {
|
|
133
|
+
head_sha: headSha,
|
|
134
|
+
content_token: contentToken,
|
|
135
|
+
max_age_seconds: maxAgeSeconds,
|
|
136
|
+
bypass_env_var: bypassEnvVar,
|
|
137
|
+
policy_off_switch: 'policy.review.local_review.mode: off',
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
policy,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Step 5: commit-count check.
|
|
145
|
+
const warnAt = policy?.commit_hygiene?.warn_at_commits ?? DEFAULT_WARN_AT_COMMITS;
|
|
146
|
+
const refuseAt = policy?.commit_hygiene?.refuse_at_commits ?? DEFAULT_REFUSE_AT_COMMITS;
|
|
147
|
+
const commitCount = countCommitsAheadOfBase(baseDir);
|
|
148
|
+
if (commitCount !== null) {
|
|
149
|
+
if (commitCount > refuseAt) {
|
|
150
|
+
return {
|
|
151
|
+
outcome: {
|
|
152
|
+
status: 'refuse',
|
|
153
|
+
reason: `commit count ${commitCount} > refuse_at_commits=${refuseAt} — squash before pushing`,
|
|
154
|
+
exitCode: 2,
|
|
155
|
+
details: {
|
|
156
|
+
commit_count: commitCount,
|
|
157
|
+
warn_at_commits: warnAt,
|
|
158
|
+
refuse_at_commits: refuseAt,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
policy,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (commitCount > warnAt) {
|
|
165
|
+
const elevated = options.strict === true;
|
|
166
|
+
return {
|
|
167
|
+
outcome: {
|
|
168
|
+
status: elevated ? 'refuse' : 'warn',
|
|
169
|
+
reason: `commit count ${commitCount} > warn_at_commits=${warnAt}${elevated ? ' (strict)' : ''}`,
|
|
170
|
+
exitCode: elevated ? 2 : 1,
|
|
171
|
+
details: {
|
|
172
|
+
commit_count: commitCount,
|
|
173
|
+
warn_at_commits: warnAt,
|
|
174
|
+
refuse_at_commits: refuseAt,
|
|
175
|
+
strict: elevated,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
policy,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
outcome: {
|
|
184
|
+
status: 'clean',
|
|
185
|
+
reason: reviewCheckSkipped
|
|
186
|
+
? 'review check skipped, commit-hygiene clean'
|
|
187
|
+
: 'recent local-review audit entry covers HEAD',
|
|
188
|
+
exitCode: 0,
|
|
189
|
+
details: { head_sha: headSha, content_token: contentToken, commit_count: commitCount },
|
|
190
|
+
},
|
|
191
|
+
policy,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
export async function runPreflight(options) {
|
|
195
|
+
const baseDir = process.cwd();
|
|
196
|
+
const { outcome } = await computePreflight(baseDir, options);
|
|
197
|
+
if (options.json === true) {
|
|
198
|
+
process.stdout.write(JSON.stringify({ ...outcome }) + '\n');
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
if (outcome.exitCode === 0) {
|
|
202
|
+
log(`preflight clean — ${outcome.reason}`);
|
|
203
|
+
}
|
|
204
|
+
else if (outcome.exitCode === 1) {
|
|
205
|
+
console.warn(`[rea] preflight WARN — ${outcome.reason}`);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
err(`preflight refuse — ${outcome.reason}`);
|
|
209
|
+
console.error('');
|
|
210
|
+
console.error(' To unblock, do ONE of:');
|
|
211
|
+
console.error(' 1. Run `rea review` — write a fresh local-review audit entry');
|
|
212
|
+
console.error(' 2. Set REA_SKIP_LOCAL_REVIEW="<reason>"');
|
|
213
|
+
console.error(' — per-invocation override (audited)');
|
|
214
|
+
console.error(' 3. Edit .rea/policy.yaml — set:');
|
|
215
|
+
console.error(' review:');
|
|
216
|
+
console.error(' local_review:');
|
|
217
|
+
console.error(' mode: off');
|
|
218
|
+
console.error(' (use this if your team does not have codex/claude installed)');
|
|
219
|
+
console.error('');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
process.exit(outcome.exitCode);
|
|
223
|
+
}
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Implementation helpers
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
async function tryLoadPolicy(baseDir) {
|
|
228
|
+
try {
|
|
229
|
+
return await loadPolicyAsync(baseDir);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function resolveHeadSha(baseDir) {
|
|
236
|
+
const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: baseDir, encoding: 'utf8' });
|
|
237
|
+
if (r.status !== 0) {
|
|
238
|
+
// Round-27 F2 fix: unborn-HEAD repos return EMPTY_TREE_SHA so the
|
|
239
|
+
// reader stays symmetric with `rea review`'s writer (which uses the
|
|
240
|
+
// same constant when HEAD can't be resolved — see review.ts). Pre-fix
|
|
241
|
+
// the reader returned '' and the both-empty guard in
|
|
242
|
+
// `findRecentLocalReview` rejected the just-written audit entry,
|
|
243
|
+
// deadlocking `git init → rea review → git commit` under
|
|
244
|
+
// `refuse_at: both`.
|
|
245
|
+
return EMPTY_TREE_SHA;
|
|
246
|
+
}
|
|
247
|
+
const sha = (r.stdout ?? '').toString().trim();
|
|
248
|
+
return sha.length > 0 ? sha : EMPTY_TREE_SHA;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Resolve the divergence base for commit-counting. The intent is "how
|
|
252
|
+
* many commits will this push deliver to TRUNK" — not "how many commits
|
|
253
|
+
* are unpushed on this branch". Order trunk-equivalent refs first.
|
|
254
|
+
*
|
|
255
|
+
* Order (0.26.0 helix-026 finding-3):
|
|
256
|
+
* 1. `origin/HEAD` (the default branch on origin — usually `main`)
|
|
257
|
+
* 2. `origin/main`
|
|
258
|
+
* 3. `origin/master`
|
|
259
|
+
* 4. `@{upstream}` (LAST RESORT — see warning below)
|
|
260
|
+
*
|
|
261
|
+
* # Why `@{upstream}` is last
|
|
262
|
+
*
|
|
263
|
+
* After `git push -u origin <branch>`, `@{upstream}` resolves to
|
|
264
|
+
* `origin/<branch>` — the branch's OWN remote tip, NOT trunk. If
|
|
265
|
+
* preflight's commit-count check were keyed to that, a 50-commit
|
|
266
|
+
* feature branch would always count "0" once pushed, defeating
|
|
267
|
+
* `refuse_at_commits` on the very long-lived branches the policy was
|
|
268
|
+
* designed to discourage.
|
|
269
|
+
*
|
|
270
|
+
* `@{upstream}` is preserved as the absolute fallback for repos with no
|
|
271
|
+
* `origin` (forks, mirrors, weird CI clones). When `@{upstream}` is the
|
|
272
|
+
* only resolvable base AND it points to a non-trunk ref, preflight
|
|
273
|
+
* accepts the no-op cost — the audit-log review check is the primary
|
|
274
|
+
* gate; commit-count is best-effort.
|
|
275
|
+
*
|
|
276
|
+
* Additional guard: when `@{upstream}` resolves to a ref under
|
|
277
|
+
* `refs/remotes/origin/` other than the default branch, we skip it.
|
|
278
|
+
* This catches the typical `git push -u origin <feature>` case while
|
|
279
|
+
* still allowing `@{upstream}` -> `origin/main` to work for branches
|
|
280
|
+
* whose upstream IS trunk.
|
|
281
|
+
*
|
|
282
|
+
* Returns null when none resolve — `rea preflight` then skips the
|
|
283
|
+
* commit-count check (best-effort; the audit-log check is the primary
|
|
284
|
+
* gate).
|
|
285
|
+
*/
|
|
286
|
+
function resolveCommitCountBase(baseDir) {
|
|
287
|
+
// Trunk-equivalent refs first. `@{upstream}` is held back as a
|
|
288
|
+
// last-resort because it can resolve to the branch's own remote tip
|
|
289
|
+
// and turn the gate into a no-op — see the docblock above.
|
|
290
|
+
const primary = ['origin/HEAD', 'origin/main', 'origin/master'];
|
|
291
|
+
for (const ref of primary) {
|
|
292
|
+
if (resolveRef(baseDir, ref).length > 0)
|
|
293
|
+
return ref;
|
|
294
|
+
}
|
|
295
|
+
// `@{upstream}` LAST. We additionally probe what it resolves to —
|
|
296
|
+
// if it's a remote feature-branch ref under `refs/remotes/origin/`
|
|
297
|
+
// (not a primary trunk ref we already tried), the candidate is
|
|
298
|
+
// useless for commit-counting and we skip it rather than silently
|
|
299
|
+
// turn the check into a no-op.
|
|
300
|
+
const upstreamSymbolic = resolveSymbolicRef(baseDir, '@{upstream}');
|
|
301
|
+
if (upstreamSymbolic.length > 0) {
|
|
302
|
+
// `git rev-parse --abbrev-ref @{upstream}` returns e.g.
|
|
303
|
+
// `origin/main` or `origin/feat/foo`. If the resolved ref matches
|
|
304
|
+
// origin/<branch> for any branch we DIDN'T already try as a primary
|
|
305
|
+
// candidate, it's a feature-tracking upstream — skip.
|
|
306
|
+
const isFeatureUpstream = upstreamSymbolic.startsWith('origin/') &&
|
|
307
|
+
!primary.includes(upstreamSymbolic);
|
|
308
|
+
if (!isFeatureUpstream) {
|
|
309
|
+
// Upstream IS a trunk-equivalent ref (origin/main / origin/master /
|
|
310
|
+
// a non-origin remote). Use it.
|
|
311
|
+
if (resolveRef(baseDir, '@{upstream}').length > 0)
|
|
312
|
+
return '@{upstream}';
|
|
313
|
+
}
|
|
314
|
+
// Feature-tracking upstream: deliberately skipped to avoid the
|
|
315
|
+
// "50-commit branch counts 0" no-op. Fall through.
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
function resolveRef(baseDir, ref) {
|
|
320
|
+
const r = spawnSync('git', ['rev-parse', '--verify', '--quiet', ref], {
|
|
321
|
+
cwd: baseDir,
|
|
322
|
+
encoding: 'utf8',
|
|
323
|
+
});
|
|
324
|
+
if (r.status !== 0)
|
|
325
|
+
return '';
|
|
326
|
+
return (r.stdout ?? '').toString().trim();
|
|
327
|
+
}
|
|
328
|
+
function resolveSymbolicRef(baseDir, ref) {
|
|
329
|
+
const r = spawnSync('git', ['rev-parse', '--abbrev-ref', ref], {
|
|
330
|
+
cwd: baseDir,
|
|
331
|
+
encoding: 'utf8',
|
|
332
|
+
});
|
|
333
|
+
if (r.status !== 0)
|
|
334
|
+
return '';
|
|
335
|
+
return (r.stdout ?? '').toString().trim();
|
|
336
|
+
}
|
|
337
|
+
function countCommitsAheadOfBase(baseDir) {
|
|
338
|
+
const base = resolveCommitCountBase(baseDir);
|
|
339
|
+
if (base === null)
|
|
340
|
+
return null;
|
|
341
|
+
const r = spawnSync('git', ['rev-list', '--count', `${base}..HEAD`], {
|
|
342
|
+
cwd: baseDir,
|
|
343
|
+
encoding: 'utf8',
|
|
344
|
+
});
|
|
345
|
+
if (r.status !== 0)
|
|
346
|
+
return null;
|
|
347
|
+
const n = Number((r.stdout ?? '').toString().trim());
|
|
348
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
|
349
|
+
}
|
|
350
|
+
export function findRecentLocalReview(baseDir, headSha, maxAgeSeconds, now = new Date(), contentToken = '') {
|
|
351
|
+
// 0.26.0 helix-026 finding-1: callers can match by content_token,
|
|
352
|
+
// head_sha, or both. We need at least ONE non-empty key — without
|
|
353
|
+
// either the function would match every record indiscriminately.
|
|
354
|
+
if (headSha.length === 0 && contentToken.length === 0)
|
|
355
|
+
return { found: false };
|
|
356
|
+
const auditPath = path.join(baseDir, '.rea', 'audit.jsonl');
|
|
357
|
+
if (!fs.existsSync(auditPath))
|
|
358
|
+
return { found: false };
|
|
359
|
+
let raw;
|
|
360
|
+
try {
|
|
361
|
+
raw = fs.readFileSync(auditPath, 'utf8');
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return { found: false };
|
|
365
|
+
}
|
|
366
|
+
const lines = raw.split(/\r?\n/);
|
|
367
|
+
const cutoffMs = now.getTime() - maxAgeSeconds * 1000;
|
|
368
|
+
// Walk in reverse — most recent first.
|
|
369
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
370
|
+
const line = lines[i];
|
|
371
|
+
if (line === undefined || line.length === 0)
|
|
372
|
+
continue;
|
|
373
|
+
let record;
|
|
374
|
+
try {
|
|
375
|
+
record = JSON.parse(line);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const toolName = typeof record.tool_name === 'string' ? record.tool_name : '';
|
|
381
|
+
if (toolName !== LOCAL_REVIEW_TOOL_NAME && toolName !== CODEX_REVIEW_TOOL_NAME) {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const status = typeof record.status === 'string' ? record.status : '';
|
|
385
|
+
// Skipped/error variants are not coverage. `denied` (blocking verdict)
|
|
386
|
+
// is also not coverage — preflight's job is to ensure a successful
|
|
387
|
+
// recent review, not just any review.
|
|
388
|
+
if (status !== 'allowed')
|
|
389
|
+
continue;
|
|
390
|
+
const metadata = (record.metadata ?? {});
|
|
391
|
+
const recordedSha = typeof metadata.head_sha === 'string' ? metadata.head_sha : '';
|
|
392
|
+
const recordedToken = typeof metadata.content_token === 'string' ? metadata.content_token : '';
|
|
393
|
+
// Coverage match: prefer content_token (stable across --amend), fall
|
|
394
|
+
// back to head_sha for legacy entries / providers that can't compute
|
|
395
|
+
// a token. See block-comment above for the full hierarchy.
|
|
396
|
+
//
|
|
397
|
+
// Round-27 F3 fix: when BOTH sides have a content_token but they
|
|
398
|
+
// DISAGREE, the entry is stale — the working-tree content has changed
|
|
399
|
+
// since the review was written. Pre-fix the `else if` ran whenever
|
|
400
|
+
// the first branch failed, INCLUDING real token mismatch, which
|
|
401
|
+
// silently fell back to head_sha matching. PoC: `rea review` writes
|
|
402
|
+
// T1, operator edits one tracked file (no commit), `git commit`
|
|
403
|
+
// under `refuse_at: commit` → preflight approves the commit because
|
|
404
|
+
// HEAD hasn't moved, defeating the whole content-token path.
|
|
405
|
+
//
|
|
406
|
+
// Fix: when both tokens are present, the comparison is authoritative —
|
|
407
|
+
// mismatch means stale, no fallback. Only when the entry is missing
|
|
408
|
+
// a content_token (legacy `codex.review`) OR the caller's contentToken
|
|
409
|
+
// is empty (non-git directory) do we fall through to head_sha.
|
|
410
|
+
let matchKind = null;
|
|
411
|
+
if (recordedToken.length > 0 && contentToken.length > 0) {
|
|
412
|
+
// Both sides have a token — token comparison is AUTHORITATIVE.
|
|
413
|
+
if (recordedToken === contentToken)
|
|
414
|
+
matchKind = 'content_token';
|
|
415
|
+
// Token mismatch: this entry is stale. Do NOT fall back.
|
|
416
|
+
}
|
|
417
|
+
else if (recordedSha.length > 0 && headSha.length > 0 && recordedSha === headSha) {
|
|
418
|
+
// No token on this entry (or caller). Legacy / non-git fallback.
|
|
419
|
+
matchKind = 'head_sha';
|
|
420
|
+
}
|
|
421
|
+
if (matchKind === null)
|
|
422
|
+
continue;
|
|
423
|
+
const verdict = typeof metadata.verdict === 'string' ? metadata.verdict : '';
|
|
424
|
+
if (verdict === 'error' || verdict === 'blocking')
|
|
425
|
+
continue;
|
|
426
|
+
const timestamp = typeof record.timestamp === 'string' ? record.timestamp : '';
|
|
427
|
+
if (timestamp.length > 0) {
|
|
428
|
+
const ts = Date.parse(timestamp);
|
|
429
|
+
if (Number.isFinite(ts) && ts < cutoffMs) {
|
|
430
|
+
// Older than max_age_seconds — keep walking; a more recent valid
|
|
431
|
+
// record may exist further back? No: we walk newest-to-oldest so
|
|
432
|
+
// anything older from here on is also stale. Stop early.
|
|
433
|
+
return { found: false };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
found: true,
|
|
438
|
+
metadata,
|
|
439
|
+
timestamp,
|
|
440
|
+
tool_name: toolName,
|
|
441
|
+
match_kind: matchKind,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
return { found: false };
|
|
445
|
+
}
|
|
446
|
+
async function safeAudit(baseDir, toolName, status, metadata, policy) {
|
|
447
|
+
try {
|
|
448
|
+
const cleanMeta = {};
|
|
449
|
+
for (const [k, v] of Object.entries(metadata)) {
|
|
450
|
+
if (v !== undefined)
|
|
451
|
+
cleanMeta[k] = v;
|
|
452
|
+
}
|
|
453
|
+
await appendAuditRecord(baseDir, {
|
|
454
|
+
tool_name: toolName,
|
|
455
|
+
server_name: LOCAL_REVIEW_SERVER_NAME,
|
|
456
|
+
tier: Tier.Read,
|
|
457
|
+
status,
|
|
458
|
+
...(Object.keys(cleanMeta).length > 0 ? { metadata: cleanMeta } : {}),
|
|
459
|
+
...(policy !== undefined ? { policy } : {}),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
catch (e) {
|
|
463
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
464
|
+
process.stderr.write(`rea: audit append failed (${toolName}): ${msg}\n`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Attach `rea preflight` to a commander Program.
|
|
469
|
+
*/
|
|
470
|
+
export function registerPreflightCommand(program) {
|
|
471
|
+
program
|
|
472
|
+
.command('preflight')
|
|
473
|
+
.description('Local-first enforcement workhorse. Refuses (exit 2) when no recent `rea.local_review` audit entry covers HEAD, when commit-hygiene thresholds are exceeded, or when the kill-switch is active. Exit 0 (clean) / 1 (warn) / 2 (refuse). Husky pre-push and the Bash-tier `local-review-gate.sh` hook both delegate here.')
|
|
474
|
+
.option('--strict', 'treat commit-hygiene warns as refusals (exit 2 instead of 1). Always set by husky pre-push.')
|
|
475
|
+
.option('--no-review-check', 'skip the audit-log lookup (still runs commit-hygiene). Audit-logged escape hatch — different from the per-invocation env-var override.')
|
|
476
|
+
.option('--json', 'emit a single-line JSON outcome instead of human-readable output')
|
|
477
|
+
.action(async (opts) => {
|
|
478
|
+
// Commander negation: --no-review-check sets opts.reviewCheck = false.
|
|
479
|
+
// We invert to noReviewCheck for clarity in the runner.
|
|
480
|
+
const noReviewCheck = opts.reviewCheck === false;
|
|
481
|
+
await runPreflight({
|
|
482
|
+
...(opts.strict === true ? { strict: true } : {}),
|
|
483
|
+
...(noReviewCheck ? { noReviewCheck: true } : {}),
|
|
484
|
+
...(opts.json === true ? { json: true } : {}),
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rea review` — local-first codex review CLI (0.26.0+).
|
|
3
|
+
*
|
|
4
|
+
* Runs `codex exec review` against the working tree (or a specified
|
|
5
|
+
* base ref), parses the verdict, and writes a `rea.local_review`
|
|
6
|
+
* audit entry that `rea preflight` consults.
|
|
7
|
+
*
|
|
8
|
+
* Exit codes:
|
|
9
|
+
*
|
|
10
|
+
* 0 — pass (or skipped because `mode: off` + codex unavailable)
|
|
11
|
+
* 1 — concerns (configurable via --strict-fail-on)
|
|
12
|
+
* 2 — blocking, codex error, or codex unavailable in `mode: enforced`
|
|
13
|
+
*
|
|
14
|
+
* Behavior matrix:
|
|
15
|
+
*
|
|
16
|
+
* policy.local_review.mode codex available? result
|
|
17
|
+
* ------------------------ --------------- ----------------------
|
|
18
|
+
* enforced or unset (def.) yes run review, audit
|
|
19
|
+
* enforced or unset (def.) no exit 2 with helpful msg
|
|
20
|
+
* off yes run review, audit
|
|
21
|
+
* off no exit 0, audit skipped
|
|
22
|
+
*
|
|
23
|
+
* The `provider` field on the audit record is `'codex'` today. Future
|
|
24
|
+
* providers (Claude-subagent, Pi, Gemma) write the SAME `rea.local_review`
|
|
25
|
+
* shape with their own `provider:` value — `rea preflight` accepts any.
|
|
26
|
+
*
|
|
27
|
+
* The CLI is a thin wrapper around `runCodexReview` from
|
|
28
|
+
* `src/hooks/push-gate/codex-runner.ts`. We do NOT re-implement codex
|
|
29
|
+
* spawning. The push-gate's iron-gate defaults (gpt-5.4 + high reasoning)
|
|
30
|
+
* apply identically here so a local review carries the same weight as
|
|
31
|
+
* the push-gate's review.
|
|
32
|
+
*/
|
|
33
|
+
import type { Command } from 'commander';
|
|
34
|
+
export interface RunReviewOptions {
|
|
35
|
+
/** Optional explicit base ref. Defaults to upstream-ladder resolution. */
|
|
36
|
+
base?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Verdict floor that turns into a non-zero exit. `'concerns'` exits 1
|
|
39
|
+
* on concerns; `'blocking'` (default) exits 0 on concerns and 2 only
|
|
40
|
+
* on blocking. Aligns with the push-gate's `concerns_blocks` knob.
|
|
41
|
+
*/
|
|
42
|
+
strictFailOn?: 'concerns' | 'blocking';
|
|
43
|
+
/** Emit a single JSON line on stdout instead of pretty output. */
|
|
44
|
+
json?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Public runner — exposed so tests can drive the function in-process and
|
|
48
|
+
* the commander binding can stay thin. Throws via `process.exit` (CLI
|
|
49
|
+
* convention across `src/cli/`).
|
|
50
|
+
*/
|
|
51
|
+
export declare function runReview(options: RunReviewOptions): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Attach `rea review` to a commander Program.
|
|
54
|
+
*/
|
|
55
|
+
export declare function registerReviewCommand(program: Command): void;
|
|
56
|
+
export declare const REA_AUDIT_RELATIVE: string;
|