@bookedsolid/rea 0.10.3 → 0.12.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.
Files changed (77) hide show
  1. package/.husky/pre-push +48 -162
  2. package/README.md +834 -552
  3. package/agents/codex-adversarial.md +5 -3
  4. package/commands/codex-review.md +3 -5
  5. package/dist/audit/append.d.ts +7 -32
  6. package/dist/audit/append.js +7 -35
  7. package/dist/cli/audit.d.ts +0 -31
  8. package/dist/cli/audit.js +5 -74
  9. package/dist/cli/doctor.d.ts +12 -0
  10. package/dist/cli/doctor.js +96 -17
  11. package/dist/cli/hook.d.ts +55 -0
  12. package/dist/cli/hook.js +138 -0
  13. package/dist/cli/index.js +5 -80
  14. package/dist/cli/init.js +1 -1
  15. package/dist/cli/install/gitignore.d.ts +2 -2
  16. package/dist/cli/install/gitignore.js +3 -3
  17. package/dist/cli/install/pre-push.d.ts +158 -272
  18. package/dist/cli/install/pre-push.js +491 -2633
  19. package/dist/cli/install/settings-merge.d.ts +17 -0
  20. package/dist/cli/install/settings-merge.js +48 -1
  21. package/dist/cli/upgrade.js +131 -3
  22. package/dist/config/tier-map.js +18 -25
  23. package/dist/hooks/push-gate/base.d.ts +104 -0
  24. package/dist/hooks/push-gate/base.js +198 -0
  25. package/dist/hooks/push-gate/codex-runner.d.ts +126 -0
  26. package/dist/hooks/push-gate/codex-runner.js +223 -0
  27. package/dist/hooks/push-gate/findings.d.ts +68 -0
  28. package/dist/hooks/push-gate/findings.js +142 -0
  29. package/dist/hooks/push-gate/halt.d.ts +28 -0
  30. package/dist/hooks/push-gate/halt.js +49 -0
  31. package/dist/hooks/push-gate/index.d.ts +98 -0
  32. package/dist/hooks/push-gate/index.js +416 -0
  33. package/dist/hooks/push-gate/policy.d.ts +55 -0
  34. package/dist/hooks/push-gate/policy.js +64 -0
  35. package/dist/hooks/push-gate/report.d.ts +89 -0
  36. package/dist/hooks/push-gate/report.js +140 -0
  37. package/dist/policy/loader.d.ts +15 -10
  38. package/dist/policy/loader.js +8 -6
  39. package/dist/policy/types.d.ts +73 -22
  40. package/package.json +1 -1
  41. package/scripts/tarball-smoke.sh +7 -2
  42. package/dist/cache/review-cache.d.ts +0 -115
  43. package/dist/cache/review-cache.js +0 -200
  44. package/dist/cli/cache.d.ts +0 -84
  45. package/dist/cli/cache.js +0 -150
  46. package/dist/hooks/review-gate/args.d.ts +0 -126
  47. package/dist/hooks/review-gate/args.js +0 -315
  48. package/dist/hooks/review-gate/audit.d.ts +0 -131
  49. package/dist/hooks/review-gate/audit.js +0 -181
  50. package/dist/hooks/review-gate/banner.d.ts +0 -97
  51. package/dist/hooks/review-gate/banner.js +0 -172
  52. package/dist/hooks/review-gate/base-resolve.d.ts +0 -155
  53. package/dist/hooks/review-gate/base-resolve.js +0 -247
  54. package/dist/hooks/review-gate/cache-key.d.ts +0 -55
  55. package/dist/hooks/review-gate/cache-key.js +0 -41
  56. package/dist/hooks/review-gate/cache.d.ts +0 -108
  57. package/dist/hooks/review-gate/cache.js +0 -120
  58. package/dist/hooks/review-gate/constants.d.ts +0 -26
  59. package/dist/hooks/review-gate/constants.js +0 -34
  60. package/dist/hooks/review-gate/diff.d.ts +0 -181
  61. package/dist/hooks/review-gate/diff.js +0 -232
  62. package/dist/hooks/review-gate/errors.d.ts +0 -72
  63. package/dist/hooks/review-gate/errors.js +0 -100
  64. package/dist/hooks/review-gate/hash.d.ts +0 -43
  65. package/dist/hooks/review-gate/hash.js +0 -46
  66. package/dist/hooks/review-gate/index.d.ts +0 -31
  67. package/dist/hooks/review-gate/index.js +0 -35
  68. package/dist/hooks/review-gate/metadata.d.ts +0 -98
  69. package/dist/hooks/review-gate/metadata.js +0 -158
  70. package/dist/hooks/review-gate/policy.d.ts +0 -55
  71. package/dist/hooks/review-gate/policy.js +0 -71
  72. package/dist/hooks/review-gate/protected-paths.d.ts +0 -46
  73. package/dist/hooks/review-gate/protected-paths.js +0 -76
  74. package/hooks/_lib/push-review-core.sh +0 -1250
  75. package/hooks/commit-review-gate.sh +0 -330
  76. package/hooks/push-review-gate-git.sh +0 -94
  77. package/hooks/push-review-gate.sh +0 -92
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Codex CLI runner for the push-gate.
3
+ *
4
+ * Shells to `codex exec review --base <ref> --json --ephemeral` and consumes
5
+ * the JSONL event stream. Every event is parsed; the sequence of
6
+ * `agent_message` items becomes the review text that `findings.ts` then
7
+ * parses for P1/P2/P3 markers.
8
+ *
9
+ * Errors are typed so `index.ts` can distinguish:
10
+ *
11
+ * - `CodexNotInstalledError` → clear install-Codex prompt
12
+ * - `CodexTimeoutError` → `review.timeout_ms` exceeded; kill signal
13
+ * - `CodexProtocolError` → stdout was not JSONL or lacked agent output
14
+ * - `CodexSubprocessError` → non-zero exit with captured stderr
15
+ *
16
+ * The `GitExecutor` interface is a narrow shim around `git` invocations the
17
+ * gate needs (base resolution, diff-names, HEAD resolution). Extracted so
18
+ * `./base.ts` and `./index.ts` can be unit-tested with deterministic fakes
19
+ * and so the one git dependency surface is in one place.
20
+ */
21
+ import type { ChildProcessWithoutNullStreams } from 'node:child_process';
22
+ export declare class CodexNotInstalledError extends Error {
23
+ readonly kind: "not-installed";
24
+ constructor();
25
+ }
26
+ export declare class CodexTimeoutError extends Error {
27
+ readonly timeoutMs: number;
28
+ readonly kind: "timeout";
29
+ constructor(timeoutMs: number);
30
+ }
31
+ export declare class CodexProtocolError extends Error {
32
+ readonly detail: string;
33
+ readonly sampleLine?: string | undefined;
34
+ readonly kind: "protocol";
35
+ constructor(detail: string, sampleLine?: string | undefined);
36
+ }
37
+ export declare class CodexSubprocessError extends Error {
38
+ readonly exitCode: number | null;
39
+ readonly signal: NodeJS.Signals | null;
40
+ readonly stderrTail: string;
41
+ readonly kind: "subprocess";
42
+ constructor(exitCode: number | null, signal: NodeJS.Signals | null, stderrTail: string);
43
+ }
44
+ export type CodexRunError = CodexNotInstalledError | CodexTimeoutError | CodexProtocolError | CodexSubprocessError;
45
+ export interface GitExecutor {
46
+ /** `git rev-parse <args>`. Returns stdout trimmed or '' on non-zero exit. */
47
+ tryRevParse(args: string[]): string;
48
+ /** `git symbolic-ref <ref>`. Returns stdout trimmed or '' on non-zero. */
49
+ trySymbolicRef(ref: string): string;
50
+ /** `git rev-parse HEAD`. Returns the 40-char SHA or '' on non-zero. */
51
+ headSha(): string;
52
+ /** `git diff --name-only <base> <head>`. Returns path list (possibly empty). */
53
+ diffNames(base: string, head: string): string[];
54
+ }
55
+ /**
56
+ * Real git implementation using `spawnSync`. Each call is independent (no
57
+ * persistent git process) — the gate runs infrequently enough that the
58
+ * fork overhead is inaudible.
59
+ */
60
+ export declare function createRealGitExecutor(cwd: string): GitExecutor;
61
+ export interface CodexRunOptions {
62
+ baseRef: string;
63
+ cwd: string;
64
+ timeoutMs: number;
65
+ /** Optional custom review prompt; defaults to Codex's built-in. */
66
+ prompt?: string;
67
+ /**
68
+ * Env passthrough. Tests inject a clean env to prevent ambient overrides.
69
+ * Production passes `process.env`.
70
+ */
71
+ env?: NodeJS.ProcessEnv;
72
+ /**
73
+ * Injection seam for tests. When set, replaces `spawn` entirely. Must
74
+ * return an object whose `stdout`/`stderr` are async iterables of Buffer
75
+ * chunks and whose `on('exit')` yields `(code, signal)` like a real
76
+ * ChildProcess. Keeping this narrow means we don't have to fake the
77
+ * whole ChildProcess API.
78
+ */
79
+ spawnImpl?: (command: string, args: readonly string[], options: {
80
+ cwd: string;
81
+ env: NodeJS.ProcessEnv;
82
+ }) => ChildProcessWithoutNullStreams;
83
+ }
84
+ export interface CodexRunResult {
85
+ /** The concatenated text of every `item.completed` agent_message item. */
86
+ reviewText: string;
87
+ /** Number of JSONL events observed — useful for debugging protocol issues. */
88
+ eventCount: number;
89
+ /** Seconds of wall time spent in the subprocess. */
90
+ durationSeconds: number;
91
+ }
92
+ /**
93
+ * Execute `codex exec review` and return the concatenated review text on
94
+ * success. Callers then pass the text to `summarizeReview()` to get a
95
+ * structured verdict.
96
+ *
97
+ * Every error case throws a typed `CodexRunError`. Callers are expected to
98
+ * catch and translate to an exit code + audit event.
99
+ */
100
+ export declare function runCodexReview(options: CodexRunOptions): Promise<CodexRunResult>;
101
+ export interface CodexJsonlParseResult {
102
+ reviewText: string;
103
+ eventCount: number;
104
+ }
105
+ /**
106
+ * Parse the JSONL event stream emitted by `codex exec review --json`. We
107
+ * tolerate partial lines (stream chunks may split mid-object; our caller
108
+ * gives us the full stdout after exit, but robustness costs nothing).
109
+ *
110
+ * The only events we care about are `item.completed` where `item.type ===
111
+ * "agent_message"` — those carry the review text. Everything else (turn
112
+ * lifecycle, command_execution telemetry, thread metadata) is counted but
113
+ * discarded.
114
+ *
115
+ * A JSONL line that doesn't parse as JSON is tolerated: we skip it and
116
+ * continue. Codex occasionally emits warnings outside the JSON envelope
117
+ * (e.g. macOS xcrun cache errors leak into stderr but can accidentally
118
+ * land on stdout in misbehaving shells); we treat these as non-fatal.
119
+ *
120
+ * We throw `CodexProtocolError` only when the ENTIRE stdout contains zero
121
+ * parseable events AND zero `agent_message`-carrying items. An empty diff
122
+ * can legitimately yield zero agent messages with events (thread.started,
123
+ * turn.started, turn.completed), so we allow zero findings when at least
124
+ * one event parsed.
125
+ */
126
+ export declare function parseCodexJsonl(stdout: string): CodexJsonlParseResult;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Codex CLI runner for the push-gate.
3
+ *
4
+ * Shells to `codex exec review --base <ref> --json --ephemeral` and consumes
5
+ * the JSONL event stream. Every event is parsed; the sequence of
6
+ * `agent_message` items becomes the review text that `findings.ts` then
7
+ * parses for P1/P2/P3 markers.
8
+ *
9
+ * Errors are typed so `index.ts` can distinguish:
10
+ *
11
+ * - `CodexNotInstalledError` → clear install-Codex prompt
12
+ * - `CodexTimeoutError` → `review.timeout_ms` exceeded; kill signal
13
+ * - `CodexProtocolError` → stdout was not JSONL or lacked agent output
14
+ * - `CodexSubprocessError` → non-zero exit with captured stderr
15
+ *
16
+ * The `GitExecutor` interface is a narrow shim around `git` invocations the
17
+ * gate needs (base resolution, diff-names, HEAD resolution). Extracted so
18
+ * `./base.ts` and `./index.ts` can be unit-tested with deterministic fakes
19
+ * and so the one git dependency surface is in one place.
20
+ */
21
+ import { spawn, spawnSync } from 'node:child_process';
22
+ // ---------------------------------------------------------------------------
23
+ // Errors
24
+ // ---------------------------------------------------------------------------
25
+ export class CodexNotInstalledError extends Error {
26
+ kind = 'not-installed';
27
+ constructor() {
28
+ super('codex CLI not found on PATH. Install with `npm i -g @openai/codex`, or set `review.codex_required: false` in .rea/policy.yaml to disable the push-gate.');
29
+ this.name = 'CodexNotInstalledError';
30
+ }
31
+ }
32
+ export class CodexTimeoutError extends Error {
33
+ timeoutMs;
34
+ kind = 'timeout';
35
+ constructor(timeoutMs) {
36
+ super(`codex exec review exceeded policy.review.timeout_ms (${timeoutMs}ms). The subprocess was killed. Consider raising the timeout, narrowing the diff, or running /codex-review manually to debug.`);
37
+ this.timeoutMs = timeoutMs;
38
+ this.name = 'CodexTimeoutError';
39
+ }
40
+ }
41
+ export class CodexProtocolError extends Error {
42
+ detail;
43
+ sampleLine;
44
+ kind = 'protocol';
45
+ constructor(detail, sampleLine) {
46
+ super(`codex exec review produced unexpected output: ${detail}${sampleLine !== undefined ? ` (sample: ${sampleLine.slice(0, 120)})` : ''}`);
47
+ this.detail = detail;
48
+ this.sampleLine = sampleLine;
49
+ this.name = 'CodexProtocolError';
50
+ }
51
+ }
52
+ export class CodexSubprocessError extends Error {
53
+ exitCode;
54
+ signal;
55
+ stderrTail;
56
+ kind = 'subprocess';
57
+ constructor(exitCode, signal, stderrTail) {
58
+ super(`codex exec review exited ${exitCode !== null ? `with code ${exitCode}` : `via signal ${signal ?? 'unknown'}`}. stderr tail: ${stderrTail.slice(-800)}`);
59
+ this.exitCode = exitCode;
60
+ this.signal = signal;
61
+ this.stderrTail = stderrTail;
62
+ this.name = 'CodexSubprocessError';
63
+ }
64
+ }
65
+ /**
66
+ * Real git implementation using `spawnSync`. Each call is independent (no
67
+ * persistent git process) — the gate runs infrequently enough that the
68
+ * fork overhead is inaudible.
69
+ */
70
+ export function createRealGitExecutor(cwd) {
71
+ const run = (args) => {
72
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
73
+ return {
74
+ code: r.status ?? -1,
75
+ stdout: typeof r.stdout === 'string' ? r.stdout : '',
76
+ stderr: typeof r.stderr === 'string' ? r.stderr : '',
77
+ };
78
+ };
79
+ return {
80
+ tryRevParse(args) {
81
+ const r = run(['rev-parse', ...args]);
82
+ return r.code === 0 ? r.stdout.trim() : '';
83
+ },
84
+ trySymbolicRef(ref) {
85
+ const r = run(['symbolic-ref', ref]);
86
+ return r.code === 0 ? r.stdout.trim() : '';
87
+ },
88
+ headSha() {
89
+ const r = run(['rev-parse', 'HEAD']);
90
+ return r.code === 0 ? r.stdout.trim() : '';
91
+ },
92
+ diffNames(base, head) {
93
+ const r = run(['diff', '--name-only', base, head]);
94
+ if (r.code !== 0)
95
+ return [];
96
+ return r.stdout.split(/\r?\n/).filter((l) => l.length > 0);
97
+ },
98
+ };
99
+ }
100
+ /**
101
+ * Execute `codex exec review` and return the concatenated review text on
102
+ * success. Callers then pass the text to `summarizeReview()` to get a
103
+ * structured verdict.
104
+ *
105
+ * Every error case throws a typed `CodexRunError`. Callers are expected to
106
+ * catch and translate to an exit code + audit event.
107
+ */
108
+ export async function runCodexReview(options) {
109
+ const spawner = options.spawnImpl ?? spawn;
110
+ const baseArgs = ['exec', 'review', '--base', options.baseRef, '--json', '--ephemeral'];
111
+ const args = options.prompt !== undefined && options.prompt.length > 0 ? [...baseArgs, options.prompt] : baseArgs;
112
+ let child;
113
+ try {
114
+ child = spawner('codex', args, {
115
+ cwd: options.cwd,
116
+ env: options.env ?? process.env,
117
+ });
118
+ }
119
+ catch (e) {
120
+ if (isEnoent(e))
121
+ throw new CodexNotInstalledError();
122
+ throw e;
123
+ }
124
+ const stdoutChunks = [];
125
+ const stderrChunks = [];
126
+ const started = Date.now();
127
+ const exitCode = await new Promise((resolve, reject) => {
128
+ const timer = setTimeout(() => {
129
+ // SIGTERM first; graceful shutdown. Codex cleans up its session files
130
+ // on SIGTERM. We don't escalate to SIGKILL here — if the subprocess
131
+ // hangs the event loop's own timeout handling will surface it.
132
+ child.kill('SIGTERM');
133
+ reject(new CodexTimeoutError(options.timeoutMs));
134
+ }, options.timeoutMs);
135
+ timer.unref?.();
136
+ child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
137
+ child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
138
+ child.on('error', (e) => {
139
+ clearTimeout(timer);
140
+ if (isEnoent(e)) {
141
+ reject(new CodexNotInstalledError());
142
+ return;
143
+ }
144
+ reject(e);
145
+ });
146
+ // `close` (not `exit`) fires after BOTH stdio streams drain and the
147
+ // process has exited. Node can emit `exit` before the final stdout
148
+ // chunks are flushed on large reviews or slow pipes, causing
149
+ // `parseCodexJsonl()` to run against a truncated buffer and
150
+ // misclassify a blocking review as pass. Waiting for `close`
151
+ // guarantees every agent_message chunk is in `stdoutChunks`.
152
+ child.on('close', (code, signal) => {
153
+ clearTimeout(timer);
154
+ if (code === null && signal !== null) {
155
+ reject(new CodexSubprocessError(null, signal, Buffer.concat(stderrChunks).toString('utf8')));
156
+ return;
157
+ }
158
+ resolve(code);
159
+ });
160
+ });
161
+ const durationSeconds = (Date.now() - started) / 1000;
162
+ if (exitCode !== 0 && exitCode !== null) {
163
+ throw new CodexSubprocessError(exitCode, null, Buffer.concat(stderrChunks).toString('utf8'));
164
+ }
165
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8');
166
+ const { reviewText, eventCount } = parseCodexJsonl(stdout);
167
+ return { reviewText, eventCount, durationSeconds };
168
+ }
169
+ /**
170
+ * Parse the JSONL event stream emitted by `codex exec review --json`. We
171
+ * tolerate partial lines (stream chunks may split mid-object; our caller
172
+ * gives us the full stdout after exit, but robustness costs nothing).
173
+ *
174
+ * The only events we care about are `item.completed` where `item.type ===
175
+ * "agent_message"` — those carry the review text. Everything else (turn
176
+ * lifecycle, command_execution telemetry, thread metadata) is counted but
177
+ * discarded.
178
+ *
179
+ * A JSONL line that doesn't parse as JSON is tolerated: we skip it and
180
+ * continue. Codex occasionally emits warnings outside the JSON envelope
181
+ * (e.g. macOS xcrun cache errors leak into stderr but can accidentally
182
+ * land on stdout in misbehaving shells); we treat these as non-fatal.
183
+ *
184
+ * We throw `CodexProtocolError` only when the ENTIRE stdout contains zero
185
+ * parseable events AND zero `agent_message`-carrying items. An empty diff
186
+ * can legitimately yield zero agent messages with events (thread.started,
187
+ * turn.started, turn.completed), so we allow zero findings when at least
188
+ * one event parsed.
189
+ */
190
+ export function parseCodexJsonl(stdout) {
191
+ const lines = stdout.split(/\r?\n/).filter((l) => l.length > 0);
192
+ let reviewText = '';
193
+ let eventCount = 0;
194
+ let parsedAny = false;
195
+ for (const line of lines) {
196
+ let evt;
197
+ try {
198
+ evt = JSON.parse(line);
199
+ }
200
+ catch {
201
+ // Non-JSON line. Could be a shell warning that leaked to stdout. Skip.
202
+ continue;
203
+ }
204
+ parsedAny = true;
205
+ eventCount += 1;
206
+ if (evt.type === 'item.completed' &&
207
+ evt.item !== undefined &&
208
+ evt.item.type === 'agent_message' &&
209
+ typeof evt.item.text === 'string') {
210
+ reviewText = reviewText.length > 0 ? `${reviewText}\n\n${evt.item.text}` : evt.item.text;
211
+ }
212
+ }
213
+ if (!parsedAny && lines.length > 0) {
214
+ throw new CodexProtocolError('no parseable JSONL events in stdout', lines[0]);
215
+ }
216
+ return { reviewText, eventCount };
217
+ }
218
+ function isEnoent(e) {
219
+ if (e === null || typeof e !== 'object')
220
+ return false;
221
+ const code = e.code;
222
+ return code === 'ENOENT';
223
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Finding shape and verdict inference for the stateless push-gate.
3
+ *
4
+ * `codex exec review --json` emits JSONL events over stdout. The terminal
5
+ * event is an `item.completed` with `item.type === "agent_message"` whose
6
+ * `text` body is human-prose review output using Codex's standard severity
7
+ * convention:
8
+ *
9
+ * - `[P1]` — blocking. Must be addressed before merge.
10
+ * - `[P2]` — concerns. Significant risk the reviewer wants fixed.
11
+ * - `[P3]` — nits / low-priority suggestions.
12
+ *
13
+ * We extract one `Finding` per severity-marker bullet in the message text
14
+ * and infer a verdict:
15
+ *
16
+ * - Any `P1` → `blocking`
17
+ * - Else any `P2` → `concerns`
18
+ * - Else (P3 or nothing) → `pass`
19
+ *
20
+ * This is a text-parse, not a schema consumer. Codex does not expose a
21
+ * structured review schema through the JSONL event stream today (only
22
+ * `--output-schema` on plain `codex exec` does that). When the plugin
23
+ * ecosystem catches up we can swap the parser without touching the gate.
24
+ */
25
+ export type Severity = 'P1' | 'P2' | 'P3';
26
+ export type Verdict = 'pass' | 'concerns' | 'blocking';
27
+ export interface Finding {
28
+ severity: Severity;
29
+ title: string;
30
+ /** File path, when the marker line carried one. */
31
+ file?: string;
32
+ /** Starting line number, when the marker carried `<path>:<line>`. */
33
+ line?: number;
34
+ /** Full body of the finding (all lines up to the next marker or EOF). */
35
+ body: string;
36
+ }
37
+ export interface ReviewSummary {
38
+ verdict: Verdict;
39
+ findings: Finding[];
40
+ /** The raw `agent_message` text, concatenated from every turn. */
41
+ reviewText: string;
42
+ }
43
+ /**
44
+ * Parse Codex review prose into structured findings. The parser is
45
+ * conservative — lines that don't start with a severity marker are folded
46
+ * into the previous finding's body. Unknown markers (`[P4]`, `[P0]`) are
47
+ * ignored; we only recognize P1/P2/P3.
48
+ *
49
+ * Expected marker shapes, matched line-by-line:
50
+ *
51
+ * - [P1] Title goes here — path/to/file.ts:42
52
+ * - [P1] Title — path/to/file.ts
53
+ * - [P1] Title
54
+ *
55
+ * The dash/bullet prefix is optional (Codex emits both `- [P1]` and bare
56
+ * `[P1]` depending on model and prompt). Whitespace around the severity
57
+ * marker is tolerated.
58
+ */
59
+ export declare function parseFindings(reviewText: string): Finding[];
60
+ /**
61
+ * Map a finding array to a single verdict. Safe-fail order: any P1 wins,
62
+ * then any P2, else pass.
63
+ */
64
+ export declare function inferVerdict(findings: Finding[]): Verdict;
65
+ /**
66
+ * Convenience: parse + infer in one call.
67
+ */
68
+ export declare function summarizeReview(reviewText: string): ReviewSummary;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Finding shape and verdict inference for the stateless push-gate.
3
+ *
4
+ * `codex exec review --json` emits JSONL events over stdout. The terminal
5
+ * event is an `item.completed` with `item.type === "agent_message"` whose
6
+ * `text` body is human-prose review output using Codex's standard severity
7
+ * convention:
8
+ *
9
+ * - `[P1]` — blocking. Must be addressed before merge.
10
+ * - `[P2]` — concerns. Significant risk the reviewer wants fixed.
11
+ * - `[P3]` — nits / low-priority suggestions.
12
+ *
13
+ * We extract one `Finding` per severity-marker bullet in the message text
14
+ * and infer a verdict:
15
+ *
16
+ * - Any `P1` → `blocking`
17
+ * - Else any `P2` → `concerns`
18
+ * - Else (P3 or nothing) → `pass`
19
+ *
20
+ * This is a text-parse, not a schema consumer. Codex does not expose a
21
+ * structured review schema through the JSONL event stream today (only
22
+ * `--output-schema` on plain `codex exec` does that). When the plugin
23
+ * ecosystem catches up we can swap the parser without touching the gate.
24
+ */
25
+ /**
26
+ * Parse Codex review prose into structured findings. The parser is
27
+ * conservative — lines that don't start with a severity marker are folded
28
+ * into the previous finding's body. Unknown markers (`[P4]`, `[P0]`) are
29
+ * ignored; we only recognize P1/P2/P3.
30
+ *
31
+ * Expected marker shapes, matched line-by-line:
32
+ *
33
+ * - [P1] Title goes here — path/to/file.ts:42
34
+ * - [P1] Title — path/to/file.ts
35
+ * - [P1] Title
36
+ *
37
+ * The dash/bullet prefix is optional (Codex emits both `- [P1]` and bare
38
+ * `[P1]` depending on model and prompt). Whitespace around the severity
39
+ * marker is tolerated.
40
+ */
41
+ export function parseFindings(reviewText) {
42
+ const lines = reviewText.split(/\r?\n/);
43
+ const out = [];
44
+ let current = null;
45
+ // Anchored at the start of a trimmed line — an inline `[P1]` in the
46
+ // middle of a sentence is not a finding marker. The `^` excludes the
47
+ // all-text prefix inside the match itself; we trim before testing.
48
+ const MARKER_RE = /^(?:[-*]\s*)?\[(P[123])\]\s+(.+?)\s*$/;
49
+ for (const rawLine of lines) {
50
+ const trimmed = rawLine.trim();
51
+ const match = MARKER_RE.exec(trimmed);
52
+ if (match !== null) {
53
+ if (current !== null)
54
+ out.push(current);
55
+ const severity = match[1];
56
+ const titleWithLocation = match[2] ?? '';
57
+ const { title, file, line } = splitTitleLocation(titleWithLocation);
58
+ current = {
59
+ severity,
60
+ title,
61
+ body: rawLine,
62
+ ...(file !== undefined ? { file } : {}),
63
+ ...(line !== undefined ? { line } : {}),
64
+ };
65
+ continue;
66
+ }
67
+ if (current !== null) {
68
+ current.body = current.body.length > 0 ? `${current.body}\n${rawLine}` : rawLine;
69
+ }
70
+ }
71
+ if (current !== null)
72
+ out.push(current);
73
+ return out;
74
+ }
75
+ /**
76
+ * Split "Title — file.ts:42" or "Title - file.ts" or bare "Title" into
77
+ * constituent parts. Codex emits an em-dash (`—`) as the separator in the
78
+ * default review prompt but we also accept `--` and `-` for robustness.
79
+ */
80
+ function splitTitleLocation(raw) {
81
+ // Try em-dash first (Codex default), then double-dash, then single-dash
82
+ // surrounded by whitespace. A plain `-` inside a title (e.g. "pre-push")
83
+ // is preserved because we require surrounding whitespace.
84
+ let splitIdx = raw.indexOf(' — ');
85
+ let sepLen = 3;
86
+ if (splitIdx < 0) {
87
+ splitIdx = raw.indexOf(' -- ');
88
+ sepLen = 4;
89
+ }
90
+ if (splitIdx < 0) {
91
+ const dashMatch = / - /.exec(raw);
92
+ if (dashMatch !== null) {
93
+ splitIdx = dashMatch.index;
94
+ sepLen = 3;
95
+ }
96
+ }
97
+ if (splitIdx < 0) {
98
+ return { title: raw.trim() };
99
+ }
100
+ const title = raw.slice(0, splitIdx).trim();
101
+ const locationRaw = raw.slice(splitIdx + sepLen).trim();
102
+ // `path/to/file.ts:42` or `path/to/file.ts:42-48` or bare `path/to/file.ts`.
103
+ const locMatch = /^([^\s:]+?)(?::(\d+)(?:-\d+)?)?$/.exec(locationRaw);
104
+ if (locMatch === null) {
105
+ // Location we can't parse — keep the whole thing as the title so we
106
+ // don't silently drop it.
107
+ return { title: raw.trim() };
108
+ }
109
+ const file = locMatch[1];
110
+ const lineStr = locMatch[2];
111
+ const result = { title };
112
+ if (file !== undefined && file.length > 0)
113
+ result.file = file;
114
+ if (lineStr !== undefined && lineStr.length > 0) {
115
+ const n = Number.parseInt(lineStr, 10);
116
+ if (Number.isFinite(n) && n > 0)
117
+ result.line = n;
118
+ }
119
+ return result;
120
+ }
121
+ /**
122
+ * Map a finding array to a single verdict. Safe-fail order: any P1 wins,
123
+ * then any P2, else pass.
124
+ */
125
+ export function inferVerdict(findings) {
126
+ if (findings.some((f) => f.severity === 'P1'))
127
+ return 'blocking';
128
+ if (findings.some((f) => f.severity === 'P2'))
129
+ return 'concerns';
130
+ return 'pass';
131
+ }
132
+ /**
133
+ * Convenience: parse + infer in one call.
134
+ */
135
+ export function summarizeReview(reviewText) {
136
+ const findings = parseFindings(reviewText);
137
+ return {
138
+ verdict: inferVerdict(findings),
139
+ findings,
140
+ reviewText,
141
+ };
142
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * HALT kill-switch reader for the push-gate.
3
+ *
4
+ * The push-gate is a pure composition (see `./index.ts`). `readHalt()` is the
5
+ * only side-effectful probe that must run before policy is even consulted —
6
+ * HALT overrides every other signal, including `review.codex_required: false`.
7
+ *
8
+ * `.rea/HALT` is a short plain-text file. Content is not structured — the
9
+ * first non-empty line is the "reason" we surface to the operator. Absence
10
+ * of the file means "not halted"; presence means "block every gated
11
+ * operation until `rea unfreeze`".
12
+ */
13
+ export interface HaltState {
14
+ halted: boolean;
15
+ /** Present only when `halted === true`. Trimmed first line. */
16
+ reason?: string;
17
+ }
18
+ /**
19
+ * Read `.rea/HALT` from `baseDir`. Never throws — filesystem errors collapse
20
+ * to `{ halted: false }` so a corrupted read does not silently block the
21
+ * operator. The fail-closed posture lives in the caller (`runPushGate`) when
22
+ * the gate is asked to assess HALT and cannot.
23
+ *
24
+ * We explicitly do NOT reuse `src/cli/freeze.ts`'s reader — that one prompts
25
+ * via clack for unfreeze confirmation. The hook path must stay dependency-
26
+ * free and deterministic.
27
+ */
28
+ export declare function readHalt(baseDir: string): HaltState;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * HALT kill-switch reader for the push-gate.
3
+ *
4
+ * The push-gate is a pure composition (see `./index.ts`). `readHalt()` is the
5
+ * only side-effectful probe that must run before policy is even consulted —
6
+ * HALT overrides every other signal, including `review.codex_required: false`.
7
+ *
8
+ * `.rea/HALT` is a short plain-text file. Content is not structured — the
9
+ * first non-empty line is the "reason" we surface to the operator. Absence
10
+ * of the file means "not halted"; presence means "block every gated
11
+ * operation until `rea unfreeze`".
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ /**
16
+ * Read `.rea/HALT` from `baseDir`. Never throws — filesystem errors collapse
17
+ * to `{ halted: false }` so a corrupted read does not silently block the
18
+ * operator. The fail-closed posture lives in the caller (`runPushGate`) when
19
+ * the gate is asked to assess HALT and cannot.
20
+ *
21
+ * We explicitly do NOT reuse `src/cli/freeze.ts`'s reader — that one prompts
22
+ * via clack for unfreeze confirmation. The hook path must stay dependency-
23
+ * free and deterministic.
24
+ */
25
+ export function readHalt(baseDir) {
26
+ const p = path.join(baseDir, '.rea', 'HALT');
27
+ if (!fs.existsSync(p)) {
28
+ return { halted: false };
29
+ }
30
+ let raw;
31
+ try {
32
+ raw = fs.readFileSync(p, 'utf8');
33
+ }
34
+ catch {
35
+ // Unreadable HALT file is treated as "halted with unknown reason" — the
36
+ // file exists, so the operator intended to halt; we just can't read the
37
+ // message. Surfacing a generic reason preserves the kill-switch
38
+ // semantics without silently passing.
39
+ return { halted: true, reason: 'unknown (HALT file unreadable)' };
40
+ }
41
+ const firstLine = raw
42
+ .split(/\r?\n/)
43
+ .map((l) => l.trim())
44
+ .find((l) => l.length > 0);
45
+ return {
46
+ halted: true,
47
+ reason: firstLine !== undefined && firstLine.length > 0 ? firstLine : 'unknown',
48
+ };
49
+ }