@bookedsolid/rea 0.3.0 → 0.5.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 (64) hide show
  1. package/.husky/pre-push +15 -18
  2. package/README.md +41 -1
  3. package/dist/cache/review-cache.d.ts +115 -0
  4. package/dist/cache/review-cache.js +200 -0
  5. package/dist/cli/cache.d.ts +52 -0
  6. package/dist/cli/cache.js +112 -0
  7. package/dist/cli/doctor.d.ts +19 -4
  8. package/dist/cli/doctor.js +172 -5
  9. package/dist/cli/index.js +50 -1
  10. package/dist/cli/init.js +109 -7
  11. package/dist/cli/install/gitignore.d.ts +114 -0
  12. package/dist/cli/install/gitignore.js +356 -0
  13. package/dist/cli/install/pre-push.d.ts +335 -0
  14. package/dist/cli/install/pre-push.js +2818 -0
  15. package/dist/cli/serve.d.ts +64 -0
  16. package/dist/cli/serve.js +270 -2
  17. package/dist/cli/status.d.ts +90 -0
  18. package/dist/cli/status.js +399 -0
  19. package/dist/cli/upgrade.js +20 -0
  20. package/dist/cli/utils.d.ts +4 -0
  21. package/dist/cli/utils.js +4 -0
  22. package/dist/gateway/circuit-breaker.d.ts +17 -0
  23. package/dist/gateway/circuit-breaker.js +32 -3
  24. package/dist/gateway/downstream-pool.d.ts +2 -1
  25. package/dist/gateway/downstream-pool.js +2 -2
  26. package/dist/gateway/downstream.d.ts +39 -3
  27. package/dist/gateway/downstream.js +73 -14
  28. package/dist/gateway/log.d.ts +122 -0
  29. package/dist/gateway/log.js +334 -0
  30. package/dist/gateway/middleware/audit.d.ts +10 -1
  31. package/dist/gateway/middleware/audit.js +26 -1
  32. package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
  33. package/dist/gateway/middleware/blocked-paths.js +439 -67
  34. package/dist/gateway/middleware/injection.d.ts +218 -13
  35. package/dist/gateway/middleware/injection.js +433 -51
  36. package/dist/gateway/middleware/kill-switch.d.ts +10 -1
  37. package/dist/gateway/middleware/kill-switch.js +20 -1
  38. package/dist/gateway/observability/metrics.d.ts +125 -0
  39. package/dist/gateway/observability/metrics.js +321 -0
  40. package/dist/gateway/server.d.ts +19 -0
  41. package/dist/gateway/server.js +99 -15
  42. package/dist/policy/loader.d.ts +23 -0
  43. package/dist/policy/loader.js +30 -0
  44. package/dist/policy/profiles.d.ts +13 -0
  45. package/dist/policy/profiles.js +12 -0
  46. package/dist/policy/types.d.ts +48 -0
  47. package/dist/registry/fingerprint.d.ts +73 -0
  48. package/dist/registry/fingerprint.js +81 -0
  49. package/dist/registry/fingerprints-store.d.ts +62 -0
  50. package/dist/registry/fingerprints-store.js +111 -0
  51. package/dist/registry/interpolate.d.ts +58 -0
  52. package/dist/registry/interpolate.js +121 -0
  53. package/dist/registry/loader.d.ts +2 -2
  54. package/dist/registry/loader.js +22 -1
  55. package/dist/registry/tofu-gate.d.ts +41 -0
  56. package/dist/registry/tofu-gate.js +189 -0
  57. package/dist/registry/tofu.d.ts +111 -0
  58. package/dist/registry/tofu.js +173 -0
  59. package/dist/registry/types.d.ts +9 -1
  60. package/hooks/push-review-gate.sh +185 -1
  61. package/package.json +1 -1
  62. package/profiles/bst-internal-no-codex.yaml +5 -0
  63. package/profiles/bst-internal.yaml +7 -0
  64. package/scripts/tarball-smoke.sh +197 -0
package/.husky/pre-push CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/bin/sh
2
+ # rea:husky-pre-push-gate v1
3
+ # rea:gate-body-v1
2
4
  # .husky/pre-push — rea governance gate for terminal-initiated pushes.
3
5
  #
4
6
  # Mirrors the logic of `.claude/hooks/push-review-gate.sh` but consumes the
@@ -20,8 +22,10 @@
20
22
  # which ran the loop in a subshell — `exit 1` inside the loop aborted the
21
23
  # subshell only, and the script then ran `exit 0` and allowed the push. We
22
24
  # now feed the loop with a here-doc so it runs in the main shell, and we
