@bookedsolid/rea 0.40.0 → 0.42.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.
@@ -0,0 +1,535 @@
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. The per-record
122
+ * timestamp filter inside `computeAuditSummary` then drops
123
+ * out-of-window records during the scan. 0.41.0 round-3 P2 +
124
+ * 0.42.0 charter item 3: rotated filenames are NOT authoritative
125
+ * for "earliest contained record" — they are wall-clock at the
126
+ * ROTATION INSTANT, which can be days after the file's earliest
127
+ * contents when the rotation size cap is reached late. Pruning
128
+ * by filename therefore drops in-window records from
129
+ * conservatively-rotated logs (a rotated file from 7 days ago can
130
+ * still contain records from 14 days ago because the previous
131
+ * rotation event was 14 days ago). The cost of walking every
132
+ * rotated segment under `--since` is bounded by the rotation cap
133
+ * × number of segments — comfortably manageable in the
134
+ * summary-rollup setting where we already read every byte for
135
+ * the in-window scan; the win is correctness.
136
+ *
137
+ * Sort order is timestamp-ascending (by FILENAME stamp); the current
138
+ * `audit.jsonl` is always appended last (it is the newest segment
139
+ * of the chain).
140
+ */
141
+ async function resolveSummaryFileWalk(baseDir, windowStart) {
142
+ const reaDir = path.join(baseDir, REA_DIR);
143
+ const currentAudit = path.join(reaDir, AUDIT_FILE);
144
+ const files = [];
145
+ const rotated = await listRotatedAuditFiles(reaDir);
146
+ // Both `windowStart === null` and `windowStart` set: walk every
147
+ // rotated segment. Pre-0.42.0 the `windowStart` branch attempted to
148
+ // prune rotated files by their filename stamp ("rotated at >=
149
+ // windowStart minus one buffer file"). That was wrong: the filename
150
+ // stamp marks the ROTATION event, not the earliest record contained
151
+ // in the file. A rotated file's records can pre-date its filename
152
+ // stamp by days when the previous rotation cycle was long. Walking
153
+ // every rotated file and letting the per-record `timestamp >=
154
+ // windowStart` filter inside `computeAuditSummary` decide is the
155
+ // only correct approach: we never falsely drop an in-window record
156
+ // because of where it happens to live on disk. Reference:
157
+ // 0.41.0 round-3 P2 + 0.42.0 charter item 3.
158
+ //
159
+ // `windowStart === null` (no --since) already walks every rotated
160
+ // segment — same code path.
161
+ void windowStart; // intentionally unused — full-walk is correct in both modes
162
+ for (const name of rotated)
163
+ files.push(path.join(reaDir, name));
164
+ try {
165
+ const stat = await fs.stat(currentAudit);
166
+ if (stat.isFile())
167
+ files.push(currentAudit);
168
+ }
169
+ catch (e) {
170
+ if (e.code !== 'ENOENT')
171
+ throw e;
172
+ }
173
+ return files;
174
+ }
175
+ /** Map a rea Tier value (or unknown) to a stable bucket key for the
176
+ * by_tier table. Unknown values bucket to `'unknown'` so the rollup
177
+ * surfaces them rather than silently dropping.
178
+ */
179
+ function tierBucket(value) {
180
+ if (typeof value !== 'string' || value.length === 0)
181
+ return 'unknown';
182
+ return value;
183
+ }
184
+ function statusBucket(value) {
185
+ if (typeof value !== 'string' || value.length === 0)
186
+ return 'unknown';
187
+ return value;
188
+ }
189
+ /**
190
+ * Parse an ISO-8601 timestamp into a Date. Returns `null` on failure.
191
+ */
192
+ function parseTimestamp(raw) {
193
+ if (typeof raw !== 'string')
194
+ return null;
195
+ const d = new Date(raw);
196
+ return Number.isNaN(d.getTime()) ? null : d;
197
+ }
198
+ /**
199
+ * Sample-verify per-record hash integrity across the IN-WINDOW
200
+ * records only. Returns `ok`, `tampered`, or `unsampled` (no
201
+ * records in window).
202
+ *
203
+ * Sampling: take indices at offsets 0, n/k, 2n/k, …, (k-1)n/k where
204
+ * n is the in-window count and k is `CHAIN_SAMPLE_SIZE`. Always
205
+ * sample the first and last record.
206
+ *
207
+ * Codex round-2 P1: we do NOT verify `prev_hash` linkage between
208
+ * adjacent records in the filtered list. `appendAuditRecord` accepts
209
+ * caller-supplied timestamps, so a valid chain can look like
210
+ * `in-window → out-of-window → in-window`; in that case the second
211
+ * in-window record's `prev_hash` points to the filtered-out entry,
212
+ * not the previous survivor in our filtered view, and a linkage
213
+ * check would false-positive `tampered` on a healthy log. The
214
+ * authoritative chain walk lives in `rea audit verify`; summary
215
+ * stays advisory.
216
+ */
217
+ function sampleChainIntegrity(records) {
218
+ if (records.length === 0)
219
+ return { result: 'unsampled', samplesVerified: 0 };
220
+ const sampleIndices = new Set();
221
+ const k = Math.min(CHAIN_SAMPLE_SIZE, records.length);
222
+ for (let i = 0; i < k; i += 1) {
223
+ const idx = Math.floor((i * records.length) / k);
224
+ sampleIndices.add(idx);
225
+ }
226
+ sampleIndices.add(records.length - 1);
227
+ let samplesVerified = 0;
228
+ for (const i of sampleIndices) {
229
+ const r = records[i];
230
+ const { hash, ...rest } = r;
231
+ const recomputed = computeHash(rest);
232
+ if (recomputed !== hash) {
233
+ return { result: 'tampered', samplesVerified };
234
+ }
235
+ samplesVerified += 1;
236
+ }
237
+ return { result: 'ok', samplesVerified };
238
+ }
239
+ /**
240
+ * Compute the summary. Pure (read-only). Throws
241
+ * `AuditSummarySinceError` on bad `--since`; everything else is
242
+ * surfaced via the result.
243
+ */
244
+ export async function computeAuditSummary(options = {}) {
245
+ const baseDir = options.baseDir ?? process.cwd();
246
+ const now = options.now ?? new Date();
247
+ let windowSeconds = null;
248
+ let windowStart = null;
249
+ let windowEnd = null;
250
+ if (options.since !== undefined && options.since.length > 0) {
251
+ windowSeconds = parseDurationSeconds(options.since);
252
+ windowEnd = now;
253
+ windowStart = new Date(now.getTime() - windowSeconds * 1000);
254
+ }
255
+ const files = await resolveSummaryFileWalk(baseDir, windowStart);
256
+ const byToolName = {};
257
+ const byTier = {};
258
+ const byStatus = {};
259
+ const bySession = {};
260
+ let totalEvents = 0;
261
+ let earliest = null;
262
+ let latest = null;
263
+ // We only feed in-window records to the chain-sample check.
264
+ const inWindowRecords = [];
265
+ // 0.42.0 codex round 4 P2 + round 6 P2 (2026-05-16): reserved for
266
+ // future per-segment time-range metadata that would let us prove a
267
+ // skipped file is out of scope. Always empty under 0.42.0 — see
268
+ // the AuditSummaryResult.unreadable_segments docstring.
269
+ const unreadableSegments = [];
270
+ // We rebuild the actually-read file list as we go so the summary
271
+ // never claims to have scanned a file that was silently skipped.
272
+ // (Currently identical to `files` minus ENOENT entries since every
273
+ // other read error throws — kept as a separate accumulator so the
274
+ // shape stays correct when the future `unreadable_segments`
275
+ // soft-skip path lands.)
276
+ const actuallyScanned = [];
277
+ for (const filePath of files) {
278
+ let raw;
279
+ try {
280
+ raw = await fs.readFile(filePath, 'utf8');
281
+ }
282
+ catch (e) {
283
+ const errno = e.code;
284
+ if (errno === 'ENOENT')
285
+ continue;
286
+ // 0.42.0 codex round 4 P2 + round 5 P2 + round 6 P2 (2026-05-16):
287
+ // earlier rounds attempted to soft-skip unreadable rotations to
288
+ // accommodate backup-restore artifacts. Round 6 caught that the
289
+ // soft-skip is unsound: `resolveSummaryFileWalk` now enqueues
290
+ // every rotated segment under `--since` (filename-stamp pruning
291
+ // was correctly removed because the stamp marks the rotation
292
+ // event, not the earliest record contained), so we CANNOT prove
293
+ // an unreadable file is out of scope without reading it. A
294
+ // silent skip would mean `rea audit summary` could exit 0 with
295
+ // an undercount AND `chain_integrity: ok` while real in-window
296
+ // records went uncounted.
297
+ //
298
+ // Throwing with a precise, actionable error is the right call:
299
+ // the operator can chmod the file, move it out of .rea/, or
300
+ // delete it. `unreadable_segments` in the result is reserved
301
+ // for the never-reached future case where we can prove a file
302
+ // is genuinely out of scope (we'd need rotation start/end
303
+ // metadata for that — out of scope here).
304
+ throw new Error(`rea audit summary: cannot read ${filePath} (${errno ?? 'unknown errno'}). ` +
305
+ `An unreadable audit segment may contain in-window records, so the summary ` +
306
+ `would be silently incomplete. Fix permissions (e.g. \`chmod u+r ${filePath}\`), ` +
307
+ `or move the file out of \`.rea/\` if you no longer need it. The current ` +
308
+ `audit.jsonl is always required.`);
309
+ }
310
+ actuallyScanned.push(filePath);
311
+ for (const line of raw.split('\n')) {
312
+ if (line.length === 0)
313
+ continue;
314
+ let parsed;
315
+ try {
316
+ parsed = JSON.parse(line);
317
+ }
318
+ catch {
319
+ // Malformed line — `rea audit verify` is the tool for that.
320
+ // Summary just skips and moves on.
321
+ continue;
322
+ }
323
+ const ts = parseTimestamp(parsed.timestamp);
324
+ if (windowStart !== null && (ts === null || ts < windowStart))
325
+ continue;
326
+ // upper bound is `now`; future-dated records (skew, replay) are
327
+ // still counted — they're real records that landed in the file.
328
+ totalEvents += 1;
329
+ const toolName = typeof parsed.tool_name === 'string' && parsed.tool_name.length > 0
330
+ ? parsed.tool_name
331
+ : '(unknown)';
332
+ byToolName[toolName] = (byToolName[toolName] ?? 0) + 1;
333
+ const tier = tierBucket(parsed.tier);
334
+ byTier[tier] = (byTier[tier] ?? 0) + 1;
335
+ const status = statusBucket(parsed.status);
336
+ byStatus[status] = (byStatus[status] ?? 0) + 1;
337
+ const session = typeof parsed.session_id === 'string' && parsed.session_id.length > 0
338
+ ? parsed.session_id
339
+ : '(unknown)';
340
+ bySession[session] = (bySession[session] ?? 0) + 1;
341
+ const tsRaw = typeof parsed.timestamp === 'string' ? parsed.timestamp : null;
342
+ if (tsRaw !== null) {
343
+ if (earliest === null || tsRaw < earliest)
344
+ earliest = tsRaw;
345
+ if (latest === null || tsRaw > latest)
346
+ latest = tsRaw;
347
+ }
348
+ inWindowRecords.push(parsed);
349
+ }
350
+ }
351
+ const { result: chainIntegrity, samplesVerified } = sampleChainIntegrity(inWindowRecords);
352
+ // Suppress unused-variable warning for genesis hash by referencing
353
+ // it: the chain-sample check uses it implicitly via the relaxed
354
+ // first-record rule. Kept as an import-time anchor for the linkage
355
+ // contract documented in `sampleChainIntegrity`.
356
+ void GENESIS_HASH;
357
+ return {
358
+ schema_version: AUDIT_SUMMARY_SCHEMA_VERSION,
359
+ window_seconds: windowSeconds,
360
+ window_start: windowStart !== null ? windowStart.toISOString() : null,
361
+ window_end: windowEnd !== null ? windowEnd.toISOString() : null,
362
+ // 0.42.0 codex round 4 P2: report only the files actually read.
363
+ // Unreadable rotations are reported separately under
364
+ // `unreadable_segments` so consumers can tell the difference
365
+ // between "scanned and empty" and "skipped because permissions".
366
+ files_scanned: actuallyScanned,
367
+ unreadable_segments: unreadableSegments,
368
+ total_events: totalEvents,
369
+ by_tool_name: byToolName,
370
+ by_tier: byTier,
371
+ by_status: byStatus,
372
+ by_session: bySession,
373
+ session_count: Object.keys(bySession).length,
374
+ earliest_timestamp: earliest,
375
+ latest_timestamp: latest,
376
+ chain_integrity: chainIntegrity,
377
+ chain_samples_verified: samplesVerified,
378
+ };
379
+ }
380
+ function sortBucket(bucket) {
381
+ return Object.entries(bucket).sort((a, b) => {
382
+ if (b[1] !== a[1])
383
+ return b[1] - a[1];
384
+ return a[0].localeCompare(b[0]);
385
+ });
386
+ }
387
+ function renderBucket(d, total) {
388
+ const lines = [];
389
+ lines.push(`${d.title}:`);
390
+ const visible = d.limit !== undefined ? d.entries.slice(0, d.limit) : d.entries;
391
+ const overflow = d.limit !== undefined ? d.entries.slice(d.limit) : [];
392
+ const maxNameLen = visible.reduce((m, [n]) => Math.max(m, n.length), 0);
393
+ for (const [name, count] of visible) {
394
+ const pad = ' '.repeat(maxNameLen - name.length + 2);
395
+ const pct = total > 0 ? ` (${((count * 100) / total).toFixed(1)}%)` : '';
396
+ lines.push(` ${name}${pad}${String(count).padStart(6)}${pct}`);
397
+ }
398
+ if (overflow.length > 0) {
399
+ const overflowSum = overflow.reduce((s, [, n]) => s + n, 0);
400
+ const pad = ' '.repeat(Math.max(0, maxNameLen - 7) + 2);
401
+ const pct = total > 0 ? ` (${((overflowSum * 100) / total).toFixed(1)}%)` : '';
402
+ lines.push(` (other)${pad}${String(overflowSum).padStart(6)}${pct}`);
403
+ }
404
+ return lines.join('\n');
405
+ }
406
+ /**
407
+ * Render the result as a human-readable terminal block. Designed for
408
+ * the default `rea audit summary` invocation; `--json` callers bypass
409
+ * this entirely.
410
+ */
411
+ export function renderAuditSummary(result) {
412
+ const lines = [];
413
+ const windowLabel = result.window_seconds !== null ? formatDurationShort(result.window_seconds) : 'all time';
414
+ lines.push(`rea audit summary (${windowLabel})`);
415
+ lines.push('─'.repeat(40));
416
+ lines.push(`total events: ${String(result.total_events).padStart(6)}`);
417
+ lines.push(`sessions: ${String(result.session_count).padStart(6)}`);
418
+ if (result.session_count > 0) {
419
+ const avg = result.total_events / result.session_count;
420
+ lines.push(`events/session avg: ${avg.toFixed(1).padStart(6)}`);
421
+ }
422
+ lines.push('');
423
+ if (result.total_events === 0) {
424
+ lines.push(result.window_seconds !== null
425
+ ? 'No events in the requested window.'
426
+ : 'No events in the audit log.');
427
+ lines.push('');
428
+ if (result.files_scanned.length === 0) {
429
+ lines.push('(no audit files found — has `rea serve` ever run?)');
430
+ lines.push('');
431
+ }
432
+ // 0.42.0 codex round 4 P2: even in the zero-events early-return,
433
+ // surface unreadable segments so the operator sees the gap.
434
+ if (result.unreadable_segments.length > 0) {
435
+ lines.push(`unreadable rotated segments: ${String(result.unreadable_segments.length)} ` +
436
+ `(see stderr for paths; fix permissions and re-run to include them)`);
437
+ lines.push('');
438
+ }
439
+ return lines.join('\n');
440
+ }
441
+ const total = result.total_events;
442
+ lines.push(renderBucket({ title: 'by tool_name', entries: sortBucket(result.by_tool_name), limit: 12 }, total));
443
+ lines.push('');
444
+ lines.push(renderBucket({ title: 'by tier', entries: sortBucket(result.by_tier) }, total));
445
+ lines.push('');
446
+ lines.push(renderBucket({ title: 'by status', entries: sortBucket(result.by_status) }, total));
447
+ lines.push('');
448
+ // Sessions can balloon — limit to 5 by default.
449
+ lines.push(renderBucket({ title: 'top sessions', entries: sortBucket(result.by_session), limit: 5 }, total));
450
+ lines.push('');
451
+ if (result.earliest_timestamp !== null && result.latest_timestamp !== null) {
452
+ lines.push(`window: ${result.earliest_timestamp} → ${result.latest_timestamp}`);
453
+ }
454
+ const chainLabel = result.chain_integrity === 'ok'
455
+ ? `ok (${String(result.chain_samples_verified)} sample${result.chain_samples_verified === 1 ? '' : 's'} verified)`
456
+ : result.chain_integrity === 'tampered'
457
+ ? 'TAMPERED — run `rea audit verify` for the exact break'
458
+ : 'unsampled (no records in window)';
459
+ lines.push(`chain integrity: ${chainLabel}`);
460
+ lines.push(`files scanned: ${String(result.files_scanned.length)}`);
461
+ // 0.42.0 codex round 4 P2 (2026-05-16): surface unreadable rotated
462
+ // segments so an operator scanning the rendered summary doesn't
463
+ // miss a skipped archive that the JSON consumers can see.
464
+ if (result.unreadable_segments.length > 0) {
465
+ lines.push(`unreadable rotated segments: ${String(result.unreadable_segments.length)} ` +
466
+ `(see stderr for paths; fix permissions and re-run to include them)`);
467
+ }
468
+ lines.push('');
469
+ return lines.join('\n');
470
+ }
471
+ /**
472
+ * Compact human duration label for the header. `86400` → `last 24h`,
473
+ * `604800` → `last 7d`, etc. We pick the coarsest single unit that
474
+ * yields an integer; otherwise fall back to seconds.
475
+ */
476
+ function formatDurationShort(seconds) {
477
+ const units = [
478
+ ['w', 60 * 60 * 24 * 7],
479
+ ['d', 60 * 60 * 24],
480
+ ['h', 60 * 60],
481
+ ['m', 60],
482
+ ['s', 1],
483
+ ];
484
+ for (const [unit, factor] of units) {
485
+ if (seconds % factor === 0) {
486
+ return `last ${String(seconds / factor)}${unit}`;
487
+ }
488
+ }
489
+ return `last ${String(seconds)}s`;
490
+ }
491
+ /** Commander entrypoint. */
492
+ export async function runAuditSummary(options) {
493
+ let result;
494
+ try {
495
+ result = await computeAuditSummary({
496
+ ...(options.since !== undefined ? { since: options.since } : {}),
497
+ ...(options.now !== undefined ? { now: options.now } : {}),
498
+ });
499
+ }
500
+ catch (e) {
501
+ if (e instanceof AuditSummarySinceError) {
502
+ err(`rea audit summary: ${e.message}`);
503
+ process.exit(1);
504
+ }
505
+ throw e;
506
+ }
507
+ if (options.json === true) {
508
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
509
+ return;
510
+ }
511
+ process.stdout.write(renderAuditSummary(result));
512
+ if (result.chain_integrity === 'tampered') {
513
+ // Non-zero exit gives CI users a single signal for "something is
514
+ // off"; the JSON path stays exit 0 so machine consumers can react
515
+ // to the integrity field directly.
516
+ log('summary complete with tampered chain — see message above.');
517
+ process.exit(1);
518
+ }
519
+ }
520
+ /**
521
+ * Register `rea audit summary` under the `audit` command group.
522
+ */
523
+ export function registerAuditSummaryCommand(auditCommand) {
524
+ auditCommand
525
+ .command('summary')
526
+ .description('High-level audit-log summary — counts by tool_name, tier, session, status; window timestamps; sample-verified chain integrity. Read-only.')
527
+ .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.')
528
+ .option('--json', 'emit a JSON document instead of the human-readable table')
529
+ .action(async (opts) => {
530
+ await runAuditSummary({
531
+ ...(opts.since !== undefined ? { since: opts.since } : {}),
532
+ ...(opts.json === true ? { json: true } : {}),
533
+ });
534
+ });
535
+ }
@@ -138,6 +138,26 @@ export interface PolicyReaderProbes {
138
138
  * ignore the argument; the default production probe uses it.
139
139
  */
140
140
  python3PyYamlReachable?: (baseDir: string) => boolean;
141
+ /**
142
+ * 0.42.0 codex round 5 P2 (2026-05-16) — execution probe for the
143
+ * python3 list-walker branch in `policy_reader_get_list`. That
144
+ * branch needs to spawn `python3 -c "..."` with `import json` from
145
+ * stdlib; PyYAML is irrelevant. The check is execution-based (not
146
+ * PATH-only) because a `python3` symlink can resolve on PATH but
147
+ * fail to start in the current sandbox (dangling pyenv/asdf stub,
148
+ * permission-denied interpreter, missing dynamic libs). A PATH-only
149
+ * check would let the doctor declare `warn` on a box where the
150
+ * shim will actually fall through to Tier 3 — masking a real
151
+ * enforcement gap for list-valued policy keys.
152
+ *
153
+ * The probe runs `python3 -c "import json; print('ok')"` with the
154
+ * same env scrub as the PyYAML probe (PYTHONPATH/PYTHONHOME/
155
+ * PYTHONSTARTUP unset, PYTHONSAFEPATH=1, sys.path scrubbed) so a
156
+ * malicious repo cannot plant a `./json.py` that shadows stdlib
157
+ * and falsely report `true` while the real loader (which scrubs)
158
+ * fails.
159
+ */
160
+ python3ListWalkerReachable?: (baseDir: string) => boolean;
141
161
  awkOnPath?: () => string | null;
142
162
  jqOnPath?: () => string | null;
143
163
  }
