@bookedsolid/rea 0.10.2 → 0.11.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 (66) hide show
  1. package/.husky/pre-push +22 -167
  2. package/agents/codex-adversarial.md +5 -3
  3. package/commands/codex-review.md +3 -5
  4. package/dist/audit/append.d.ts +7 -32
  5. package/dist/audit/append.js +7 -35
  6. package/dist/cli/audit.d.ts +0 -31
  7. package/dist/cli/audit.js +5 -74
  8. package/dist/cli/doctor.js +6 -16
  9. package/dist/cli/hook.d.ts +48 -0
  10. package/dist/cli/hook.js +127 -0
  11. package/dist/cli/index.js +5 -80
  12. package/dist/cli/init.js +1 -1
  13. package/dist/cli/install/gitignore.d.ts +2 -2
  14. package/dist/cli/install/gitignore.js +3 -3
  15. package/dist/cli/install/pre-push.d.ts +146 -271
  16. package/dist/cli/install/pre-push.js +471 -2633
  17. package/dist/cli/install/settings-merge.d.ts +17 -0
  18. package/dist/cli/install/settings-merge.js +48 -1
  19. package/dist/cli/upgrade.js +131 -3
  20. package/dist/config/tier-map.js +18 -25
  21. package/dist/hooks/push-gate/base.d.ts +57 -0
  22. package/dist/hooks/push-gate/base.js +77 -0
  23. package/dist/hooks/push-gate/codex-runner.d.ts +126 -0
  24. package/dist/hooks/push-gate/codex-runner.js +223 -0
  25. package/dist/hooks/push-gate/findings.d.ts +68 -0
  26. package/dist/hooks/push-gate/findings.js +142 -0
  27. package/dist/hooks/push-gate/halt.d.ts +28 -0
  28. package/dist/hooks/push-gate/halt.js +49 -0
  29. package/dist/hooks/push-gate/index.d.ts +90 -0
  30. package/dist/hooks/push-gate/index.js +351 -0
  31. package/dist/hooks/push-gate/policy.d.ts +41 -0
  32. package/dist/hooks/push-gate/policy.js +55 -0
  33. package/dist/hooks/push-gate/report.d.ts +89 -0
  34. package/dist/hooks/push-gate/report.js +140 -0
  35. package/dist/policy/loader.d.ts +10 -10
  36. package/dist/policy/loader.js +7 -6
  37. package/dist/policy/types.d.ts +31 -22
  38. package/package.json +1 -1
  39. package/dist/cache/review-cache.d.ts +0 -115
  40. package/dist/cache/review-cache.js +0 -200
  41. package/dist/cli/cache.d.ts +0 -84
  42. package/dist/cli/cache.js +0 -150
  43. package/dist/hooks/review-gate/args.d.ts +0 -126
  44. package/dist/hooks/review-gate/args.js +0 -315
  45. package/dist/hooks/review-gate/banner.d.ts +0 -97
  46. package/dist/hooks/review-gate/banner.js +0 -172
  47. package/dist/hooks/review-gate/cache-key.d.ts +0 -55
  48. package/dist/hooks/review-gate/cache-key.js +0 -41
  49. package/dist/hooks/review-gate/constants.d.ts +0 -26
  50. package/dist/hooks/review-gate/constants.js +0 -34
  51. package/dist/hooks/review-gate/errors.d.ts +0 -72
  52. package/dist/hooks/review-gate/errors.js +0 -100
  53. package/dist/hooks/review-gate/hash.d.ts +0 -43
  54. package/dist/hooks/review-gate/hash.js +0 -46
  55. package/dist/hooks/review-gate/index.d.ts +0 -21
  56. package/dist/hooks/review-gate/index.js +0 -21
  57. package/dist/hooks/review-gate/metadata.d.ts +0 -98
  58. package/dist/hooks/review-gate/metadata.js +0 -158
  59. package/dist/hooks/review-gate/policy.d.ts +0 -55
  60. package/dist/hooks/review-gate/policy.js +0 -71
  61. package/dist/hooks/review-gate/protected-paths.d.ts +0 -46
  62. package/dist/hooks/review-gate/protected-paths.js +0 -76
  63. package/hooks/_lib/push-review-core.sh +0 -1250
  64. package/hooks/commit-review-gate.sh +0 -330
  65. package/hooks/push-review-gate-git.sh +0 -94
  66. package/hooks/push-review-gate.sh +0 -92