23
- # track `block_push` in the enclosing scope. Final `exit 1` is reached only
24
- # if no refspec is blocked; a single blocking refspec propagates correctly.
25
+ # abort immediately (`exit 1`) on the first blocking refspec. The accumulator
26
+ # pattern (`block_push=1; continue`) was dropped so the text-level detector
27
+ # in `src/cli/install/pre-push.ts` can verify the miss-path is truly blocking
28
+ # without modeling loop-carried flags and post-loop exit blocks.
25
29
 
26
30
  set -eu
27
31
 
@@ -63,13 +67,11 @@ if [ -f "$READ_FIELD_JS" ]; then
63
67
  fi
64
68
  fi
65
69
 
66
- block_push=0
67
-
68
- # Here-doc feeds the loop without creating a subshell, so `block_push=1`
69
- # assignments below persist in the enclosing scope and the final `exit`
70
- # reflects them. A pipeline would run the loop in a subshell and `exit 1`
71
- # inside it would only abort that subshell — NOT the push — which was a
72
- # real governance defect in the pre-review version of this file.
70
+ # Here-doc feeds the loop without creating a subshell, so an `exit 1`
71
+ # inside the loop terminates the hook and blocks the push. A pipeline
72
+ # would run the loop in a subshell and `exit 1` inside it would only
73
+ # abort that subshell NOT the push which was a real governance
74
+ # defect in the pre-review version of this file.
73
75
  while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
74
76
  [ -z "${local_sha:-}" ] && continue
75
77
  # Branch deletion: local_sha is 40 zeros. Skip protected-path check.
@@ -103,26 +105,21 @@ while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
103
105
  if [ ! -f "$AUDIT_LOG" ]; then
104
106
  printf 'PUSH BLOCKED: protected paths changed but no audit log found at %s\n' "$AUDIT_LOG" >&2
105
107
  printf ' Run /codex-review on HEAD %s before pushing.\n' "$local_sha" >&2
106
- block_push=1
107
- continue
108
+ exit 1
108
109
  fi
109
110
  # Require both (a) a `codex.review` tool_name and (b) the exact head_sha
110
111
  # on the same JSONL line. The `codex.review` pattern ends with a closing
111
- # quote, so `codex.review.skipped` never satisfies the gate.
112
+ # quote, so `codex.review.skipped` never satisfies the gate. The first
113
+ # refspec that fails this check aborts the hook — no accumulator needed.
112
114
  if ! grep -E '"tool_name":"codex\.review"' "$AUDIT_LOG" 2>/dev/null | \
113
115
  grep -qF "\"head_sha\":\"$local_sha\""; then
114
116
  printf 'PUSH BLOCKED: protected paths changed — /codex-review required for HEAD %s\n' "$local_sha" >&2
115
117
  printf ' Run /codex-review, or set REA_SKIP_CODEX_REVIEW=<reason> to bypass.\n' >&2
116
- block_push=1
117
- continue
118
+ exit 1
118
119
  fi
119
120
  fi
120
121
  done <<HOOK_INPUT_EOF
121
122
  $INPUT
122
123
  HOOK_INPUT_EOF
123
124
 
124
- if [ "$block_push" -ne 0 ]; then
125
- exit 1
126
- fi
127
-
128
125
  exit 0
package/README.md CHANGED
@@ -66,7 +66,10 @@ to build a separate package that composes with REA.
66
66
  `policy.yaml` is the maximum surface area — one outbound POST, opt-in.
67
67
  - **Not a daemon supervisor.** `rea serve` is started by Claude Code via
68
68
  `.mcp.json`. Claude Code owns the lifecycle. There is no `rea start`,
69
- no `rea stop`, no pid file, no systemd unit.
69
+ no `rea stop`, no systemd unit. A short-lived `.rea/serve.pid`
70
+ breadcrumb is written at startup so `rea status` can detect a live
71
+ gateway — it is removed on graceful shutdown and never used for
72
+ locking or lifecycle management.
70
73
  - **Not a hosted service.** There is no REA Cloud, no SaaS tier, no
71
74
  multi-token workstreams, no workload isolation platform.
72
75
  - **Not a 70-agent roster.** 10 curated agents ship in the package. Four
@@ -132,6 +135,43 @@ install, `.mcp.json` gateway wiring, Codex plugin availability, and the
132
135
  integrity of the audit hash chain. It returns a pass/fail summary with
133
136
  specific remediation hints.
134
137
 
