@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.
- package/.husky/pre-push +15 -18
- package/README.md +41 -1
- package/dist/cache/review-cache.d.ts +115 -0
- package/dist/cache/review-cache.js +200 -0
- package/dist/cli/cache.d.ts +52 -0
- package/dist/cli/cache.js +112 -0
- package/dist/cli/doctor.d.ts +19 -4
- package/dist/cli/doctor.js +172 -5
- package/dist/cli/index.js +50 -1
- package/dist/cli/init.js +109 -7
- package/dist/cli/install/gitignore.d.ts +114 -0
- package/dist/cli/install/gitignore.js +356 -0
- package/dist/cli/install/pre-push.d.ts +335 -0
- package/dist/cli/install/pre-push.js +2818 -0
- package/dist/cli/serve.d.ts +64 -0
- package/dist/cli/serve.js +270 -2
- package/dist/cli/status.d.ts +90 -0
- package/dist/cli/status.js +399 -0
- package/dist/cli/upgrade.js +20 -0
- package/dist/cli/utils.d.ts +4 -0
- package/dist/cli/utils.js +4 -0
- package/dist/gateway/circuit-breaker.d.ts +17 -0
- package/dist/gateway/circuit-breaker.js +32 -3
- package/dist/gateway/downstream-pool.d.ts +2 -1
- package/dist/gateway/downstream-pool.js +2 -2
- package/dist/gateway/downstream.d.ts +39 -3
- package/dist/gateway/downstream.js +73 -14
- package/dist/gateway/log.d.ts +122 -0
- package/dist/gateway/log.js +334 -0
- package/dist/gateway/middleware/audit.d.ts +10 -1
- package/dist/gateway/middleware/audit.js +26 -1
- package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
- package/dist/gateway/middleware/blocked-paths.js +439 -67
- package/dist/gateway/middleware/injection.d.ts +218 -13
- package/dist/gateway/middleware/injection.js +433 -51
- package/dist/gateway/middleware/kill-switch.d.ts +10 -1
- package/dist/gateway/middleware/kill-switch.js +20 -1
- package/dist/gateway/observability/metrics.d.ts +125 -0
- package/dist/gateway/observability/metrics.js +321 -0
- package/dist/gateway/server.d.ts +19 -0
- package/dist/gateway/server.js +99 -15
- package/dist/policy/loader.d.ts +23 -0
- package/dist/policy/loader.js +30 -0
- package/dist/policy/profiles.d.ts +13 -0
- package/dist/policy/profiles.js +12 -0
- package/dist/policy/types.d.ts +48 -0
- package/dist/registry/fingerprint.d.ts +73 -0
- package/dist/registry/fingerprint.js +81 -0
- package/dist/registry/fingerprints-store.d.ts +62 -0
- package/dist/registry/fingerprints-store.js +111 -0
- package/dist/registry/interpolate.d.ts +58 -0
- package/dist/registry/interpolate.js +121 -0
- package/dist/registry/loader.d.ts +2 -2
- package/dist/registry/loader.js +22 -1
- package/dist/registry/tofu-gate.d.ts +41 -0
- package/dist/registry/tofu-gate.js +189 -0
- package/dist/registry/tofu.d.ts +111 -0
- package/dist/registry/tofu.js +173 -0
- package/dist/registry/types.d.ts +9 -1
- package/hooks/push-review-gate.sh +185 -1
- package/package.json +1 -1
- package/profiles/bst-internal-no-codex.yaml +5 -0
- package/profiles/bst-internal.yaml +7 -0
- 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
|
-
#
|
|
24
|
-
#
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/cli/doctor.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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;
|