@@ -183,12 +203,12 @@ export declare function checkPolicyReaderTier2(baseDir: string, probes?: PolicyR
183
203
  * Practically always present (POSIX requirement).
184
204
  *
185
205
  * 0.40.0 charter item 2 — conditional verdict, refined by codex
186
- * round 1 P2:
206
+ * round 1 P2 (0.40.0) and round 2 P2 (0.42.0):
187
207
  * - awk present → `pass`
188
208
  * - awk absent AND Tier 2 reachable → `warn`
189
209
  * (Tier 2 implies python3, which is a list-walker)
190
210
  * - awk absent AND Tier 1 reachable AND a list walker
191
- * (jq OR python3) is on PATH → `warn`
211
+ * (jq OR full Tier-2 reachable) is usable → `warn`
192
212
  * - awk absent AND Tier 1 reachable BUT no list walker → `fail`
193
213
  * (codex round 1 P2 — list-valued policy reads silently
194
214
  * fail-closed even though scalar reads work, so the
@@ -207,9 +227,29 @@ export declare function checkPolicyReaderTier2(baseDir: string, probes?: PolicyR
207
227
  * functional box that has python3 + jq + the rea CLI all wired but
208
228
  * happens to lack awk.
209
229
  *
230
+ * List-iteration semantic (clarifying note for codex round 2 P2,
231
+ * 2026-05-16): `policy_reader_get_list` in
232
+ * `hooks/_lib/policy-reader.sh` walks the cached subtree JSON via
233
+ * `jq` OR `python3` (stdlib-only — `json` module, no PyYAML import).
234
+ * PyYAML is only needed for Tier 2 itself (YAML PARSING into JSON),
235
+ * NOT for iterating the already-parsed JSON arrays at list-read time.
236
+ *
237
+ * Codex round 5 P2 (2026-05-16): the "list walker" predicate uses
238
+ * `python3ListWalkerReachable` — an EXECUTION probe that actually
239
+ * spawns `python3 -c "import json"` — instead of `python3OnPath`. A
240
+ * PATH-only check passes for broken pyenv/asdf shims, dangling
241
+ * symlinks, and sandboxed environments where the interpreter cannot
242
+ * start; in those cases the shim's list-walker branch would actually
243
+ * fail and `blocked_paths`/`protected_writes` enforcement would
244
+ * silently break while doctor reported `warn`. The execution probe
245
+ * mirrors `defaultPython3PyYamlReachable` exactly but swaps the
246
+ * `import yaml` for `import json` so it's not gated on PyYAML
247
+ * availability (which is irrelevant to list iteration).
248
+ *
210
249
  * Takes `baseDir` so it can evaluate Tier 1's two-stage check (dist
211
- * present + CLI invokable) and Tier 2's reachability. Probes are
212
- * threaded through identically.
250
+ * present + CLI invokable), Tier 2's reachability, and the
251
+ * list-walker execution probe. All probes are threaded through
252
+ * identically.
213
253
  */
214
254
  export declare function checkPolicyReaderTier3(baseDir: string, probes?: PolicyReaderProbes): CheckResult;
215
255
  /**