138
+ ### 4. Watch the running gateway
139
+
140
+ ```bash
141
+ rea status # human-readable summary
142
+ rea status --json # JSON — pipe to jq
143
+ ```
144
+
145
+ `rea status` is the live-process view. It reads the pidfile written by
146
+ `rea serve`, verifies the pid is alive, and surfaces the session id,
147
+ policy summary (profile, autonomy, HALT state), and audit stats (lines,
148
+ last timestamp, whether the tail record's hash looks well-formed). Use
149
+ `rea check` when you want the pure on-disk view without probing for a
150
+ live process.
151
+
152
+ ### 5. Optional Prometheus `/metrics` endpoint
153
+
154
+ `rea serve` can expose a loopback-only Prometheus endpoint when the
155
+ `REA_METRICS_PORT` environment variable is set:
156
+
157
+ ```bash
158
+ REA_METRICS_PORT=9464 rea serve
159
+ # in another shell
160
+ curl http://127.0.0.1:9464/metrics
161
+ ```
162
+
163
+ Metrics exposed: per-downstream call and error counters, in-flight
164
+ gauge, audit-lines-appended counter, circuit-breaker state gauge, and a
165
+ seconds-since-last-HALT-check gauge. The listener binds to `127.0.0.1`
166
+ only, serves only `GET /metrics` (everything else is a fixed-body 404),
167
+ and never binds by default — "no silent listeners" is a design rule.
168
+ There is no TLS; scrape through SSH/a reverse proxy if you need
169
+ cross-host access.
170
+
171
+ Set `REA_LOG_LEVEL=debug` for verbose gateway logs; the default is
172
+ `info`. Records are JSON lines on a non-TTY stderr and pretty-printed
173
+ on an interactive terminal.
174
+
135
175
  ## Architecture
136
176
 
137
177
  ### Middleware chain
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Review cache (BUG-009). The push-review-gate hook (`hooks/push-review-gate.sh`)
3
+ * has shipped since 0.3.x calling `rea cache check <sha>` to skip re-review on
4
+ * a previously-approved diff, and `rea cache set <sha> pass ...` as the
5
+ * operator's advertised way to complete the gate. Neither subcommand existed
6
+ * in the CLI through 0.4.0. Once BUG-008's pre-push stdin adapter lands and
7
+ * the gate actually fires, a protected-path push has no completion path
8
+ * without this cache — hence "paired ship blocker."
9
+ *
10
+ * ## File layout
11
+ *
12
+ * `.rea/review-cache.jsonl` — one JSON object per line, terminated with `\n`.
13
+ * Each entry:
14
+ *
15
+ * {
16
+ * "sha": "<diff-sha256>",
17
+ * "branch": "<feature-branch>",
18
+ * "base": "<target-branch>",
19
+ * "result": "pass" | "fail",
20
+ * "recorded_at": "<ISO-8601>",
21
+ * "reason"?: "<free text>" // optional, populated on fail or on skip
22
+ * }
23
+ *
24
+ * The `sha` is whatever the caller supplies — the hook happens to use a
25
+ * SHA-256 of the full diff, but the cache does not interpret or validate the
26
+ * value. Hash-chained is intentionally NOT required: this is a keyed cache,
27
+ * not an append-only integrity log. The audit log at `.rea/audit.jsonl`
28
+ * remains the integrity story.
29
+ *
30
+ * ## Concurrency
31
+ *
32
+ * Every write takes the same `proper-lockfile` lock on the `.rea/` parent
33
+ * directory that the audit helpers use (`withAuditLock`). This means a
34
+ * concurrent audit append and cache write serialize against each other — a
35
+ * negligible cost given cache writes happen once per push gate completion.
36
+ *
37
+ * ## Idempotency
38
+ *
39
+ * `appendEntry` writes a new line unconditionally. `lookup` returns the most
40
+ * recent entry matching `(sha, branch, base)`. This "last write wins" keeps
41
+ * the write path O(1) and the read path O(n) over the file; n is bounded by
42
+ * typical review frequency (dozens per week, not millions). If a future
43
+ * operator needs a compact file, `rea cache clear <sha>` drops matching
44
+ * entries and a separate `rea cache compact` (not in 0.5.0) could rewrite.
45
+ *
46
+ * ## TTL
47
+ *
48
+ * `lookup` honors `review.cache_max_age_seconds` (default 3600). Entries
49
+ * older than the window are treated as a miss. Expired entries are not
50
+ * garbage-collected on read — `rea cache clear` or `rea cache compact`
51
+ * is the operator tool for shrinking.
52
+ */
53
+ /** Default TTL when policy does not supply one. */
54
+ export declare const DEFAULT_CACHE_MAX_AGE_SECONDS = 3600;
55
+ export type CacheResult = 'pass' | 'fail';
56
+ export interface CacheEntry {
57
+ sha: string;
58
+ branch: string;
59
+ base: string;
60
+ result: CacheResult;
61
+ recorded_at: string;
62
+ reason?: string;
63
+ }
64
+ export interface CacheLookupInput {
65
+ sha: string;
66
+ branch: string;
67
+ base: string;
68
+ /** Epoch ms used as the "now" reference for TTL comparison. Defaults to `Date.now()`. */
69
+ nowMs?: number;
70
+ /** TTL in seconds; defaults to {@link DEFAULT_CACHE_MAX_AGE_SECONDS}. */
71
+ maxAgeSeconds?: number;
72
+ }
73
+ export interface CacheLookupResult {
74
+ hit: boolean;
75
+ entry?: CacheEntry;
76
+ /** Reason for a miss. One of `'no-entry' | 'expired' | 'empty-file'`. Always set when `hit === false`. */
77
+ missReason?: 'no-entry' | 'expired' | 'empty-file';
78
+ }
79
+ export interface CacheAppendInput {
80
+ sha: string;
81
+ branch: string;
82
+ base: string;
83
+ result: CacheResult;
84
+ reason?: string;
85
+ /** ISO-8601 timestamp. Defaults to `new Date().toISOString()`. */
86
+ timestamp?: string;
87
+ }
88
+ export declare function resolveCacheFile(baseDir: string): string;
89
+ /**
90
+ * Append an entry to the cache. Writes are serialized through the shared
91
+ * `.rea/` directory lock so audit writes and cache writes do not interleave.
92
+ */
93
+ export declare function appendEntry(baseDir: string, input: CacheAppendInput): Promise<CacheEntry>;
94
+ /**
95
+ * Find the most-recent entry matching `(sha, branch, base)` within the TTL
96
+ * window. Idempotent and side-effect free.
97
+ */
98
+ export declare function lookup(baseDir: string, input: CacheLookupInput): Promise<CacheLookupResult>;
99
+ /**
100
+ * Remove every entry matching `sha`. Returns the count removed. A `0` return
101
+ * is a valid outcome (sha not present). Writes back via the same lock as
102
+ * `appendEntry`, so concurrent sets do not lose entries.
103
+ *
104
+ * Writes use temp-file + `fs.rename` (atomic within a single directory on
105
+ * POSIX) so unlocked readers (`lookup`, `list`) can never observe a torn or
106
+ * empty intermediate state. Codex F4 on the 0.5.0 PR1 review.
107
+ */
108
+ export declare function clear(baseDir: string, sha: string): Promise<number>;
109
+ /**
110
+ * Return every entry, optionally filtered by branch. Entries are returned in
111
+ * file order (oldest first). Callers that want "newest first" should reverse.
112
+ */
113
+ export declare function list(baseDir: string, options?: {
114
+ branch?: string;
115
+ }): Promise<CacheEntry[]>;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Review cache (BUG-009). The push-review-gate hook (`hooks/push-review-gate.sh`)
3
+ * has shipped since 0.3.x calling `rea cache check <sha>` to skip re-review on
4
+ * a previously-approved diff, and `rea cache set <sha> pass ...` as the
5
+ * operator's advertised way to complete the gate. Neither subcommand existed
6
+ * in the CLI through 0.4.0. Once BUG-008's pre-push stdin adapter lands and
7
+ * the gate actually fires, a protected-path push has no completion path
8
+ * without this cache — hence "paired ship blocker."
9
+ *
10
+ * ## File layout
11
+ *
12
+ * `.rea/review-cache.jsonl` — one JSON object per line, terminated with `\n`.
13
+ * Each entry:
14
+ *
15
+ * {
16
+ * "sha": "<diff-sha256>",
17
+ * "branch": "<feature-branch>",
18
+ * "base": "<target-branch>",
19
+ * "result": "pass" | "fail",
20
+ * "recorded_at": "<ISO-8601>",
21
+ * "reason"?: "<free text>" // optional, populated on fail or on skip
22
+ * }
23
+ *
24
+ * The `sha` is whatever the caller supplies — the hook happens to use a
25
+ * SHA-256 of the full diff, but the cache does not interpret or validate the
26
+ * value. Hash-chained is intentionally NOT required: this is a keyed cache,
27
+ * not an append-only integrity log. The audit log at `.rea/audit.jsonl`
28
+ * remains the integrity story.
29
+ *
30
+ * ## Concurrency
31
+ *
32
+ * Every write takes the same `proper-lockfile` lock on the `.rea/` parent
33
+ * directory that the audit helpers use (`withAuditLock`). This means a
34
+ * concurrent audit append and cache write serialize against each other — a
35
+ * negligible cost given cache writes happen once per push gate completion.
36
+ *
37
+ * ## Idempotency
38
+ *
39
+ * `appendEntry` writes a new line unconditionally. `lookup` returns the most
40
+ * recent entry matching `(sha, branch, base)`. This "last write wins" keeps
41
+ * the write path O(1) and the read path O(n) over the file; n is bounded by
42
+ * typical review frequency (dozens per week, not millions). If a future
43
+ * operator needs a compact file, `rea cache clear <sha>` drops matching
44
+ * entries and a separate `rea cache compact` (not in 0.5.0) could rewrite.
45
+ *
46
+ * ## TTL
47
+ *
48
+ * `lookup` honors `review.cache_max_age_seconds` (default 3600). Entries
49
+ * older than the window are treated as a miss. Expired entries are not
50
+ * garbage-collected on read — `rea cache clear` or `rea cache compact`
51
+ * is the operator tool for shrinking.
52
+ */
53
+ import fs from 'node:fs/promises';
54
+ import path from 'node:path';
55
+ import { withAuditLock } from '../audit/fs.js';
56
+ /** Default TTL when policy does not supply one. */
57
+ export const DEFAULT_CACHE_MAX_AGE_SECONDS = 3600;
58
+ /**
59
+ * Tolerated clock skew for future-dated entries. A `recorded_at` more than
60
+ * this far in the future relative to `nowMs` is treated as tampered or
61
+ * severely-drifted and forces a miss (re-review). 60s covers NTP jitter on
62
+ * well-synced hosts; anything beyond that is noise we do not trust.
63
+ */
64
+ const FUTURE_SKEW_ALLOWANCE_MS = 60_000;
65
+ const CACHE_FILENAME = 'review-cache.jsonl';
66
+ const REA_DIRNAME = '.rea';
67
+ export function resolveCacheFile(baseDir) {
68
+ return path.join(baseDir, REA_DIRNAME, CACHE_FILENAME);
69
+ }
70
+ /**
71
+ * Load every entry from the cache file. Returns `[]` when the file does not
72
+ * exist or is empty. Malformed lines are skipped — we never throw on a
73
+ * corrupt line, because the cache is advisory and a bad write (e.g. a
74
+ * half-written line from a crashed host) must not block a subsequent push.
75
+ */
76
+ async function loadEntries(cacheFile) {
77
+ let raw;
78
+ try {
79
+ raw = await fs.readFile(cacheFile, 'utf8');
80
+ }
81
+ catch (err) {
82
+ if (err.code === 'ENOENT')
83
+ return [];
84
+ throw err;
85
+ }
86
+ if (raw.length === 0)
87
+ return [];
88
+ const entries = [];
89
+ for (const line of raw.split('\n')) {
90
+ if (line.length === 0)
91
+ continue;
92
+ try {
93
+ const parsed = JSON.parse(line);
94
+ if (typeof parsed.sha === 'string' &&
95
+ typeof parsed.branch === 'string' &&
96
+ typeof parsed.base === 'string' &&
97
+ (parsed.result === 'pass' || parsed.result === 'fail') &&
98
+ typeof parsed.recorded_at === 'string') {
99
+ entries.push(parsed);
100
+ }
101
+ }
102
+ catch {
103
+ // Skip malformed line.
104
+ }
105
+ }
106
+ return entries;
107
+ }
108
+ /**
109
+ * Append an entry to the cache. Writes are serialized through the shared
110
+ * `.rea/` directory lock so audit writes and cache writes do not interleave.
111
+ */
112
+ export async function appendEntry(baseDir, input) {
113
+ const cacheFile = resolveCacheFile(baseDir);
114
+ await fs.mkdir(path.dirname(cacheFile), { recursive: true });
115
+ const entry = {
116
+ sha: input.sha,
117
+ branch: input.branch,
118
+ base: input.base,
119
+ result: input.result,
120
+ recorded_at: input.timestamp ?? new Date().toISOString(),
121
+ ...(input.reason !== undefined && input.reason.length > 0
122
+ ? { reason: input.reason }
123
+ : {}),
124
+ };
125
+ await withAuditLock(cacheFile, async () => {
126
+ const line = JSON.stringify(entry) + '\n';
127
+ await fs.appendFile(cacheFile, line);
128
+ });
129
+ return entry;
130
+ }
131
+ /**
132
+ * Find the most-recent entry matching `(sha, branch, base)` within the TTL
133
+ * window. Idempotent and side-effect free.
134
+ */
135
+ export async function lookup(baseDir, input) {
136
+ const cacheFile = resolveCacheFile(baseDir);
137
+ const entries = await loadEntries(cacheFile);
138
+ if (entries.length === 0)
139
+ return { hit: false, missReason: 'empty-file' };
140
+ // Walk from the tail so the first match is the newest.
141
+ let matched;
142
+ for (let i = entries.length - 1; i >= 0; i--) {
143
+ const e = entries[i];
144
+ if (e.sha === input.sha && e.branch === input.branch && e.base === input.base) {
145
+ matched = e;
146
+ break;
147
+ }
148
+ }
149
+ if (matched === undefined)
150
+ return { hit: false, missReason: 'no-entry' };
151
+ const nowMs = input.nowMs ?? Date.now();
152
+ const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_CACHE_MAX_AGE_SECONDS;
153
+ const recordedMs = Date.parse(matched.recorded_at);
154
+ if (Number.isNaN(recordedMs)) {
155
+ // Corrupt timestamp — treat as an expired miss so the caller re-reviews.
156
+ return { hit: false, missReason: 'expired', entry: matched };
157
+ }
158
+ if (recordedMs > nowMs + FUTURE_SKEW_ALLOWANCE_MS) {
159
+ return { hit: false, missReason: 'expired', entry: matched };
160
+ }
161
+ if ((nowMs - recordedMs) / 1000 > maxAgeSeconds) {
162
+ return { hit: false, missReason: 'expired', entry: matched };
163
+ }
164
+ return { hit: true, entry: matched };
165
+ }
166
+ /**
167
+ * Remove every entry matching `sha`. Returns the count removed. A `0` return
168
+ * is a valid outcome (sha not present). Writes back via the same lock as
169
+ * `appendEntry`, so concurrent sets do not lose entries.
170
+ *
171
+ * Writes use temp-file + `fs.rename` (atomic within a single directory on
172
+ * POSIX) so unlocked readers (`lookup`, `list`) can never observe a torn or
173
+ * empty intermediate state. Codex F4 on the 0.5.0 PR1 review.
174
+ */
175
+ export async function clear(baseDir, sha) {
176
+ const cacheFile = resolveCacheFile(baseDir);
177
+ return withAuditLock(cacheFile, async () => {
178
+ const entries = await loadEntries(cacheFile);
179
+ const kept = entries.filter((e) => e.sha !== sha);
180
+ const removed = entries.length - kept.length;
181
+ if (removed === 0)
182
+ return 0;
183
+ const out = kept.length === 0 ? '' : kept.map((e) => JSON.stringify(e)).join('\n') + '\n';
184
+ const tmpFile = `${cacheFile}.tmp.${process.pid}.${Date.now()}`;
185
+ await fs.writeFile(tmpFile, out);
186
+ await fs.rename(tmpFile, cacheFile);
187
+ return removed;
188
+ });
189
+ }
190
+ /**
191
+ * Return every entry, optionally filtered by branch. Entries are returned in
192
+ * file order (oldest first). Callers that want "newest first" should reverse.
193
+ */
194
+ export async function list(baseDir, options = {}) {
195
+ const cacheFile = resolveCacheFile(baseDir);
196
+ const entries = await loadEntries(cacheFile);
197
+ if (options.branch === undefined)
198
+ return entries;
199
+ return entries.filter((e) => e.branch === options.branch);
200
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `rea cache` — push-review cache operator subcommands (BUG-009).
3
+ *
4
+ * Four verbs:
5
+ * - `check <sha> --branch <b> --base <b>` — JSON to stdout ONLY; never
6
+ * diagnostics. `hooks/push-review-gate.sh` reads this via
7
+ * `printf '%s' "$CACHE_RESULT" | jq -e '.hit == true'`, so any stray
8
+ * text on stdout would poison the hook's JSON parse.
9
+ * - `set <sha> pass|fail --branch <b> --base <b> [--reason <s>]` — record
10
+ * a review outcome.
11
+ * - `clear <sha>` — drop every entry for a sha (dev convenience).
12
+ * - `list [--branch <b>]` — pretty-print entries.
13
+ *
14
+ * The TTL used by `check` reads `review.cache_max_age_seconds` from
15
+ * `.rea/policy.yaml` when present, falling back to
16
+ * {@link DEFAULT_CACHE_MAX_AGE_SECONDS} (1 hour) when the policy file or
17
+ * field is absent. An unreadable/malformed policy file is NOT fatal for
18
+ * `check` — it degrades to the default so a broken policy never deadlocks
19
+ * the push gate; other commands that don't consume the TTL ignore the policy
20
+ * entirely.
21
+ */
22
+ import { type CacheResult } from '../cache/review-cache.js';
23
+ export interface CacheCheckOptions {
24
+ sha: string;
25
+ branch: string;
26
+ base: string;
27
+ }
28
+ export interface CacheSetOptions {
29
+ sha: string;
30
+ result: CacheResult;
31
+ branch: string;
32
+ base: string;
33
+ reason?: string;
34
+ }
35
+ export interface CacheClearOptions {
36
+ sha: string;
37
+ }
38
+ export interface CacheListOptions {
39
+ branch?: string;
40
+ }
41
+ /**
42
+ * Print the cache-check JSON to stdout. Hook contract: stdout is ONLY JSON.
43
+ * On a miss we still exit 0 with `{"hit":false}` — the hook interprets
44
+ * non-zero as "rea broken, force re-review" via its `|| echo '{"hit":false}'`
45
+ * fallback.
46
+ */
47
+ export declare function runCacheCheck(options: CacheCheckOptions): Promise<void>;
48
+ export declare function runCacheSet(options: CacheSetOptions): Promise<void>;
49
+ export declare function runCacheClear(options: CacheClearOptions): Promise<void>;
50
+ export declare function runCacheList(options: CacheListOptions): Promise<void>;
51
+ /** Parse-and-validate helper for `set` — surfaces a clean error on bad input. */
52
+ export declare function parseCacheResult(raw: string): CacheResult;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `rea cache` — push-review cache operator subcommands (BUG-009).
3
+ *
4
+ * Four verbs:
5
+ * - `check <sha> --branch <b> --base <b>` — JSON to stdout ONLY; never
6
+ * diagnostics. `hooks/push-review-gate.sh` reads this via
7
+ * `printf '%s' "$CACHE_RESULT" | jq -e '.hit == true'`, so any stray
8
+ * text on stdout would poison the hook's JSON parse.
9
+ * - `set <sha> pass|fail --branch <b> --base <b> [--reason <s>]` — record
10
+ * a review outcome.
11
+ * - `clear <sha>` — drop every entry for a sha (dev convenience).
12
+ * - `list [--branch <b>]` — pretty-print entries.
13
+ *
14
+ * The TTL used by `check` reads `review.cache_max_age_seconds` from
15
+ * `.rea/policy.yaml` when present, falling back to
16
+ * {@link DEFAULT_CACHE_MAX_AGE_SECONDS} (1 hour) when the policy file or
17
+ * field is absent. An unreadable/malformed policy file is NOT fatal for
18
+ * `check` — it degrades to the default so a broken policy never deadlocks
19
+ * the push gate; other commands that don't consume the TTL ignore the policy
20
+ * entirely.
21
+ */
22
+ import { loadPolicy } from '../policy/loader.js';
23
+ import { DEFAULT_CACHE_MAX_AGE_SECONDS, appendEntry, clear as clearEntries, list as listEntries, lookup, } from '../cache/review-cache.js';
24
+ import { err, log } from './utils.js';
25
+ function resolveMaxAgeSeconds(baseDir) {
26
+ try {
27
+ const policy = loadPolicy(baseDir);
28
+ const configured = policy.review?.cache_max_age_seconds;
29
+ if (typeof configured === 'number' && configured > 0)
30
+ return configured;
31
+ return DEFAULT_CACHE_MAX_AGE_SECONDS;
32
+ }
33
+ catch {
34
+ // Missing or malformed policy must not block the push gate — degrade to
35
+ // the default. `rea doctor` is the canonical surface for flagging a
36
+ // broken policy file; the cache is not the place to re-diagnose it.
37
+ return DEFAULT_CACHE_MAX_AGE_SECONDS;
38
+ }
39
+ }
40
+ /**
41
+ * Print the cache-check JSON to stdout. Hook contract: stdout is ONLY JSON.
42
+ * On a miss we still exit 0 with `{"hit":false}` — the hook interprets
43
+ * non-zero as "rea broken, force re-review" via its `|| echo '{"hit":false}'`
44
+ * fallback.
45
+ */
46
+ export async function runCacheCheck(options) {
47
+ const baseDir = process.cwd();
48
+ const maxAgeSeconds = resolveMaxAgeSeconds(baseDir);
49
+ const result = await lookup(baseDir, {
50
+ sha: options.sha,
51
+ branch: options.branch,
52
+ base: options.base,
53
+ maxAgeSeconds,
54
+ });
55
+ if (result.hit && result.entry !== undefined) {
56
+ const payload = {
57
+ hit: true,
58
+ result: result.entry.result,
59
+ branch: result.entry.branch,
60
+ base: result.entry.base,
61
+ recorded_at: result.entry.recorded_at,
62
+ ...(result.entry.reason !== undefined ? { reason: result.entry.reason } : {}),
63
+ };
64
+ process.stdout.write(JSON.stringify(payload) + '\n');
65
+ return;
66
+ }
67
+ process.stdout.write(JSON.stringify({ hit: false }) + '\n');
68
+ }
69
+ export async function runCacheSet(options) {
70
+ const baseDir = process.cwd();
71
+ const entry = await appendEntry(baseDir, {
72
+ sha: options.sha,
73
+ branch: options.branch,
74
+ base: options.base,
75
+ result: options.result,
76
+ ...(options.reason !== undefined && options.reason.length > 0
77
+ ? { reason: options.reason }
78
+ : {}),
79
+ });
80
+ log(`Recorded ${entry.result} for ${entry.sha.slice(0, 12)} (${entry.branch} → ${entry.base}).`);
81
+ }
82
+ export async function runCacheClear(options) {
83
+ const baseDir = process.cwd();
84
+ const removed = await clearEntries(baseDir, options.sha);
85
+ if (removed === 0) {
86
+ log(`No entries found for ${options.sha.slice(0, 12)}.`);
87
+ return;
88
+ }
89
+ log(`Cleared ${removed} entr${removed === 1 ? 'y' : 'ies'} for ${options.sha.slice(0, 12)}.`);
90
+ }
91
+ export async function runCacheList(options) {
92
+ const baseDir = process.cwd();
93
+ const entries = await listEntries(baseDir, {
94
+ ...(options.branch !== undefined ? { branch: options.branch } : {}),
95
+ });
96
+ if (entries.length === 0) {
97
+ log('No review-cache entries.');
98
+ return;
99
+ }
100
+ for (const e of entries) {
101
+ const shortSha = e.sha.slice(0, 12);
102
+ const reason = e.reason !== undefined ? ` — ${e.reason}` : '';
103
+ console.log(`${e.recorded_at} ${e.result.padEnd(4)} ${shortSha} ${e.branch} → ${e.base}${reason}`);
104
+ }
105
+ }
106
+ /** Parse-and-validate helper for `set` — surfaces a clean error on bad input. */
107
+ export function parseCacheResult(raw) {
108
+ if (raw === 'pass' || raw === 'fail')
109
+ return raw;
110
+ err(`result must be 'pass' or 'fail'; got ${JSON.stringify(raw)}`);
111
+ process.exit(1);
112
+ }
@@ -1,4 +1,5 @@
1
1
  import { type CodexProbeState } from '../gateway/observability/codex-probe.js';
2
+ import { type PrePushDoctorState } from './install/pre-push.js';
2
3
  export interface CheckResult {
3
4
  label: string;
4
5
  /**
@@ -9,6 +10,15 @@ export interface CheckResult {
9
10
  status: 'pass' | 'fail' | 'warn' | 'info';
10
11
  detail?: string;
11
12
  }
13
+ /**
14
+ * G7: report the TOFU fingerprint-store state. Pass = every enabled server
15
+ * in the registry has a matching stored fingerprint. Warn = at least one
16
+ * server would be first-seen or drifted at next `rea serve`. Info = no
17
+ * enabled servers (nothing to fingerprint). Fail only for unreadable store.
18
+ *
19
+ * Exported so tests can drive this without spinning up the full `runDoctor`.
20
+ */
21
+ export declare function checkFingerprintStore(baseDir: string): Promise<CheckResult>;
12
22
  /**
13
23
  * Translate a `CodexProbeState` into two doctor CheckResults: one for
14
24
  * responsiveness (pass/warn) and one informational line about the last
@@ -22,11 +32,16 @@ export declare function checksFromProbeState(state: CodexProbeState): CheckResul
22
32
  * `runDoctor`.
23
33
  *
24
34
  * `codexProbeState` is consulted ONLY when Codex is required by policy.
25
- * Callers that already have a fresh probe state (e.g. `runDoctor`) should
26
- * pass it; callers that don't (e.g. unit tests of the existing doctor
27
- * surface) can omit it and the probe-derived fields are skipped.
35
+ * `prePushState` is the pre-computed G6 pre-push inspection; when omitted
36
+ * the pre-push check is skipped entirely (older call sites that don't yet
37
+ * thread the state through keep working without behavioural change).
38
+ * Callers that already have fresh state (e.g. `runDoctor`) should pass
39
+ * both; callers that don't (e.g. unit tests of the existing doctor
40
+ * surface) can omit them and those checks are skipped.
41
+ *
42
+ * `activeForeign` always yields `fail` — a foreign hook bypassing the gate is a hard governance gap.
28
43
  */
29
- export declare function collectChecks(baseDir: string, codexProbeState?: CodexProbeState): CheckResult[];
44
+ export declare function collectChecks(baseDir: string, codexProbeState?: CodexProbeState, prePushState?: PrePushDoctorState): CheckResult[];
30
45
  export interface RunDoctorOptions {
31
46
  /** When true, print a 7-day telemetry summary after the checks (G11.5). */
32
47
  metrics?: boolean;