@@ -41,6 +41,23 @@ export interface MergeResult {
41
41
  * hooks and returns the merged settings plus a list of warnings. Does NOT
42
42
  * touch disk.
43
43
  */
44
+ /**
45
+ * Remove hook entries whose `command` contains any of the listed
46
+ * substrings. Returns a deep-cloned settings object plus the count of
47
+ * entries removed. Empty hook arrays and empty groups are pruned too so
48
+ * the resulting file doesn't accumulate empty sentinel objects after
49
+ * repeated upgrade runs.
50
+ *
51
+ * Used by `rea upgrade` to purge references to hooks we delete in a
52
+ * release (0.11.0 removed `push-review-gate.sh` and `commit-review-gate.sh`).
53
+ * Without this, `mergeSettings()` is additive-only and consumer
54
+ * `.claude/settings.json` would keep executing stale commands that
55
+ * point at files we just deleted from `.claude/hooks/`.
56
+ */
57
+ export declare function pruneHookCommands(existing: Record<string, unknown>, staleSubstrings: readonly string[]): {
58
+ merged: Record<string, unknown>;
59
+ removedCount: number;
60
+ };
44
61
  export declare function mergeSettings(existing: Record<string, unknown>, desired: DesiredHookGroup[]): MergeResult;
45
62
  /**
46
63
  * Atomic write via tmp-file + rename.
@@ -39,6 +39,54 @@ function keyFor(matcher, command) {
39
39
  * hooks and returns the merged settings plus a list of warnings. Does NOT
40
40
  * touch disk.
41
41
  */
42
+ /**
43
+ * Remove hook entries whose `command` contains any of the listed
44
+ * substrings. Returns a deep-cloned settings object plus the count of
45
+ * entries removed. Empty hook arrays and empty groups are pruned too so
46
+ * the resulting file doesn't accumulate empty sentinel objects after
47
+ * repeated upgrade runs.
48
+ *
49
+ * Used by `rea upgrade` to purge references to hooks we delete in a
50
+ * release (0.11.0 removed `push-review-gate.sh` and `commit-review-gate.sh`).
51
+ * Without this, `mergeSettings()` is additive-only and consumer
52
+ * `.claude/settings.json` would keep executing stale commands that
53
+ * point at files we just deleted from `.claude/hooks/`.
54
+ */
55
+ export function pruneHookCommands(existing, staleSubstrings) {
56
+ const merged = deepClone(existing);
57
+ const hooks = ensureHooksShape(merged);
58
+ let removedCount = 0;
59
+ for (const event of Object.keys(hooks)) {
60
+ const groups = hooks[event];
61
+ if (!Array.isArray(groups))
62
+ continue;
63
+ for (const group of groups) {
64
+ const entries = group.hooks ?? [];
65
+ if (!Array.isArray(entries))
66
+ continue;
67
+ const kept = [];
68
+ for (const entry of entries) {
69
+ const cmd = typeof entry.command === 'string' ? entry.command : '';
70
+ if (staleSubstrings.some((s) => cmd.includes(s))) {
71
+ removedCount += 1;
72
+ continue;
73
+ }
74
+ kept.push(entry);
75
+ }
76
+ group.hooks = kept;
77
+ }
78
+ // Drop groups whose hooks list is now empty.
79
+ hooks[event] = groups.filter((g) => Array.isArray(g.hooks) && g.hooks.length > 0);
80
+ }
81
+ // Drop events whose group list is now empty.
82
+ for (const event of Object.keys(hooks)) {
83
+ const groups = hooks[event];
84
+ if (Array.isArray(groups) && groups.length === 0) {
85
+ delete hooks[event];
86
+ }
87
+ }
88
+ return { merged, removedCount };
89
+ }
42
90
  export function mergeSettings(existing, desired) {
43
91
  const merged = deepClone(existing);
44
92
  const hooks = ensureHooksShape(merged);
@@ -215,7 +263,6 @@ export function defaultDesiredHooks() {
215
263
  { type: 'command', command: `${base}/security-disclosure-gate.sh`, timeout: 5000, statusMessage: 'Checking disclosure policy...' },
216
264
  { type: 'command', command: `${base}/pr-issue-link-gate.sh`, timeout: 5000, statusMessage: 'Checking PR for issue reference...' },
217
265
  { type: 'command', command: `${base}/attribution-advisory.sh`, timeout: 5000, statusMessage: 'Checking for AI attribution...' },
218
- { type: 'command', command: `${base}/push-review-gate.sh`, timeout: 30000, statusMessage: 'Running push review gate...' },
219
266
  ],
220
267
  },
221
268
  {
@@ -44,11 +44,103 @@ import { loadPolicy } from '../policy/loader.js';
44
44
  import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
45
45
  import { buildFragment, extractFragment, } from './install/claude-md.js';
46
46
  import { atomicReplaceFile, safeDeleteFile, safeInstallFile, safeReadFile, } from './install/fs-safe.js';
47
- import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
47
+ import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, pruneHookCommands, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
48
48
  import { ensureReaGitignore } from './install/gitignore.js';
49
49
  import { manifestExists, readManifest, writeManifestAtomic, } from './install/manifest-io.js';
50
50
  import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
51
51
  import { err, getPkgVersion, log, warn } from './utils.js';
52
+ // ---------------------------------------------------------------------------
53
+ // 0.11.0 policy migration
54
+ // ---------------------------------------------------------------------------
55
+ /**
56
+ * Strip 0.10.x review.* fields that 0.11.0 rejects at schema load, and
57
+ * backfill `concerns_blocks: true` if the `review:` block exists without
58
+ * it. Writes a timestamped backup to `.rea/policy.yaml.bak-<ts>` before
59
+ * mutating.
60
+ *
61
+ * Surgical text rewrite, not YAML round-trip. YAML round-tripping loses
62
+ * comments and reformats quoting; operators care about their comments.
63
+ * Line-by-line deletion of the two removed keys keeps every other line
64
+ * byte-identical. This is safe because the keys we strip are simple
65
+ * scalars (`cache_max_age_seconds: 3600`, `allow_skip_in_ci: false`) that
66
+ * cannot span multiple lines in any reasonable policy file.
67
+ */
68
+ async function migrateReviewPolicyFor0110(resolvedRoot, opts) {
69
+ const policyPath = path.join(resolvedRoot, '.rea', 'policy.yaml');
70
+ let raw;
71
+ try {
72
+ raw = await fsPromises.readFile(policyPath, 'utf8');
73
+ }
74
+ catch {
75
+ return; // no policy to migrate
76
+ }
77
+ const lines = raw.split(/\r?\n/);
78
+ const REVIEW_RE = /^review:\s*(?:#.*)?$/;
79
+ const REMOVED_KEY_RE = /^(\s+)(cache_max_age_seconds|allow_skip_in_ci):\s*\S.*$/;
80
+ const INDENT_RE = /^(\s+)\S/;
81
+ let reviewStart = -1;
82
+ let reviewIndent = '';
83
+ const linesToDrop = new Set();
84
+ let hasConcernsBlocks = false;
85
+ let reviewChildIndent = '';
86
+ for (let i = 0; i < lines.length; i += 1) {
87
+ const line = lines[i] ?? '';
88
+ if (reviewStart < 0 && REVIEW_RE.test(line.trimEnd())) {
89
+ reviewStart = i;
90
+ const indentMatch = INDENT_RE.exec(line);
91
+ reviewIndent = indentMatch?.[1] ?? '';
92
+ continue;
93
+ }
94
+ if (reviewStart < 0)
95
+ continue;
96
+ // Inside the review: block as long as indent is deeper than reviewIndent.
97
+ if (line.trim() === '')
98
+ continue;
99
+ const indentMatch = INDENT_RE.exec(line);
100
+ const lineIndent = indentMatch?.[1] ?? '';
101
+ if (lineIndent.length <= reviewIndent.length) {
102
+ // Left the block.
103
+ break;
104
+ }
105
+ if (reviewChildIndent.length === 0)
106
+ reviewChildIndent = lineIndent;
107
+ if (/^\s+concerns_blocks:/.test(line))
108
+ hasConcernsBlocks = true;
109
+ if (REMOVED_KEY_RE.test(line)) {
110
+ linesToDrop.add(i);
111
+ }
112
+ }
113
+ const addConcernsBlocks = reviewStart >= 0 && !hasConcernsBlocks;
114
+ if (linesToDrop.size === 0 && !addConcernsBlocks)
115
+ return;
116
+ const newLines = [];
117
+ for (let i = 0; i < lines.length; i += 1) {
118
+ if (linesToDrop.has(i))
119
+ continue;
120
+ newLines.push(lines[i] ?? '');
121
+ // Append `concerns_blocks: true` right after the `review:` header line
122
+ // when the block exists but lacks the key.
123
+ if (i === reviewStart && addConcernsBlocks) {
124
+ const indent = reviewChildIndent.length > 0 ? reviewChildIndent : reviewIndent + ' ';
125
+ newLines.push(`${indent}concerns_blocks: true`);
126
+ }
127
+ }
128
+ const updated = newLines.join('\n');
129
+ const droppedSummary = [];
130
+ if (linesToDrop.size > 0)
131
+ droppedSummary.push(`dropped ${linesToDrop.size} removed-field line(s)`);
132
+ if (addConcernsBlocks)
133
+ droppedSummary.push('added concerns_blocks: true');
134
+ if (opts.dryRun) {
135
+ log(`[dry-run] would migrate .rea/policy.yaml: ${droppedSummary.join(', ')}`);
136
+ return;
137
+ }
138
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
139
+ const backupPath = `${policyPath}.bak-${ts}`;
140
+ await fsPromises.copyFile(policyPath, backupPath);
141
+ await atomicReplaceFile(policyPath, updated);
142
+ warn(`migrated .rea/policy.yaml for 0.11.0 (${droppedSummary.join(', ')}); backup at ${path.basename(backupPath)}`);
143
+ }
52
144
  /**
53
145
  * Hard cap for `showDiff` reads. Canonical files are all tiny (<64KB) but a
54
146
  * consumer could have replaced a hook with a 500MB log; refuse to slurp the
@@ -282,19 +374,50 @@ async function upgradeClaudeMdFragment(resolvedRoot, opts) {
282
374
  await atomicReplaceFile(claudeMdPath, next);
283
375
  return { sha: newSha, action: 'written' };
284
376
  }
377
+ /**
378
+ * Hook commands deleted in 0.11.0. `upgradeSettings` prunes any entries
379
+ * whose `command` string contains one of these tokens so consumer
380
+ * `.claude/settings.json` files don't keep invoking missing scripts
381
+ * (which would fail every matched tool call until the operator edited
382
+ * settings by hand).
383
+ *
384
+ * 0.11.0 removals:
385
+ * - push-review-gate.sh (replaced by husky stub → rea hook push-gate)
386
+ * - commit-review-gate.sh (intentionally unregistered; source-of-truth deleted)
387
+ * - push-review-gate-git.sh (native-git adapter; deleted)
388
+ *
389
+ * Add future removals here rather than baking the list into
390
+ * `pruneHookCommands` itself — the removal list is release history,
391
+ * not a static setting.
392
+ */
393
+ const STALE_HOOK_COMMAND_TOKENS = [
394
+ 'push-review-gate.sh',
395
+ 'commit-review-gate.sh',
396
+ 'push-review-gate-git.sh',
397
+ ];
285
398
  async function upgradeSettings(baseDir, opts) {
286
399
  const desired = defaultDesiredHooks();
287
400
  const sha = canonicalSettingsSubsetHash(desired);
288
401
  const { settings, settingsPath } = readSettings(baseDir);
289
- const mergeResult = mergeSettings(settings, desired);
402
+ // PRUNE FIRST (remove 0.11.0-deleted hook references), THEN MERGE
403
+ // (add any new hooks). Order matters: merging first would re-add the
404
+ // stale entry only to have the prune re-delete it on the next line —
405
+ // pointless work. Pruning first means the merge sees a clean baseline.
406
+ const pruned = pruneHookCommands(settings, STALE_HOOK_COMMAND_TOKENS);
407
+ const mergeResult = mergeSettings(pruned.merged, desired);
290
408
  if (opts.dryRun !== true) {
291
409
  await writeSettingsAtomic(settingsPath, mergeResult.merged);
292
410
  }
411
+ const warnings = [...mergeResult.warnings];
412
+ if (pruned.removedCount > 0) {
413
+ warnings.push(`pruned ${pruned.removedCount} stale 0.10.x hook entr${pruned.removedCount === 1 ? 'y' : 'ies'} from .claude/settings.json (removed in 0.11.0)`);
414
+ }
293
415
  return {
294
416
  sha,
295
417
  addedCount: mergeResult.addedCount,
296
418
  skippedCount: mergeResult.skippedCount,
297
- warnings: mergeResult.warnings,
419
+ removedCount: pruned.removedCount,
420
+ warnings,
298
421
  };
299
422
  }
300
423
  /** Re-hash a file we just wrote. Source and on-disk bytes should match, but
@@ -321,6 +444,11 @@ export async function runUpgrade(options = {}) {
321
444
  if (options.force === true && !dryRun) {
322
445
  warn('--force: overwriting locally-modified files and deleting removed-upstream entries without prompt.');
323
446
  }
447
+ // 0.11.0 migration — strip removed review.* fields and backfill the new
448
+ // concerns_blocks default. Runs before canonical file reconciliation so a
449
+ // policy that fails strict schema load (which happens on upgrade from
450
+ // 0.10.x the moment we re-read `.rea/policy.yaml`) is cleaned up first.
451
+ await migrateReviewPolicyFor0110(resolvedRoot, { dryRun });
324
452
  const canonicalFiles = await enumerateCanonicalFiles();
325
453
  if (canonicalFiles.length === 0) {
326
454
  err('no canonical files found in package — is the build complete?');
@@ -110,28 +110,20 @@ export function isToolBlocked(toolName, serverName, gatewayConfig) {
110
110
  * Classify a `rea <subcommand>` Bash invocation by its own semantics rather
111
111
  * than the generic Bash default.
112
112
  *
113
- * Defect E (rea#78): REA's own governance CLI must not be denied by REA's own
114
- * middleware. The gate's error messages literally say "Run `rea cache set
115
- * <sha> pass --branch <x> --base <y>`" then the agent is denied at autonomy
116
- * L1 because `Bash` is classified Write and the downstream middleware can't
117
- * see that the Write is just appending a line to `.rea/review-cache.jsonl`.
113
+ * REA's own governance CLI must not be denied by REA's own middleware. This
114
+ * helper returns the tier appropriate to the rea subcommand when the command
115
+ * parses as `rea <sub>` or `npx rea <sub>`. Returns `null` if the command is
116
+ * not a rea invocation callers then fall back to the generic Bash tier.
118
117
  *
119
- * This helper returns the tier appropriate to the rea subcommand when the
120
- * command parses as `rea <sub>` or `npx rea <sub>`. Returns `null` if the
121
- * command is not a rea invocation callers then fall back to the generic
122
- * Bash tier.
123
- *
124
- * Tier mapping:
125
- * - Read: `cache check|list|get`, `audit verify`,
126
- * `audit record codex-review`, `check`, `doctor`, `status`
127
- * - Write: `cache set|clear`, `audit rotate`, `init`,
128
- * `serve`, `upgrade`, `unfreeze`
118
+ * Tier mapping (0.11.0):
119
+ * - Read: `audit verify`, `check`, `doctor`, `status`,
120
+ * `hook push-gate` (exec-onlyno state mutation)
121
+ * - Write: `audit rotate`, `init`, `serve`, `upgrade`, `unfreeze`
129
122
  * - Destructive: `freeze` (writes `.rea/HALT`, suspends the session)
130
123
  *
131
- * `audit record codex-review` is Read-tier because it is REA's own append-only
132
- * audit surface the whole point of the command is to let an L1 agent satisfy
133
- * the push-review gate without a human in the loop. Write-tier here would
134
- * reintroduce exactly the deadlock Defect D/E close.
124
+ * `rea cache` and `rea audit record codex-review` were removed in 0.11.0
125
+ * the stateless push-gate needs neither. Any lingering reference to those
126
+ * subcommands falls through to `Tier.Write` (safe default).
135
127
  *
136
128
  * SECURITY: returns `null` for any command containing shell metacharacters
137
129
  * that would let an attacker piggyback arbitrary commands onto an allowed
@@ -275,18 +267,19 @@ export function reaCommandTier(command) {
275
267
  case 'doctor':
276
268
  case 'status':
277
269
  return Tier.Read;
278
- case 'cache': {
279
- if (sub2 === 'check' || sub2 === 'list' || sub2 === 'get')
270
+ case 'hook': {
271
+ // `rea hook push-gate` is execution-only it runs codex exec review
272
+ // and writes `.rea/last-review.json` + an audit record, but the
273
+ // user-visible effect is an exit code. Classify Read so the pre-push
274
+ // hook is not blocked at L1 (symmetric to the 0.10.x reasoning for
275
+ // `audit record codex-review`).
276
+ if (sub2 === 'push-gate')
280
277
  return Tier.Read;
281
- if (sub2 === 'set' || sub2 === 'clear')
282
- return Tier.Write;
283
278
  return Tier.Write;
284
279
  }
285
280
  case 'audit': {
286
281
  if (sub2 === 'verify')
287
282
  return Tier.Read;
288
- if (sub2 === 'record')
289
- return Tier.Read;
290
283
  if (sub2 === 'rotate')
291
284
  return Tier.Write;
292
285
  return Tier.Write;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Base-branch resolution for the push-gate.
3
+ *
4
+ * Codex `exec review --base <ref>` needs a git ref to diff against. The
5
+ * caller may provide one via `rea hook push-gate --base <ref>`; if they
6
+ * don't, we resolve it in priority order:
7
+ *
8
+ * 1. Current branch's upstream (`@{upstream}`) — the branch the operator
9
+ * configured as their tracking ref. Most accurate since it matches
10
+ * what `git push` is about to compare against.
11
+ * 2. `origin/HEAD` symbolic ref (e.g. `refs/remotes/origin/main`). Set
12
+ * automatically by `git clone` and `git remote set-head`.
13
+ * 3. Explicit probes: `origin/main` → `origin/master` via rev-parse.
14
+ * 4. Local `main` / `master` — last resort when the clone has no remote
15
+ * tracking refs yet (freshly initialized sibling project, mirror
16
+ * clone).
17
+ *
18
+ * On total failure we surface the sentinel `empty-tree` SHA
19
+ * (`4b825dc642cb6eb9a060e54bf8d69288fbee4904`) so the diff covers every
20
+ * file in HEAD. First pushes to a fresh remote still get reviewed — the
21
+ * 0.10.x fail-open at this resolution step was defect J.
22
+ *
23
+ * All git commands are invoked via the injected `GitExecutor` — tests
24
+ * replace it with a fake to avoid shelling out.
25
+ */
26
+ import type { GitExecutor } from './codex-runner.js';
27
+ export interface BaseResolution {
28
+ /**
29
+ * Resolved ref (branch, remote-tracking ref, or 40-char SHA). Always
30
+ * usable as the `--base` argument to `codex exec review`.
31
+ */
32
+ ref: string;
33
+ /** Where the ref came from — surfaces in audit records and stderr. */
34
+ source: 'explicit' | 'upstream' | 'origin-head' | 'origin-main' | 'origin-master' | 'local-main' | 'local-master' | 'empty-tree';
35
+ }
36
+ /**
37
+ * Well-known empty-tree SHA: `git hash-object -t tree /dev/null`. Every git
38
+ * installation carries this object implicitly. Using it as a fallback lets
39
+ * a review on a clone with no tracking refs still exercise the entire HEAD
40
+ * tree diff.
41
+ */
42
+ export declare const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
43
+ export interface ResolveBaseOptions {
44
+ /**
45
+ * Explicit ref from `--base <ref>` or equivalent. When set, the resolver
46
+ * returns it unchanged with `source: 'explicit'` — the caller is
47
+ * responsible for its correctness. We do NOT validate that it exists;
48
+ * that's Codex's job (it'll error clearly if the ref is bad).
49
+ */
50
+ explicit?: string;
51
+ }
52
+ /**
53
+ * Resolve the base ref using the configured priority order. Never throws —
54
+ * every failure mode degrades to the next step, and the worst case (no
55
+ * tracking refs, no local main/master) is `empty-tree`.
56
+ */
57
+ export declare function resolveBaseRef(git: GitExecutor, options?: ResolveBaseOptions): BaseResolution;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Base-branch resolution for the push-gate.
3
+ *
4
+ * Codex `exec review --base <ref>` needs a git ref to diff against. The
5
+ * caller may provide one via `rea hook push-gate --base <ref>`; if they
6
+ * don't, we resolve it in priority order:
7
+ *
8
+ * 1. Current branch's upstream (`@{upstream}`) — the branch the operator
9
+ * configured as their tracking ref. Most accurate since it matches
10
+ * what `git push` is about to compare against.
11
+ * 2. `origin/HEAD` symbolic ref (e.g. `refs/remotes/origin/main`). Set
12
+ * automatically by `git clone` and `git remote set-head`.
13
+ * 3. Explicit probes: `origin/main` → `origin/master` via rev-parse.
14
+ * 4. Local `main` / `master` — last resort when the clone has no remote
15
+ * tracking refs yet (freshly initialized sibling project, mirror
16
+ * clone).
17
+ *
18
+ * On total failure we surface the sentinel `empty-tree` SHA
19
+ * (`4b825dc642cb6eb9a060e54bf8d69288fbee4904`) so the diff covers every
20
+ * file in HEAD. First pushes to a fresh remote still get reviewed — the
21
+ * 0.10.x fail-open at this resolution step was defect J.
22
+ *
23
+ * All git commands are invoked via the injected `GitExecutor` — tests
24
+ * replace it with a fake to avoid shelling out.
25
+ */
26
+ /**
27
+ * Well-known empty-tree SHA: `git hash-object -t tree /dev/null`. Every git
28
+ * installation carries this object implicitly. Using it as a fallback lets
29
+ * a review on a clone with no tracking refs still exercise the entire HEAD
30
+ * tree diff.
31
+ */
32
+ export const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
33
+ /**
34
+ * Resolve the base ref using the configured priority order. Never throws —
35
+ * every failure mode degrades to the next step, and the worst case (no
36
+ * tracking refs, no local main/master) is `empty-tree`.
37
+ */
38
+ export function resolveBaseRef(git, options = {}) {
39
+ if (options.explicit !== undefined && options.explicit.length > 0) {
40
+ return { ref: options.explicit, source: 'explicit' };
41
+ }
42
+ // 1. Upstream of current branch. `@{upstream}` resolves to the configured
43
+ // tracking ref (typically `refs/remotes/origin/<branch>`). Returns
44
+ // empty on branches without an upstream — which is normal for a brand
45
+ // new feature branch; fall through.
46
+ const upstream = git
47
+ .tryRevParse(['--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
48
+ .trim();
49
+ if (upstream.length > 0) {
50
+ return { ref: upstream, source: 'upstream' };
51
+ }
52
+ // 2. origin/HEAD symbolic ref. Set by `git clone` to point at the remote's
53
+ // default branch. `git symbolic-ref` returns the full ref path; we
54
+ // hand that to Codex directly (it's `refs/remotes/origin/<name>`).
55
+ const originHead = git.trySymbolicRef('refs/remotes/origin/HEAD').trim();
56
+ if (originHead.length > 0) {
57
+ return { ref: originHead, source: 'origin-head' };
58
+ }
59
+ // 3. Explicit probes for the two most common default-branch names.
60
+ if (git.tryRevParse(['--verify', '--quiet', 'refs/remotes/origin/main']).length > 0) {
61
+ return { ref: 'refs/remotes/origin/main', source: 'origin-main' };
62
+ }
63
+ if (git.tryRevParse(['--verify', '--quiet', 'refs/remotes/origin/master']).length > 0) {
64
+ return { ref: 'refs/remotes/origin/master', source: 'origin-master' };
65
+ }
66
+ // 4. Local branches. `main` and `master` without `refs/remotes/` prefix
67
+ // resolve via `refs/heads/`. Order matches priority 3.
68
+ if (git.tryRevParse(['--verify', '--quiet', 'refs/heads/main']).length > 0) {
69
+ return { ref: 'refs/heads/main', source: 'local-main' };
70
+ }
71
+ if (git.tryRevParse(['--verify', '--quiet', 'refs/heads/master']).length > 0) {
72
+ return { ref: 'refs/heads/master', source: 'local-master' };
73
+ }
74
+ // 5. Last resort: diff against the empty-tree SHA. Covers every file in
75
+ // HEAD; expensive for large repos but correct.
76
+ return { ref: EMPTY_TREE_SHA, source: 'empty-tree' };
77
+ }
@@ -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;