@bookedsolid/rea 0.40.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -36
- package/dist/cli/audit-summary.d.ts +145 -0
- package/dist/cli/audit-summary.js +479 -0
- package/dist/cli/index.js +33 -0
- package/dist/cli/install/gitignore.d.ts +23 -1
- package/dist/cli/install/gitignore.js +33 -6
- package/dist/cli/install/unified-diff.d.ts +78 -0
- package/dist/cli/install/unified-diff.js +270 -0
- package/dist/cli/upgrade-check.d.ts +149 -0
- package/dist/cli/upgrade-check.js +599 -0
- package/package.json +1 -1
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rea audit summary` — high-level audit-log overview (0.41.0).
|
|
3
|
+
*
|
|
4
|
+
* The audit log is rich and `rea audit specialists` already exists for
|
|
5
|
+
* one narrow event-class. `rea audit summary` complements it with a
|
|
6
|
+
* broad rollup: total events, counts by `tool_name`, by tier, by
|
|
7
|
+
* session, by status, the time window covered, and a sample-verified
|
|
8
|
+
* chain-integrity check.
|
|
9
|
+
*
|
|
10
|
+
* # Filtering
|
|
11
|
+
*
|
|
12
|
+
* `--since <duration>` accepts a compact duration string
|
|
13
|
+
* (`s`/`m`/`h`/`d`/`w`) and filters records by `timestamp >= now -
|
|
14
|
+
* duration`. Examples: `24h`, `7d`, `90m`, `2w`. This is DIFFERENT
|
|
15
|
+
* from `rea audit verify --since <file>` / `rea audit specialists
|
|
16
|
+
* --since <file>` which take a rotated-file ANCHOR, not a duration —
|
|
17
|
+
* the two `--since` semantics serve different needs (anchor for chain
|
|
18
|
+
* walks, duration for summarization) and we accept the surface area
|
|
19
|
+
* cost. The duration form is what consumers reach for when asking
|
|
20
|
+
* "what happened in the last day?".
|
|
21
|
+
*
|
|
22
|
+
* # Chain integrity
|
|
23
|
+
*
|
|
24
|
+
* `rea audit verify` does the rigorous per-record re-hash. `summary`
|
|
25
|
+
* samples up to `CHAIN_SAMPLE_SIZE` records, evenly spaced through
|
|
26
|
+
* the filtered window, and reports `ok` / `tampered` / `unsampled`
|
|
27
|
+
* (window empty). Operators who suspect tampering should still run
|
|
28
|
+
* `rea audit verify` for an authoritative answer.
|
|
29
|
+
*
|
|
30
|
+
* # JSON output
|
|
31
|
+
*
|
|
32
|
+
* {
|
|
33
|
+
* "schema_version": 1,
|
|
34
|
+
* "window_seconds": 86400,
|
|
35
|
+
* "window_start": "2026-05-15T13:42:00Z",
|
|
36
|
+
* "window_end": "2026-05-16T13:42:00Z",
|
|
37
|
+
* "files_scanned": ["/abs/path/.rea/audit.jsonl"],
|
|
38
|
+
* "total_events": 1247,
|
|
39
|
+
* "by_tool_name": { "Bash": 612, "Edit": 289, … },
|
|
40
|
+
* "by_tier": { "read": 683, "write": 416, "destructive": 148 },
|
|
41
|
+
* "by_status": { "allowed": 1242, "denied": 5, "error": 0 },
|
|
42
|
+
* "by_session": { "session-abc…": 312, "session-def…": 935 },
|
|
43
|
+
* "session_count": 8,
|
|
44
|
+
* "earliest_timestamp": "2026-05-15T13:43:01.103Z",
|
|
45
|
+
* "latest_timestamp": "2026-05-16T13:41:57.842Z",
|
|
46
|
+
* "chain_integrity": "ok",
|
|
47
|
+
* "chain_samples_verified": 12
|
|
48
|
+
* }
|
|
49
|
+
*
|
|
50
|
+
* # Walk scope
|
|
51
|
+
*
|
|
52
|
+
* v1 walks the current `.rea/audit.jsonl` plus EVERY rotated file
|
|
53
|
+
* whose latest record falls within the window. Older rotated files
|
|
54
|
+
* are skipped — they cannot contain in-window records. When `--since`
|
|
55
|
+
* is omitted, no time filter is applied and the walk covers the
|
|
56
|
+
* current `audit.jsonl` only (operators wanting historical depth
|
|
57
|
+
* should pass `--since <DUR>`).
|
|
58
|
+
*/
|
|
59
|
+
import fs from 'node:fs/promises';
|
|
60
|
+
import path from 'node:path';
|
|
61
|
+
import { computeHash, GENESIS_HASH } from '../audit/fs.js';
|
|
62
|
+
import { listRotatedAuditFiles } from './audit-specialists.js';
|
|
63
|
+
import { AUDIT_FILE, REA_DIR, err, log } from './utils.js';
|
|
64
|
+
export const AUDIT_SUMMARY_SCHEMA_VERSION = 1;
|
|
65
|
+
/** Hard cap on chain-integrity samples. Keeps `rea audit summary`
|
|
66
|
+
* fast even on large logs while still surfacing obvious tampering. */
|
|
67
|
+
export const CHAIN_SAMPLE_SIZE = 12;
|
|
68
|
+
/**
|
|
69
|
+
* Thrown by `computeAuditSummary` when `--since` cannot be parsed.
|
|
70
|
+
* The commander wrapper exits 1.
|
|
71
|
+
*/
|
|
72
|
+
export class AuditSummarySinceError extends Error {
|
|
73
|
+
constructor(message) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = 'AuditSummarySinceError';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Parse a compact duration string into seconds. Accepts:
|
|
80
|
+
*
|
|
81
|
+
* - `<N>s` — seconds
|
|
82
|
+
* - `<N>m` — minutes
|
|
83
|
+
* - `<N>h` — hours
|
|
84
|
+
* - `<N>d` — days
|
|
85
|
+
* - `<N>w` — weeks (7 days)
|
|
86
|
+
*
|
|
87
|
+
* `N` must be a positive integer with no whitespace. Returns the
|
|
88
|
+
* number of seconds; throws `AuditSummarySinceError` on parse failure.
|
|
89
|
+
*
|
|
90
|
+
* We deliberately do not accept bare numbers (would be ambiguous) or
|
|
91
|
+
* fractional units (no real use case; complicates rendering).
|
|
92
|
+
*/
|
|
93
|
+
export function parseDurationSeconds(raw) {
|
|
94
|
+
const m = /^(\d+)(s|m|h|d|w)$/i.exec(raw.trim());
|
|
95
|
+
if (m === null) {
|
|
96
|
+
throw new AuditSummarySinceError(`--since: cannot parse ${JSON.stringify(raw)}. ` +
|
|
97
|
+
`Expected <N><unit> where unit is s|m|h|d|w (e.g. 24h, 7d, 90m).`);
|
|
98
|
+
}
|
|
99
|
+
const n = Number.parseInt(m[1], 10);
|
|
100
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
101
|
+
throw new AuditSummarySinceError(`--since: duration must be a positive integer; got ${JSON.stringify(raw)}.`);
|
|
102
|
+
}
|
|
103
|
+
const unit = m[2].toLowerCase();
|
|
104
|
+
const factor = {
|
|
105
|
+
s: 1,
|
|
106
|
+
m: 60,
|
|
107
|
+
h: 60 * 60,
|
|
108
|
+
d: 60 * 60 * 24,
|
|
109
|
+
w: 60 * 60 * 24 * 7,
|
|
110
|
+
};
|
|
111
|
+
return n * factor[unit];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the audit files to walk. Always includes the current
|
|
115
|
+
* `audit.jsonl` (when it exists).
|
|
116
|
+
*
|
|
117
|
+
* - `windowStart === null` (no `--since`): walk EVERY rotated file
|
|
118
|
+
* PLUS the current `audit.jsonl`. Round-1 P2: the prior shape
|
|
119
|
+
* dropped rotated history silently while the header still
|
|
120
|
+
* advertised "all time", undercounting long-lived repos.
|
|
121
|
+
* - `windowStart` set: walk every rotated file whose basename
|
|
122
|
+
* timestamp >= the cutoff, PLUS one rotated file immediately
|
|
123
|
+
* before the cutoff (the in-flight file at cutoff time may
|
|
124
|
+
* contain in-window records).
|
|
125
|
+
*
|
|
126
|
+
* Sort order is timestamp-ascending; the current `audit.jsonl` is
|
|
127
|
+
* always appended last (it is the newest segment of the chain).
|
|
128
|
+
*/
|
|
129
|
+
async function resolveSummaryFileWalk(baseDir, windowStart) {
|
|
130
|
+
const reaDir = path.join(baseDir, REA_DIR);
|
|
131
|
+
const currentAudit = path.join(reaDir, AUDIT_FILE);
|
|
132
|
+
const files = [];
|
|
133
|
+
const rotated = await listRotatedAuditFiles(reaDir);
|
|
134
|
+
if (windowStart === null) {
|
|
135
|
+
// Walk every rotated segment. The "all time" header would be a
|
|
136
|
+
// lie otherwise.
|
|
137
|
+
for (const name of rotated)
|
|
138
|
+
files.push(path.join(reaDir, name));
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
// Rotated filenames are `audit-YYYYMMDD-HHMMSS(-N).jsonl` in UTC.
|
|
142
|
+
// We treat each filename as "rotated at this instant" and include
|
|
143
|
+
// every file rotated >= windowStart, plus one file immediately
|
|
144
|
+
// before windowStart (the in-flight file at cutoff time may
|
|
145
|
+
// contain in-window records).
|
|
146
|
+
const stampToDate = (name) => {
|
|
147
|
+
const m = /^audit-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})/.exec(name);
|
|
148
|
+
if (m === null)
|
|
149
|
+
return null;
|
|
150
|
+
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
|
|
151
|
+
const d = new Date(iso);
|
|
152
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
153
|
+
};
|
|
154
|
+
const cutoffIdx = rotated.findIndex((n) => {
|
|
155
|
+
const d = stampToDate(n);
|
|
156
|
+
return d !== null && d >= windowStart;
|
|
157
|
+
});
|
|
158
|
+
const startIdx = cutoffIdx === -1 ? Math.max(0, rotated.length - 1) : Math.max(0, cutoffIdx - 1);
|
|
159
|
+
for (const name of rotated.slice(startIdx)) {
|
|
160
|
+
files.push(path.join(reaDir, name));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const stat = await fs.stat(currentAudit);
|
|
165
|
+
if (stat.isFile())
|
|
166
|
+
files.push(currentAudit);
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
if (e.code !== 'ENOENT')
|
|
170
|
+
throw e;
|
|
171
|
+
}
|
|
172
|
+
return files;
|
|
173
|
+
}
|
|
174
|
+
/** Map a rea Tier value (or unknown) to a stable bucket key for the
|
|
175
|
+
* by_tier table. Unknown values bucket to `'unknown'` so the rollup
|
|
176
|
+
* surfaces them rather than silently dropping.
|
|
177
|
+
*/
|
|
178
|
+
function tierBucket(value) {
|
|
179
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
180
|
+
return 'unknown';
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
function statusBucket(value) {
|
|
184
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
185
|
+
return 'unknown';
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Parse an ISO-8601 timestamp into a Date. Returns `null` on failure.
|
|
190
|
+
*/
|
|
191
|
+
function parseTimestamp(raw) {
|
|
192
|
+
if (typeof raw !== 'string')
|
|
193
|
+
return null;
|
|
194
|
+
const d = new Date(raw);
|
|
195
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Sample-verify per-record hash integrity across the IN-WINDOW
|
|
199
|
+
* records only. Returns `ok`, `tampered`, or `unsampled` (no
|
|
200
|
+
* records in window).
|
|
201
|
+
*
|
|
202
|
+
* Sampling: take indices at offsets 0, n/k, 2n/k, …, (k-1)n/k where
|
|
203
|
+
* n is the in-window count and k is `CHAIN_SAMPLE_SIZE`. Always
|
|
204
|
+
* sample the first and last record.
|
|
205
|
+
*
|
|
206
|
+
* Codex round-2 P1: we do NOT verify `prev_hash` linkage between
|
|
207
|
+
* adjacent records in the filtered list. `appendAuditRecord` accepts
|
|
208
|
+
* caller-supplied timestamps, so a valid chain can look like
|
|
209
|
+
* `in-window → out-of-window → in-window`; in that case the second
|
|
210
|
+
* in-window record's `prev_hash` points to the filtered-out entry,
|
|
211
|
+
* not the previous survivor in our filtered view, and a linkage
|
|
212
|
+
* check would false-positive `tampered` on a healthy log. The
|
|
213
|
+
* authoritative chain walk lives in `rea audit verify`; summary
|
|
214
|
+
* stays advisory.
|
|
215
|
+
*/
|
|
216
|
+
function sampleChainIntegrity(records) {
|
|
217
|
+
if (records.length === 0)
|
|
218
|
+
return { result: 'unsampled', samplesVerified: 0 };
|
|
219
|
+
const sampleIndices = new Set();
|
|
220
|
+
const k = Math.min(CHAIN_SAMPLE_SIZE, records.length);
|
|
221
|
+
for (let i = 0; i < k; i += 1) {
|
|
222
|
+
const idx = Math.floor((i * records.length) / k);
|
|
223
|
+
sampleIndices.add(idx);
|
|
224
|
+
}
|
|
225
|
+
sampleIndices.add(records.length - 1);
|
|
226
|
+
let samplesVerified = 0;
|
|
227
|
+
for (const i of sampleIndices) {
|
|
228
|
+
const r = records[i];
|
|
229
|
+
const { hash, ...rest } = r;
|
|
230
|
+
const recomputed = computeHash(rest);
|
|
231
|
+
if (recomputed !== hash) {
|
|
232
|
+
return { result: 'tampered', samplesVerified };
|
|
233
|
+
}
|
|
234
|
+
samplesVerified += 1;
|
|
235
|
+
}
|
|
236
|
+
return { result: 'ok', samplesVerified };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Compute the summary. Pure (read-only). Throws
|
|
240
|
+
* `AuditSummarySinceError` on bad `--since`; everything else is
|
|
241
|
+
* surfaced via the result.
|
|
242
|
+
*/
|
|
243
|
+
export async function computeAuditSummary(options = {}) {
|
|
244
|
+
const baseDir = options.baseDir ?? process.cwd();
|
|
245
|
+
const now = options.now ?? new Date();
|
|
246
|
+
let windowSeconds = null;
|
|
247
|
+
let windowStart = null;
|
|
248
|
+
let windowEnd = null;
|
|
249
|
+
if (options.since !== undefined && options.since.length > 0) {
|
|
250
|
+
windowSeconds = parseDurationSeconds(options.since);
|
|
251
|
+
windowEnd = now;
|
|
252
|
+
windowStart = new Date(now.getTime() - windowSeconds * 1000);
|
|
253
|
+
}
|
|
254
|
+
const files = await resolveSummaryFileWalk(baseDir, windowStart);
|
|
255
|
+
const byToolName = {};
|
|
256
|
+
const byTier = {};
|
|
257
|
+
const byStatus = {};
|
|
258
|
+
const bySession = {};
|
|
259
|
+
let totalEvents = 0;
|
|
260
|
+
let earliest = null;
|
|
261
|
+
let latest = null;
|
|
262
|
+
// We only feed in-window records to the chain-sample check.
|
|
263
|
+
const inWindowRecords = [];
|
|
264
|
+
for (const filePath of files) {
|
|
265
|
+
let raw;
|
|
266
|
+
try {
|
|
267
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
if (e.code === 'ENOENT')
|
|
271
|
+
continue;
|
|
272
|
+
throw e;
|
|
273
|
+
}
|
|
274
|
+
for (const line of raw.split('\n')) {
|
|
275
|
+
if (line.length === 0)
|
|
276
|
+
continue;
|
|
277
|
+
let parsed;
|
|
278
|
+
try {
|
|
279
|
+
parsed = JSON.parse(line);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Malformed line — `rea audit verify` is the tool for that.
|
|
283
|
+
// Summary just skips and moves on.
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const ts = parseTimestamp(parsed.timestamp);
|
|
287
|
+
if (windowStart !== null && (ts === null || ts < windowStart))
|
|
288
|
+
continue;
|
|
289
|
+
// upper bound is `now`; future-dated records (skew, replay) are
|
|
290
|
+
// still counted — they're real records that landed in the file.
|
|
291
|
+
totalEvents += 1;
|
|
292
|
+
const toolName = typeof parsed.tool_name === 'string' && parsed.tool_name.length > 0
|
|
293
|
+
? parsed.tool_name
|
|
294
|
+
: '(unknown)';
|
|
295
|
+
byToolName[toolName] = (byToolName[toolName] ?? 0) + 1;
|
|
296
|
+
const tier = tierBucket(parsed.tier);
|
|
297
|
+
byTier[tier] = (byTier[tier] ?? 0) + 1;
|
|
298
|
+
const status = statusBucket(parsed.status);
|
|
299
|
+
byStatus[status] = (byStatus[status] ?? 0) + 1;
|
|
300
|
+
const session = typeof parsed.session_id === 'string' && parsed.session_id.length > 0
|
|
301
|
+
? parsed.session_id
|
|
302
|
+
: '(unknown)';
|
|
303
|
+
bySession[session] = (bySession[session] ?? 0) + 1;
|
|
304
|
+
const tsRaw = typeof parsed.timestamp === 'string' ? parsed.timestamp : null;
|
|
305
|
+
if (tsRaw !== null) {
|
|
306
|
+
if (earliest === null || tsRaw < earliest)
|
|
307
|
+
earliest = tsRaw;
|
|
308
|
+
if (latest === null || tsRaw > latest)
|
|
309
|
+
latest = tsRaw;
|
|
310
|
+
}
|
|
311
|
+
inWindowRecords.push(parsed);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const { result: chainIntegrity, samplesVerified } = sampleChainIntegrity(inWindowRecords);
|
|
315
|
+
// Suppress unused-variable warning for genesis hash by referencing
|
|
316
|
+
// it: the chain-sample check uses it implicitly via the relaxed
|
|
317
|
+
// first-record rule. Kept as an import-time anchor for the linkage
|
|
318
|
+
// contract documented in `sampleChainIntegrity`.
|
|
319
|
+
void GENESIS_HASH;
|
|
320
|
+
return {
|
|
321
|
+
schema_version: AUDIT_SUMMARY_SCHEMA_VERSION,
|
|
322
|
+
window_seconds: windowSeconds,
|
|
323
|
+
window_start: windowStart !== null ? windowStart.toISOString() : null,
|
|
324
|
+
window_end: windowEnd !== null ? windowEnd.toISOString() : null,
|
|
325
|
+
files_scanned: files,
|
|
326
|
+
total_events: totalEvents,
|
|
327
|
+
by_tool_name: byToolName,
|
|
328
|
+
by_tier: byTier,
|
|
329
|
+
by_status: byStatus,
|
|
330
|
+
by_session: bySession,
|
|
331
|
+
session_count: Object.keys(bySession).length,
|
|
332
|
+
earliest_timestamp: earliest,
|
|
333
|
+
latest_timestamp: latest,
|
|
334
|
+
chain_integrity: chainIntegrity,
|
|
335
|
+
chain_samples_verified: samplesVerified,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function sortBucket(bucket) {
|
|
339
|
+
return Object.entries(bucket).sort((a, b) => {
|
|
340
|
+
if (b[1] !== a[1])
|
|
341
|
+
return b[1] - a[1];
|
|
342
|
+
return a[0].localeCompare(b[0]);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
function renderBucket(d, total) {
|
|
346
|
+
const lines = [];
|
|
347
|
+
lines.push(`${d.title}:`);
|
|
348
|
+
const visible = d.limit !== undefined ? d.entries.slice(0, d.limit) : d.entries;
|
|
349
|
+
const overflow = d.limit !== undefined ? d.entries.slice(d.limit) : [];
|
|
350
|
+
const maxNameLen = visible.reduce((m, [n]) => Math.max(m, n.length), 0);
|
|
351
|
+
for (const [name, count] of visible) {
|
|
352
|
+
const pad = ' '.repeat(maxNameLen - name.length + 2);
|
|
353
|
+
const pct = total > 0 ? ` (${((count * 100) / total).toFixed(1)}%)` : '';
|
|
354
|
+
lines.push(` ${name}${pad}${String(count).padStart(6)}${pct}`);
|
|
355
|
+
}
|
|
356
|
+
if (overflow.length > 0) {
|
|
357
|
+
const overflowSum = overflow.reduce((s, [, n]) => s + n, 0);
|
|
358
|
+
const pad = ' '.repeat(Math.max(0, maxNameLen - 7) + 2);
|
|
359
|
+
const pct = total > 0 ? ` (${((overflowSum * 100) / total).toFixed(1)}%)` : '';
|
|
360
|
+
lines.push(` (other)${pad}${String(overflowSum).padStart(6)}${pct}`);
|
|
361
|
+
}
|
|
362
|
+
return lines.join('\n');
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Render the result as a human-readable terminal block. Designed for
|
|
366
|
+
* the default `rea audit summary` invocation; `--json` callers bypass
|
|
367
|
+
* this entirely.
|
|
368
|
+
*/
|
|
369
|
+
export function renderAuditSummary(result) {
|
|
370
|
+
const lines = [];
|
|
371
|
+
const windowLabel = result.window_seconds !== null ? formatDurationShort(result.window_seconds) : 'all time';
|
|
372
|
+
lines.push(`rea audit summary (${windowLabel})`);
|
|
373
|
+
lines.push('─'.repeat(40));
|
|
374
|
+
lines.push(`total events: ${String(result.total_events).padStart(6)}`);
|
|
375
|
+
lines.push(`sessions: ${String(result.session_count).padStart(6)}`);
|
|
376
|
+
if (result.session_count > 0) {
|
|
377
|
+
const avg = result.total_events / result.session_count;
|
|
378
|
+
lines.push(`events/session avg: ${avg.toFixed(1).padStart(6)}`);
|
|
379
|
+
}
|
|
380
|
+
lines.push('');
|
|
381
|
+
if (result.total_events === 0) {
|
|
382
|
+
lines.push(result.window_seconds !== null
|
|
383
|
+
? 'No events in the requested window.'
|
|
384
|
+
: 'No events in the audit log.');
|
|
385
|
+
lines.push('');
|
|
386
|
+
if (result.files_scanned.length === 0) {
|
|
387
|
+
lines.push('(no audit files found — has `rea serve` ever run?)');
|
|
388
|
+
lines.push('');
|
|
389
|
+
}
|
|
390
|
+
return lines.join('\n');
|
|
391
|
+
}
|
|
392
|
+
const total = result.total_events;
|
|
393
|
+
lines.push(renderBucket({ title: 'by tool_name', entries: sortBucket(result.by_tool_name), limit: 12 }, total));
|
|
394
|
+
lines.push('');
|
|
395
|
+
lines.push(renderBucket({ title: 'by tier', entries: sortBucket(result.by_tier) }, total));
|
|
396
|
+
lines.push('');
|
|
397
|
+
lines.push(renderBucket({ title: 'by status', entries: sortBucket(result.by_status) }, total));
|
|
398
|
+
lines.push('');
|
|
399
|
+
// Sessions can balloon — limit to 5 by default.
|
|
400
|
+
lines.push(renderBucket({ title: 'top sessions', entries: sortBucket(result.by_session), limit: 5 }, total));
|
|
401
|
+
lines.push('');
|
|
402
|
+
if (result.earliest_timestamp !== null && result.latest_timestamp !== null) {
|
|
403
|
+
lines.push(`window: ${result.earliest_timestamp} → ${result.latest_timestamp}`);
|
|
404
|
+
}
|
|
405
|
+
const chainLabel = result.chain_integrity === 'ok'
|
|
406
|
+
? `ok (${String(result.chain_samples_verified)} sample${result.chain_samples_verified === 1 ? '' : 's'} verified)`
|
|
407
|
+
: result.chain_integrity === 'tampered'
|
|
408
|
+
? 'TAMPERED — run `rea audit verify` for the exact break'
|
|
409
|
+
: 'unsampled (no records in window)';
|
|
410
|
+
lines.push(`chain integrity: ${chainLabel}`);
|
|
411
|
+
lines.push(`files scanned: ${String(result.files_scanned.length)}`);
|
|
412
|
+
lines.push('');
|
|
413
|
+
return lines.join('\n');
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Compact human duration label for the header. `86400` → `last 24h`,
|
|
417
|
+
* `604800` → `last 7d`, etc. We pick the coarsest single unit that
|
|
418
|
+
* yields an integer; otherwise fall back to seconds.
|
|
419
|
+
*/
|
|
420
|
+
function formatDurationShort(seconds) {
|
|
421
|
+
const units = [
|
|
422
|
+
['w', 60 * 60 * 24 * 7],
|
|
423
|
+
['d', 60 * 60 * 24],
|
|
424
|
+
['h', 60 * 60],
|
|
425
|
+
['m', 60],
|
|
426
|
+
['s', 1],
|
|
427
|
+
];
|
|
428
|
+
for (const [unit, factor] of units) {
|
|
429
|
+
if (seconds % factor === 0) {
|
|
430
|
+
return `last ${String(seconds / factor)}${unit}`;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return `last ${String(seconds)}s`;
|
|
434
|
+
}
|
|
435
|
+
/** Commander entrypoint. */
|
|
436
|
+
export async function runAuditSummary(options) {
|
|
437
|
+
let result;
|
|
438
|
+
try {
|
|
439
|
+
result = await computeAuditSummary({
|
|
440
|
+
...(options.since !== undefined ? { since: options.since } : {}),
|
|
441
|
+
...(options.now !== undefined ? { now: options.now } : {}),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
catch (e) {
|
|
445
|
+
if (e instanceof AuditSummarySinceError) {
|
|
446
|
+
err(`rea audit summary: ${e.message}`);
|
|
447
|
+
process.exit(1);
|
|
448
|
+
}
|
|
449
|
+
throw e;
|
|
450
|
+
}
|
|
451
|
+
if (options.json === true) {
|
|
452
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
process.stdout.write(renderAuditSummary(result));
|
|
456
|
+
if (result.chain_integrity === 'tampered') {
|
|
457
|
+
// Non-zero exit gives CI users a single signal for "something is
|
|
458
|
+
// off"; the JSON path stays exit 0 so machine consumers can react
|
|
459
|
+
// to the integrity field directly.
|
|
460
|
+
log('summary complete with tampered chain — see message above.');
|
|
461
|
+
process.exit(1);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Register `rea audit summary` under the `audit` command group.
|
|
466
|
+
*/
|
|
467
|
+
export function registerAuditSummaryCommand(auditCommand) {
|
|
468
|
+
auditCommand
|
|
469
|
+
.command('summary')
|
|
470
|
+
.description('High-level audit-log summary — counts by tool_name, tier, session, status; window timestamps; sample-verified chain integrity. Read-only.')
|
|
471
|
+
.option('--since <duration>', 'Filter to records within the last <duration>. Compact form: <N><unit> where unit is s|m|h|d|w (e.g. 24h, 7d). Distinct from `rea audit verify --since <file>` which anchors on a rotated file.')
|
|
472
|
+
.option('--json', 'emit a JSON document instead of the human-readable table')
|
|
473
|
+
.action(async (opts) => {
|
|
474
|
+
await runAuditSummary({
|
|
475
|
+
...(opts.since !== undefined ? { since: opts.since } : {}),
|
|
476
|
+
...(opts.json === true ? { json: true } : {}),
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -13,6 +13,8 @@ import { runServe } from './serve.js';
|
|
|
13
13
|
import { runStatus } from './status.js';
|
|
14
14
|
import { runTofuAccept, runTofuList } from './tofu.js';
|
|
15
15
|
import { runUpgrade } from './upgrade.js';
|
|
16
|
+
import { runUpgradeCheck } from './upgrade-check.js';
|
|
17
|
+
import { registerAuditSummaryCommand } from './audit-summary.js';
|
|
16
18
|
import { registerVerifyClaimCommand } from './verify-claim.js';
|
|
17
19
|
import { err, getPkgVersion } from './utils.js';
|
|
18
20
|
async function main() {
|
|
@@ -51,7 +53,34 @@ async function main() {
|
|
|
51
53
|
.option('--dry-run', 'show what would change; write nothing')
|
|
52
54
|
.option('-y, --yes', 'non-interactive — keep drifted files, skip removed-upstream')
|
|
53
55
|
.option('--force', 'non-interactive — overwrite drift, delete removed-upstream')
|
|
56
|
+
// 0.41.0 — `--check` is a non-interactive, structured preview that
|
|
57
|
+
// emits unified diffs per modified file and exits 0 regardless of
|
|
58
|
+
// what would change. Distinct from `--dry-run`, which rehearses the
|
|
59
|
+
// FULL interactive flow with writes suppressed. Use `--check` in CI
|
|
60
|
+
// to surface the changes a `rea upgrade` PR would produce; use
|
|
61
|
+
// `--dry-run` locally to walk through the same prompts you'd see
|
|
62
|
+
// during a real upgrade.
|
|
63
|
+
.option('--check', '0.41.0 — preview-only mode: classify files, emit unified diffs, exit 0')
|
|
64
|
+
.option('--json', '(with --check) emit a single JSON document instead of the text summary')
|
|
65
|
+
.option('--no-diff', '(with --check) omit unified-diff bodies (counts + paths only)')
|
|
54
66
|
.action(async (opts) => {
|
|
67
|
+
if (opts.check === true) {
|
|
68
|
+
await runUpgradeCheck({
|
|
69
|
+
json: opts.json === true,
|
|
70
|
+
noDiff: opts.diff === false,
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Codex round-2 P1: `--json` / `--no-diff` are preview-only.
|
|
75
|
+
// Before this PR they were unknown flags and commander rejected
|
|
76
|
+
// them; now they exist on the command. Refuse them without
|
|
77
|
+
// `--check` rather than silently performing a real upgrade —
|
|
78
|
+
// a CI typo (`rea upgrade --json` without `--check`) must not
|
|
79
|
+
// rewrite `.claude/` / `.husky/` / managed fragments.
|
|
80
|
+
if (opts.json === true || opts.diff === false) {
|
|
81
|
+
err('`--json` / `--no-diff` are preview-only flags; pass `--check` to use them.');
|
|
82
|
+
process.exit(2);
|
|
83
|
+
}
|
|
55
84
|
await runUpgrade({
|
|
56
85
|
dryRun: opts.dryRun,
|
|
57
86
|
yes: opts.yes,
|
|
@@ -111,6 +140,10 @@ async function main() {
|
|
|
111
140
|
// records. Read-only; honors $CLAUDE_SESSION_ID for current-session
|
|
112
141
|
// filtering. v1 omits --since / --session (deferred to 0.29.1).
|
|
113
142
|
registerAuditSpecialistsSubcommand(audit);
|
|
143
|
+
// 0.41.0 — `rea audit summary [--since=DUR] [--json]` high-level
|
|
144
|
+
// overview reader. Counts events by tool_name, tier, session,
|
|
145
|
+
// status; samples chain integrity. Tier-Read; never mutates.
|
|
146
|
+
registerAuditSummaryCommand(audit);
|
|
114
147
|
// Register `rea hook push-gate` — the stateless pre-push Codex gate
|
|
115
148
|
// called by `.husky/pre-push` and `.git/hooks/pre-push`.
|
|
116
149
|
registerHookCommand(program);
|
|
@@ -102,6 +102,28 @@ export interface EnsureGitignoreResult {
|
|
|
102
102
|
addedEntries: string[];
|
|
103
103
|
/** Non-fatal operator-facing messages (e.g. symlink refused, duplicate blocks). */
|
|
104
104
|
warnings: string[];
|
|
105
|
+
/**
|
|
106
|
+
* 0.41.0 — when computed under `dryRun: true`, the full would-be
|
|
107
|
+
* on-disk content. Omitted in real-write mode (the file IS the
|
|
108
|
+
* authoritative copy). Callers like `rea upgrade --check` use this
|
|
109
|
+
* to render a unified diff against the current on-disk content.
|
|
110
|
+
*/
|
|
111
|
+
previewContent?: string;
|
|
112
|
+
/**
|
|
113
|
+
* 0.41.0 — current on-disk content at planning time. `null` when
|
|
114
|
+
* the file does not exist. Omitted in real-write mode.
|
|
115
|
+
*/
|
|
116
|
+
previousContent?: string | null;
|
|
117
|
+
}
|
|
118
|
+
export interface EnsureGitignoreOptions {
|
|
119
|
+
/**
|
|
120
|
+
* When `true`, compute the action + would-be content without
|
|
121
|
+
* writing. Returns `previewContent` and `previousContent`. Default
|
|
122
|
+
* `false` (write-on).
|
|
123
|
+
*/
|
|
124
|
+
dryRun?: boolean;
|
|
125
|
+
/** Override the canonical entry list. Tests use this. */
|
|
126
|
+
entries?: readonly string[];
|
|
105
127
|
}
|
|
106
128
|
/**
|
|
107
129
|
* Main entry point. Idempotent: calling twice in a row produces `unchanged`
|
|
@@ -111,4 +133,4 @@ export interface EnsureGitignoreResult {
|
|
|
111
133
|
* init` and `rea upgrade` pass the default. Tests override to verify
|
|
112
134
|
* reconciliation.
|
|
113
135
|
*/
|
|
114
|
-
export declare function ensureReaGitignore(targetDir: string,
|
|
136
|
+
export declare function ensureReaGitignore(targetDir: string, optionsOrEntries?: EnsureGitignoreOptions | readonly string[]): Promise<EnsureGitignoreResult>;
|
|
@@ -271,7 +271,15 @@ async function writeAtomic(absPath, content) {
|
|
|
271
271
|
* init` and `rea upgrade` pass the default. Tests override to verify
|
|
272
272
|
* reconciliation.
|
|
273
273
|
*/
|
|
274
|
-
export async function ensureReaGitignore(targetDir,
|
|
274
|
+
export async function ensureReaGitignore(targetDir, optionsOrEntries = {}) {
|
|
275
|
+
// Back-compat: pre-0.41.0 callers passed `entries` as the second
|
|
276
|
+
// positional argument. Detect the legacy shape and forward to the
|
|
277
|
+
// options form so we don't break existing call sites.
|
|
278
|
+
const options = Array.isArray(optionsOrEntries)
|
|
279
|
+
? { entries: optionsOrEntries }
|
|
280
|
+
: optionsOrEntries;
|
|
281
|
+
const entries = options.entries ?? REA_GITIGNORE_ENTRIES;
|
|
282
|
+
const dryRun = options.dryRun === true;
|
|
275
283
|
const absPath = path.resolve(targetDir, GITIGNORE);
|
|
276
284
|
const warnings = [];
|
|
277
285
|
let existing;
|
|
@@ -280,18 +288,26 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
|
|
|
280
288
|
}
|
|
281
289
|
catch (err) {
|
|
282
290
|
warnings.push(err.message);
|
|
283
|
-
return {
|
|
291
|
+
return {
|
|
292
|
+
path: absPath,
|
|
293
|
+
action: 'unchanged',
|
|
294
|
+
addedEntries: [],
|
|
295
|
+
warnings,
|
|
296
|
+
...(dryRun ? { previousContent: null, previewContent: '' } : {}),
|
|
297
|
+
};
|
|
284
298
|
}
|
|
285
299
|
// Detect EOL so a CRLF repo stays CRLF and doesn't get torn. Codex F3.
|
|
286
300
|
const eol = existing !== null && existing.includes('\r\n') ? '\r\n' : '\n';
|
|
287
301
|
if (existing === null) {
|
|
288
302
|
const content = buildManagedBlock(entries, '\n') + '\n';
|
|
289
|
-
|
|
303
|
+
if (!dryRun)
|
|
304
|
+
await writeAtomic(absPath, content);
|
|
290
305
|
return {
|
|
291
306
|
path: absPath,
|
|
292
307
|
action: 'created',
|
|
293
308
|
addedEntries: [...entries],
|
|
294
309
|
warnings,
|
|
310
|
+
...(dryRun ? { previousContent: null, previewContent: content } : {}),
|
|
295
311
|
};
|
|
296
312
|
}
|
|
297
313
|
const lines = existing.split(/\r?\n/);
|
|
@@ -300,7 +316,13 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
|
|
|
300
316
|
if (block === 'duplicate') {
|
|
301
317
|
warnings.push(`${absPath} contains multiple '# === rea managed' blocks — refusing to modify. ` +
|
|
302
318
|
`Consolidate the managed blocks manually and rerun.`);
|
|
303
|
-
return {
|
|
319
|
+
return {
|
|
320
|
+
path: absPath,
|
|
321
|
+
action: 'unchanged',
|
|
322
|
+
addedEntries: [],
|
|
323
|
+
warnings,
|
|
324
|
+
...(dryRun ? { previousContent: existing, previewContent: existing } : {}),
|
|
325
|
+
};
|
|
304
326
|
}
|
|
305
327
|
if (block === null) {
|
|
306
328
|
// No managed block. Append one after a blank-line separator (unless the
|
|
@@ -315,12 +337,14 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
|
|
|
315
337
|
const separator = bodyLines.length === 0 ? [] : [''];
|
|
316
338
|
const newLines = [...bodyLines, ...separator, buildManagedBlock(entries, eol)];
|
|
317
339
|
const content = newLines.join(eol) + eol;
|
|
318
|
-
|
|
340
|
+
if (!dryRun)
|
|
341
|
+
await writeAtomic(absPath, content);
|
|
319
342
|
return {
|
|
320
343
|
path: absPath,
|
|
321
344
|
action: 'updated',
|
|
322
345
|
addedEntries: [...entries],
|
|
323
346
|
warnings,
|
|
347
|
+
...(dryRun ? { previousContent: existing, previewContent: content } : {}),
|
|
324
348
|
};
|
|
325
349
|
}
|
|
326
350
|
// Managed block exists — reconcile body lines.
|
|
@@ -332,6 +356,7 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
|
|
|
332
356
|
action: 'unchanged',
|
|
333
357
|
addedEntries: [],
|
|
334
358
|
warnings,
|
|
359
|
+
...(dryRun ? { previousContent: existing, previewContent: existing } : {}),
|
|
335
360
|
};
|
|
336
361
|
}
|
|
337
362
|
const newLines = [
|
|
@@ -342,11 +367,13 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
|
|
|
342
367
|
let content = newLines.join(eol);
|
|
343
368
|
if (hadTrailingNewline && !content.endsWith(eol))
|
|
344
369
|
content += eol;
|
|
345
|
-
|
|
370
|
+
if (!dryRun)
|
|
371
|
+
await writeAtomic(absPath, content);
|
|
346
372
|
return {
|
|
347
373
|
path: absPath,
|
|
348
374
|
action: 'updated',
|
|
349
375
|
addedEntries: added,
|
|
350
376
|
warnings,
|
|
377
|
+
...(dryRun ? { previousContent: existing, previewContent: content } : {}),
|
|
351
378
|
};
|
|
352
379
|
}
|