@bookedsolid/rea 0.46.0 → 0.47.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/MIGRATING.md CHANGED
@@ -528,6 +528,95 @@ under `--since` and lets the per-record timestamp filter drop the
528
528
  out-of-window entries. Correctness over micro-optimization;
529
529
  `rea audit summary` performance is unchanged in practice.
530
530
 
531
+ ## Audit observability completion (added in 0.47.0)
532
+
533
+ 0.46.0 shipped `rea audit by-tool` and `rea audit timeline`. 0.47.0
534
+ rounds out the observability surface with two timeline ergonomics fixes
535
+ and a new refusal-debugging reader:
536
+
537
+ ### `rea audit timeline` — helpful MAX_BUCKETS errors + auto-clamp
538
+
539
+ Pre-0.47.0, `rea audit timeline --bucket=15m --since=21d` (= 2016
540
+ buckets, just past the 2000-bucket ceiling) rejected with a generic
541
+ "use a larger --bucket or narrower --since" message. The 0.47.0 error
542
+ now carries concrete remediation:
543
+
544
+ ```text
545
+ rea audit timeline: --bucket=15m × --since=21d = 2016 buckets exceeds
546
+ MAX_BUCKETS=2000. Try --bucket=1h (504 buckets) or --since=20d 20h
547
+ (1999 buckets).
548
+ ```
549
+
550
+ For the related "I omitted `--since` and the audit log spans a year"
551
+ case, the timeline now AUTO-CLAMPS to the widest window that fits at
552
+ the requested cadence rather than throwing. The clamp is surfaced
553
+ inline in human output:
554
+
555
+ ```text
556
+ rea audit timeline (clamped to ~1999h of newest activity, hourly)
557
+ ────────────────────────────────────────
558
+ note: --since not specified; auto-clamped to newest 2000 buckets
559
+ (~1999h span at --bucket=1h). Pass --since=DUR to anchor at
560
+ now, or rerun with a WIDER --bucket (current 1h) to fit the
561
+ full log.
562
+
563
+ ```
564
+
565
+ JSON consumers see the clamp as a new `clamped_since` field — `null`
566
+ in the common case, a duration string (e.g. `"1999h"`) when the
567
+ clamp fired. The field is informational, not reproducible: `--since`
568
+ always anchors at `now`, so a clamp anchored at an older record
569
+ cannot be round-tripped through `--since=<clamped_since>`. Use the
570
+ field to detect that clamping occurred and to size the rendered
571
+ window in dashboards. For a fully reproducible view, pass `--since`
572
+ or `--bucket` explicitly. Schema version is unchanged (still v1) —
573
+ the field is purely additive. `window.start/end/seconds` is also
574
+ nulled out on sparse-log clamps where the kept buckets don't form a
575
+ contiguous time lattice, so `total_events / window.seconds` never
576
+ derives a misleading rate.
577
+
578
+ ### `rea audit top-blocks` — debugging "why was that refused?"
579
+
580
+ A new subcommand surfaces the most recent refusal events (any record
581
+ whose `status` is `denied` or `error`) from the audit log:
582
+
583
+ ```bash
584
+ rea audit top-blocks # last 20 refusals, all time
585
+ rea audit top-blocks --since=24h # last 24h
586
+ rea audit top-blocks --since=7d --limit=50 # last week, top 50
587
+ rea audit top-blocks --json # dashboard shape
588
+ ```
589
+
590
+ Each row carries the short hash (first 8 chars), full timestamp, tool
591
+ name, and the refusal reason (sourced from the record's `error` field;
592
+ truncated to ~80 chars in human output, full text in JSON). Sorted
593
+ newest-first so the most recent refusals are at the top.
594
+
595
+ Use this when an agent reports "the hook blocked my push" or "the
596
+ write was refused" and you need the exact reason without grepping
597
+ `.rea/audit.jsonl` by hand.
598
+
599
+ JSON shape (stable, v1):
600
+
601
+ ```json
602
+ {
603
+ "schema_version": 1,
604
+ "since": "24h",
605
+ "limit": 20,
606
+ "window": { "seconds": 86400, "start": "...", "end": "..." },
607
+ "total_matched": 4,
608
+ "events": [
609
+ { "hash": "...", "timestamp": "...", "tool": "Bash",
610
+ "status": "denied", "reason": "...", "session_id": "..." }
611
+ ],
612
+ "files_scanned": ["/abs/path/.rea/audit.jsonl"]
613
+ }
614
+ ```
615
+
616
+ `total_matched` is the pre-limit count, so dashboards can show
617
+ "20 of 47 refusals in window". Walk scope mirrors the sibling audit
618
+ readers — current `.rea/audit.jsonl` PLUS every rotated segment.
619
+
531
620
  ## Policy knobs worth setting
532
621
 
533
622
  For consumers with a long-running migration branch (>30 commits since
@@ -112,6 +112,16 @@ export interface AuditTimelineResult {
112
112
  /** Index of the bucket with the highest count. `-1` when no events. */
113
113
  peak_index: number;
114
114
  files_scanned: string[];
115
+ /**
116
+ * 0.47.0 charter item 2: when `--since` was NOT specified and the
117
+ * audit log spans more than `MAX_BUCKETS` buckets at the requested
118
+ * cadence, the timeline auto-clamps the window to the widest duration
119
+ * that fits. This field carries the duration string that was actually
120
+ * applied (e.g. `"7d"`) — `null` when no clamping fired (the common
121
+ * case). Dashboard consumers use this to flag "the window you saw is
122
+ * not the whole log" in their UI.
123
+ */
124
+ clamped_since: string | null;
115
125
  }
116
126
  export interface ComputeAuditTimelineOptions {
117
127
  /** Override CWD. Tests set this; production uses `process.cwd()`. */
@@ -134,6 +144,16 @@ export interface ComputeAuditTimelineOptions {
134
144
  * value but `MAX_BUCKETS` will bound the rendered output.
135
145
  */
136
146
  export declare function resolveBucketSeconds(raw: string): number;
147
+ /**
148
+ * Format a duration in seconds as the coarsest single-unit compact
149
+ * string that round-trips through `parseDurationSeconds`. Mirrors the
150
+ * shape `--since` accepts (`s`/`m`/`h`/`d`/`w`).
151
+ *
152
+ * 0.47.0 charter item 1: powers the helpful-error suggestion + the
153
+ * auto-clamp `clamped_since` field. The largest-unit pass keeps the
154
+ * suggestion readable — `"21d"` not `"1814400s"`.
155
+ */
156
+ export declare function formatDurationCompact(seconds: number): string;
137
157
  /**
138
158
  * Compute the bucketed timeline. Pure (read-only). Throws
139
159
  * `AuditTimelineOptionError` on bad `--since` / `--bucket`; throws on
@@ -165,6 +165,107 @@ function alignToBucket(epochMs, bucketSeconds) {
165
165
  const bucketMs = bucketSeconds * 1000;
166
166
  return Math.floor(epochMs / bucketMs) * bucketMs;
167
167
  }
168
+ /**
169
+ * Format a duration in seconds as the coarsest single-unit compact
170
+ * string that round-trips through `parseDurationSeconds`. Mirrors the
171
+ * shape `--since` accepts (`s`/`m`/`h`/`d`/`w`).
172
+ *
173
+ * 0.47.0 charter item 1: powers the helpful-error suggestion + the
174
+ * auto-clamp `clamped_since` field. The largest-unit pass keeps the
175
+ * suggestion readable — `"21d"` not `"1814400s"`.
176
+ */
177
+ export function formatDurationCompact(seconds) {
178
+ if (!Number.isFinite(seconds) || seconds <= 0)
179
+ return '0s';
180
+ const units = [
181
+ ['w', 60 * 60 * 24 * 7],
182
+ ['d', 60 * 60 * 24],
183
+ ['h', 60 * 60],
184
+ ['m', 60],
185
+ ['s', 1],
186
+ ];
187
+ for (const [unit, factor] of units) {
188
+ if (seconds % factor === 0) {
189
+ return `${String(seconds / factor)}${unit}`;
190
+ }
191
+ }
192
+ return `${String(seconds)}s`;
193
+ }
194
+ /**
195
+ * 0.47.0 charter item 1: build a helpful error message for the
196
+ * MAX_BUCKETS guard. Computes a concrete "use --bucket=X" and
197
+ * "use --since=Y" suggestion based on the actual inputs, so the
198
+ * operator sees the next step inline instead of having to do the
199
+ * division themselves.
200
+ *
201
+ * Strategy:
202
+ * - "Try a wider bucket" — smallest unit from {1h, 4h, 1d, 1w} that
203
+ * brings projected ≤ MAX_BUCKETS. Falls back to a concrete second
204
+ * count if no unit fits (extreme `--since`).
205
+ * - "Try a narrower since" — largest multiple of the requested bucket
206
+ * that fits under MAX_BUCKETS, rendered compactly.
207
+ *
208
+ * The error text always includes the substrings `bucket=`, `since=`,
209
+ * and `Try` so test assertions can pin the shape.
210
+ */
211
+ function bucketOverflowMessage(windowSeconds, bucketSeconds, rawBucket, rawSince, sinceImplicit) {
212
+ const projected = Math.ceil(windowSeconds / bucketSeconds);
213
+ // Candidate wider buckets, in ascending size — pick the first that fits.
214
+ const allCandidates = [
215
+ ['1h', 60 * 60],
216
+ ['4h', 4 * 60 * 60],
217
+ ['1d', 60 * 60 * 24],
218
+ ['1w', 60 * 60 * 24 * 7],
219
+ ];
220
+ const widerCandidates = allCandidates.filter((entry) => entry[1] > bucketSeconds);
221
+ let bucketSuggestion = null;
222
+ let bucketSuggestionCount = null;
223
+ for (const [label, secs] of widerCandidates) {
224
+ // Account for alignment slack: a window of N seconds at bucket
225
+ // size S emits up to `ceil(N/S) + 1` buckets after alignment
226
+ // (lower-edge + upper-edge alignment can each contribute one
227
+ // extra bucket vs the naive division). Codex round-4 P2 (0.47.0):
228
+ // suggesting a bucket where `ceil(N/S) === MAX_BUCKETS` would
229
+ // still re-throw at the post-alignment guard. Use the +1
230
+ // worst-case so the operator's retry actually succeeds.
231
+ const cnt = Math.ceil(windowSeconds / secs) + 1;
232
+ if (cnt <= MAX_BUCKETS) {
233
+ bucketSuggestion = label;
234
+ bucketSuggestionCount = cnt;
235
+ break;
236
+ }
237
+ }
238
+ // Largest --since that fits at the requested bucket size, rendered
239
+ // compactly. Subtract one bucket so the suggested value survives the
240
+ // post-alignment guard regardless of where `now` falls on the
241
+ // bucket lattice — codex round-1 P2 (0.47.0): a window of exactly
242
+ // `MAX_BUCKETS * bucketSeconds` aligns to MAX_BUCKETS+1 buckets in
243
+ // the common case (`now` not already on a boundary), so the
244
+ // operator pasting our suggestion would hit the same error they
245
+ // just got. `(MAX_BUCKETS - 1) * bucketSeconds` leaves alignment
246
+ // slack on either edge.
247
+ const fittingSinceSeconds = (MAX_BUCKETS - 1) * bucketSeconds;
248
+ const sinceSuggestion = formatDurationCompact(fittingSinceSeconds);
249
+ const sinceSuggestionCount = Math.floor(fittingSinceSeconds / bucketSeconds);
250
+ const parts = [];
251
+ const reason = sinceImplicit
252
+ ? `--since not specified; defaulting to full audit log (${rawSince}) at --bucket=${rawBucket} = ${String(projected)} buckets exceeds MAX_BUCKETS=${String(MAX_BUCKETS)}.`
253
+ : `--bucket=${rawBucket} × --since=${rawSince} = ${String(projected)} buckets exceeds MAX_BUCKETS=${String(MAX_BUCKETS)}.`;
254
+ parts.push(reason);
255
+ const suggestions = [];
256
+ if (bucketSuggestion !== null && bucketSuggestionCount !== null) {
257
+ suggestions.push(`--bucket=${bucketSuggestion} (${String(bucketSuggestionCount)} buckets)`);
258
+ }
259
+ suggestions.push(`--since=${sinceSuggestion} (${String(sinceSuggestionCount)} buckets)`);
260
+ parts.push(`Try ${suggestions.join(' or ')}.`);
261
+ return parts.join(' ');
262
+ }
263
+ // 0.47.0 round-3 (codex P1+P2): the pre-scan `measureLogBounds`
264
+ // helper was removed. The all-time auto-clamp now runs as a
265
+ // post-scan recovery against observed bucket keys (the only data
266
+ // that can't be fooled by caller-supplied timestamps or empty
267
+ // current-file edge cases). See the "post-scan auto-clamp" branch
268
+ // inside `computeAuditTimeline`.
168
269
  /**
169
270
  * Compute the bucketed timeline. Pure (read-only). Throws
170
271
  * `AuditTimelineOptionError` on bad `--since` / `--bucket`; throws on
@@ -181,6 +282,7 @@ export async function computeAuditTimeline(options = {}) {
181
282
  let windowSeconds = null;
182
283
  let windowStart = null;
183
284
  let windowEnd = null;
285
+ let clampedSince = null;
184
286
  if (options.since !== undefined && options.since.length > 0) {
185
287
  try {
186
288
  windowSeconds = parseDurationSeconds(options.since);
@@ -194,16 +296,28 @@ export async function computeAuditTimeline(options = {}) {
194
296
  windowEnd = now;
195
297
  windowStart = new Date(now.getTime() - windowSeconds * 1000);
196
298
  // Guard against runaway bucket counts under a wide --since with a
197
- // tiny --bucket. A simple ceiling is cheaper than rendering 100k
198
- // empty rows and gives the operator a clear remediation path.
299
+ // tiny --bucket. 0.47.0 charter item 1: deliver a concrete-suggestion
300
+ // error rather than the prior "use a larger --bucket or narrower
301
+ // --since" generic line — the operator should see the next step
302
+ // inline.
199
303
  const projected = Math.ceil(windowSeconds / bucketSeconds);
200
304
  if (projected > MAX_BUCKETS) {
201
- throw new AuditTimelineOptionError(`--bucket=${bucketRaw} with --since=${options.since} would emit ` +
202
- `${String(projected)} buckets (max ${String(MAX_BUCKETS)}). ` +
203
- `Use a larger --bucket or narrower --since.`);
305
+ throw new AuditTimelineOptionError(bucketOverflowMessage(windowSeconds, bucketSeconds, bucketRaw, options.since, false));
204
306
  }
205
307
  }
206
308
  const files = await resolveTimelineFileWalk(baseDir);
309
+ // 0.47.0 charter item 2: auto-clamp on long-history repos is
310
+ // implemented as a POST-SCAN recovery, not a pre-scan guess. Codex
311
+ // round-3 P1: a pre-scan clamp based on `latestMs - earliestMs`
312
+ // span is wrong for the no-`--since` path, which normally emits
313
+ // ONLY event-bearing buckets. A sparse long-lived repo (two
314
+ // records a year apart at `--bucket=1h`) has a 365d span but a
315
+ // 2-bucket result — pre-clamping would incorrectly drop one of
316
+ // those events. We must let the scan see what bucket count the
317
+ // actual records produce, then clamp only if that exceeds
318
+ // MAX_BUCKETS. The clamp anchor uses the busiest in-data range
319
+ // (max observed timestamp + alignment buffer), so we never have
320
+ // to guess from disk metadata. See the post-scan branch below.
207
321
  // Bucket key is the aligned epoch-ms boundary; value is the count.
208
322
  const buckets = new Map();
209
323
  let totalEvents = 0;
@@ -275,8 +389,19 @@ export async function computeAuditTimeline(options = {}) {
275
389
  // the renderer.
276
390
  const emit = Math.floor((endKey - startKey) / stepMs) + 1;
277
391
  if (emit > MAX_BUCKETS) {
278
- throw new AuditTimelineOptionError(`--bucket / --since would emit ${String(emit)} buckets after alignment ` +
279
- `(max ${String(MAX_BUCKETS)}). Use a larger --bucket or narrower --since.`);
392
+ // Post-alignment overflow is a near-miss vs the pre-scan
393
+ // projection check (alignment can add at most one bucket on
394
+ // either edge). Codex round-6 P3 (0.47.0): the helpful-error
395
+ // builder previously recomputed the projected count from
396
+ // `windowSeconds` and could end up saying "= 2000 buckets
397
+ // exceeds MAX_BUCKETS=2000" when the actual post-alignment
398
+ // count was 2001. Inflate `windowSeconds` by enough to make
399
+ // the projection match the actual aligned emit count — that
400
+ // way the operator sees a consistent number, and the
401
+ // remediation suggestions inherit the same +1 bias.
402
+ const effectiveSince = clampedSince ?? (options.since ?? formatDurationCompact(windowSeconds ?? 0));
403
+ const reportedSeconds = Math.max(windowSeconds ?? 0, (emit - 1) * bucketSeconds + 1);
404
+ throw new AuditTimelineOptionError(bucketOverflowMessage(reportedSeconds, bucketSeconds, bucketRaw, effectiveSince, options.since === undefined || options.since.length === 0));
280
405
  }
281
406
  for (let k = startKey; k <= endKey; k += stepMs) {
282
407
  result.push({
@@ -288,20 +413,107 @@ export async function computeAuditTimeline(options = {}) {
288
413
  }
289
414
  else if (buckets.size > 0) {
290
415
  const keys = Array.from(buckets.keys()).sort((a, b) => a - b);
416
+ const stepMs = bucketSeconds * 1000;
291
417
  if (keys.length > MAX_BUCKETS) {
292
- // Without --since the operator hasn't asked for a fixed shape,
293
- // but a runaway result still needs a guard. Refuse rather than
294
- // truncate silently.
295
- throw new AuditTimelineOptionError(`Without --since, the audit log spans ${String(keys.length)} ${bucketRaw} buckets ` +
296
- `(max ${String(MAX_BUCKETS)}). Pass --since to scope, or use a larger --bucket.`);
418
+ // 0.47.0 charter item 2 (post-scan auto-clamp): the actual
419
+ // observed bucket count exceeds MAX_BUCKETS. The no-`--since`
420
+ // path emits only event-bearing buckets (not a zero-filled
421
+ // lattice), so clamping by a contiguous time window would
422
+ // discard most of the newest activity on SPARSE logs (codex
423
+ // round-5 P1: e.g. one record/day across 2001 days at
424
+ // --bucket=1h, a time-window clamp keeps ~83 buckets — the
425
+ // newest-2000-keys clamp keeps all 2000 newest event-bearing
426
+ // buckets). The right shape: slice the keys array to the newest
427
+ // MAX_BUCKETS entries directly.
428
+ //
429
+ // Codex round-3 P1: clamp on OBSERVED data, not guessed span.
430
+ // Codex round-3 P2: observed-max timestamp sidesteps both the
431
+ // empty-current-file case and the out-of-order-timestamp case.
432
+ // Codex round-4 P2: full MAX_BUCKETS budget (the +1 alignment
433
+ // slack doesn't apply when cherry-picking from observed keys).
434
+ const fittingBuckets = MAX_BUCKETS;
435
+ const kept = keys.slice(keys.length - fittingBuckets);
436
+ const startKey = kept[0];
437
+ const anchorKey = kept[kept.length - 1];
438
+ // Determine whether the kept buckets form a CONTIGUOUS lattice
439
+ // (every bucket between startKey and anchorKey is present) or a
440
+ // SPARSE one (gaps inside). The no-`--since` path emits only
441
+ // event-bearing buckets, so a sparse clamp is the common case.
442
+ // Codex round-6 P2 (0.47.0): if we filled `window.start/end/
443
+ // seconds` with the bucket span of a sparse clamp, the JSON
444
+ // would lie to dashboard consumers — `total_events / window.
445
+ // seconds` would derive a wildly-wrong rate, and the operator
446
+ // could NOT reproduce the view by re-running with
447
+ // `--since=<clamped_since>` (it would either error or include
448
+ // far more buckets). Treat the two shapes distinctly:
449
+ // - contiguous: report the time-window shape (operator can
450
+ // paste `--since=<clamped_since>` to reproduce).
451
+ // - sparse: leave `window` null (no reproducible duration
452
+ // exists), but still report `clamped_since` so callers
453
+ // know the kept-bucket count was budgeted.
454
+ const expectedContiguousCount = Math.floor((anchorKey - startKey) / stepMs) + 1;
455
+ const isContiguous = expectedContiguousCount === kept.length;
456
+ // 0.47.0 charter item 2: `clamped_since` is ALWAYS a duration
457
+ // string (per the charter `clamped_since: "<DUR>"` contract).
458
+ // It carries the approximate time span the rendered window
459
+ // covers — informative, not necessarily paste-back-safe.
460
+ //
461
+ // Codex round-8 P2 (0.47.0): on stale logs (latest record
462
+ // hours/days ago) `--since=<DUR>` would NOT reproduce the
463
+ // returned data because `--since` always anchors on `now`,
464
+ // not on the audit's latest record. The reproducibility
465
+ // promise we entertained briefly across rounds 7-8 is
466
+ // inherently unsound — `--since` semantics fix one side of
467
+ // the window (now), so any clamp anchored at an older
468
+ // timestamp can't round-trip through it. The `note:` line in
469
+ // human output now describes the field as APPROXIMATE
470
+ // rather than pasteable.
471
+ //
472
+ // Codex round-8 P2 (0.47.0): the sparse-clamp branch
473
+ // previously emitted `"newest 2000 buckets"` for clarity, but
474
+ // that broke the documented `<DUR>` shape — dashboards trying
475
+ // to parse it as a duration would fail only on sparse logs.
476
+ // Both branches now emit a duration string; the human note
477
+ // adds the "sparse" qualifier so operators understand what
478
+ // they're looking at.
479
+ const spanSeconds = Math.max(bucketSeconds, Math.ceil((anchorKey - startKey) / 1000) + bucketSeconds);
480
+ clampedSince = formatDurationCompact(spanSeconds);
481
+ // For contiguous clamps, also fill window.* so consumers can
482
+ // compute rates against a real duration. For sparse clamps,
483
+ // window.* stays null — `total_events / window.seconds` would
484
+ // be meaningless when the kept buckets don't form a contiguous
485
+ // lattice.
486
+ if (isContiguous) {
487
+ windowSeconds = spanSeconds;
488
+ windowEnd = new Date(anchorKey + stepMs);
489
+ windowStart = new Date(startKey);
490
+ }
491
+ // Track the contiguous-vs-sparse shape so the renderer can
492
+ // surface the right note.
493
+ void expectedContiguousCount; // used above via isContiguous
494
+ // Emit each kept bucket. total_events under the post-scan clamp
495
+ // path counts only what the rendered buckets contain — older
496
+ // sliced-out buckets contribute nothing to the report.
497
+ let inWindow = 0;
498
+ for (const k of kept) {
499
+ const cnt = buckets.get(k) ?? 0;
500
+ result.push({
501
+ start: new Date(k).toISOString(),
502
+ end: new Date(k + stepMs).toISOString(),
503
+ count: cnt,
504
+ });
505
+ inWindow += cnt;
506
+ }
507
+ totalEvents = inWindow;
297
508
  }
298
- const stepMs = bucketSeconds * 1000;
299
- for (const k of keys) {
300
- result.push({
301
- start: new Date(k).toISOString(),
302
- end: new Date(k + stepMs).toISOString(),
303
- count: buckets.get(k) ?? 0,
304
- });
509
+ else {
510
+ for (const k of keys) {
511
+ result.push({
512
+ start: new Date(k).toISOString(),
513
+ end: new Date(k + stepMs).toISOString(),
514
+ count: buckets.get(k) ?? 0,
515
+ });
516
+ }
305
517
  }
306
518
  }
307
519
  // Peak index. -1 when no events at all (every bucket is 0 or the
@@ -329,6 +541,7 @@ export async function computeAuditTimeline(options = {}) {
329
541
  total_events: totalEvents,
330
542
  peak_index: peakIndex,
331
543
  files_scanned: filesScanned,
544
+ clamped_since: clampedSince,
332
545
  };
333
546
  }
334
547
  /**
@@ -381,10 +594,39 @@ function formatWindowLabel(seconds) {
381
594
  */
382
595
  export function renderAuditTimeline(result) {
383
596
  const lines = [];
384
- const windowLabel = formatWindowLabel(result.window.seconds);
597
+ // Codex round-9 P2 (0.47.0): when auto-clamp fires, the regular
598
+ // `last <DUR>` header (derived from `window.seconds`) is wrong —
599
+ // contiguous stale logs would print "last 1d" even though the
600
+ // newest event was days ago, and sparse clamps would fall back
601
+ // to "all time". Use a clamp-aware header that describes the
602
+ // returned shape instead.
385
603
  const cadenceLabel = bucketLabel(result.bucket.seconds, result.bucket.raw);
604
+ let windowLabel;
605
+ if (result.clamped_since !== null) {
606
+ windowLabel = `clamped to ~${result.clamped_since} of newest activity`;
607
+ }
608
+ else {
609
+ windowLabel = formatWindowLabel(result.window.seconds);
610
+ }
386
611
  lines.push(`rea audit timeline (${windowLabel}, ${cadenceLabel})`);
387
612
  lines.push('─'.repeat(40));
613
+ // 0.47.0 charter item 2: surface the auto-clamp inline. Operators
614
+ // scanning the rendered output should immediately see that the
615
+ // window they got isn't the full audit log. Codex round-8 P2
616
+ // (0.47.0): `clamped_since` is informational, not reproducible —
617
+ // `--since=DUR` anchors at `now`, so a clamp anchored at an older
618
+ // record can't round-trip. Codex round-9 P3 (0.47.0): only a
619
+ // WIDER `--bucket` actually changes the result — pinning the same
620
+ // bucket would just re-trigger the clamp. The remediation
621
+ // suggestion names "wider" explicitly to avoid sending operators
622
+ // down a no-op retry path.
623
+ if (result.clamped_since !== null) {
624
+ lines.push(`note: --since not specified; auto-clamped to newest ${String(MAX_BUCKETS)} buckets ` +
625
+ `(~${result.clamped_since} span at --bucket=${result.bucket.raw}). ` +
626
+ `Pass --since=DUR to anchor at now, or rerun with a WIDER --bucket ` +
627
+ `(current ${result.bucket.raw}) to fit the full log.`);
628
+ lines.push('');
629
+ }
388
630
  // Codex round-1 P2 (0.46.0): the zero-events case has two distinct
389
631
  // shapes and the renderer must NOT collapse them.
390
632
  //
@@ -0,0 +1,154 @@
1
+ /**
2
+ * `rea audit top-blocks [--since=DUR] [--limit=N] [--json]` — 0.47.0
3
+ * charter item 3.
4
+ *
5
+ * Surface the most recent refusal events from the audit log. Designed
6
+ * for the question "why was that refused?" — operators see the latest
7
+ * blocks at a glance with enough context (timestamp, tool, reason) to
8
+ * grep the offending Bash/Edit/Write call site or fix the policy that
9
+ * tripped the gate.
10
+ *
11
+ * A "refusal" in the rea audit schema is any record whose
12
+ * `InvocationStatus` is NOT `Allowed` — that's `Denied` (policy
13
+ * refused) OR `Error` (middleware exception or downstream failure).
14
+ * Both are interesting to operators debugging "why didn't this run".
15
+ *
16
+ * # Walk scope
17
+ *
18
+ * Mirrors `audit summary` / `audit by-tool` / `audit timeline`: the
19
+ * current `.rea/audit.jsonl` PLUS every rotated `audit-…jsonl` segment
20
+ * is walked regardless of `--since` (the per-record timestamp filter
21
+ * inside the main loop decides what counts). Rotated filename stamps
22
+ * mark the rotation INSTANT, not the earliest record contained
23
+ * (0.41.0 round-3 P2 / 0.42.0 charter item 3) — pruning by filename
24
+ * would silently drop in-window records from conservatively-rotated
25
+ * logs. Walking every segment is the only sound shape.
26
+ *
27
+ * # Output (default)
28
+ *
29
+ * rea audit top-blocks (last 24h, limit 20)
30
+ * ─────────────────────────────────────────
31
+ * a1b2c3d4 2026-05-17T12:34:56.789Z Bash rm -rf bypass attempted (...)
32
+ * deadbeef 2026-05-17T11:20:01.123Z Write blocked-path .env write
33
+ * …
34
+ * total: 4 refusal events in window
35
+ * files scanned: 2
36
+ *
37
+ * # JSON output
38
+ *
39
+ * {
40
+ * "schema_version": 1,
41
+ * "since": "24h",
42
+ * "limit": 20,
43
+ * "window": { "seconds": 86400, "start": "...", "end": "..." },
44
+ * "total_matched": 4,
45
+ * "events": [
46
+ * { "hash": "a1b2c3d4...", "timestamp": "...", "tool": "Bash",
47
+ * "status": "denied", "reason": "rm -rf bypass attempted (...)" },
48
+ * …
49
+ * ],
50
+ * "files_scanned": ["/abs/path/.rea/audit.jsonl"]
51
+ * }
52
+ *
53
+ * `total_matched` is the pre-limit count so dashboards can show "20 of
54
+ * 47 refusals in window". `events` is sorted newest-first and capped at
55
+ * `limit`.
56
+ */
57
+ import type { Command } from 'commander';
58
+ export declare const AUDIT_TOP_BLOCKS_SCHEMA_VERSION = 1;
59
+ /** Default `--limit` value. 20 fits a debugging session's eyeballable window. */
60
+ export declare const DEFAULT_LIMIT = 20;
61
+ /**
62
+ * Hard ceiling on `--limit`. Refusal events are typically a small slice
63
+ * of total traffic, but a 1000 cap keeps the renderer / JSON output
64
+ * bounded under a runaway misconfiguration that's denying everything.
65
+ */
66
+ export declare const MAX_LIMIT = 1000;
67
+ /**
68
+ * Thrown when `--limit` is outside [1, MAX_LIMIT] or `--since` fails to
69
+ * parse. The commander wrapper catches and exits 1. Distinct from
70
+ * `AuditByToolOptionError` / `AuditTimelineOptionError` so the
71
+ * caller-facing message names the right flag.
72
+ */
73
+ export declare class AuditTopBlocksOptionError extends Error {
74
+ constructor(message: string);
75
+ }
76
+ export interface AuditTopBlocksEvent {
77
+ /** Full sha256 hash from the audit record — stable cross-tool ID. */
78
+ hash: string;
79
+ /** Raw ISO-8601 timestamp from the record. */
80
+ timestamp: string;
81
+ /** Tool name as recorded; `(unknown)` for missing/empty. */
82
+ tool: string;
83
+ /** Raw `status` value (`denied` / `error`). */
84
+ status: string;
85
+ /**
86
+ * Best-effort human-readable reason. Sourced from the record's
87
+ * `error` field when present, else a synthesized "<status>: <tool>"
88
+ * fallback so the row carries SOMETHING informative even when the
89
+ * middleware didn't attach an error message.
90
+ */
91
+ reason: string;
92
+ /** Session ID from the record; useful for cross-referencing. */
93
+ session_id: string;
94
+ }
95
+ export interface AuditTopBlocksResult {
96
+ schema_version: typeof AUDIT_TOP_BLOCKS_SCHEMA_VERSION;
97
+ /** Raw `--since` value as passed by the caller (`null` when omitted). */
98
+ since: string | null;
99
+ /** Resolved `--limit` actually used. */
100
+ limit: number;
101
+ window: {
102
+ seconds: number | null;
103
+ start: string | null;
104
+ end: string | null;
105
+ };
106
+ /** Pre-limit count of refusal records in window. */
107
+ total_matched: number;
108
+ /** Sorted newest-first; capped at `limit`. */
109
+ events: AuditTopBlocksEvent[];
110
+ /** Absolute paths of audit files actually read. */
111
+ files_scanned: string[];
112
+ }
113
+ export interface ComputeAuditTopBlocksOptions {
114
+ /** Override CWD. Tests set this; production uses `process.cwd()`. */
115
+ baseDir?: string;
116
+ /** Raw `--since` value (e.g. `24h`, `7d`). Parsed via parseDuration. */
117
+ since?: string;
118
+ /** Raw `--limit` value. Default `DEFAULT_LIMIT`. */
119
+ limit?: number;
120
+ /** Test seam — pin "now" for deterministic window calculations. */
121
+ now?: Date;
122
+ }
123
+ /**
124
+ * Compute the top-blocks list. Pure (read-only). Throws
125
+ * `AuditTopBlocksOptionError` on bad `--since` / `--limit`.
126
+ */
127
+ export declare function computeAuditTopBlocks(options?: ComputeAuditTopBlocksOptions): Promise<AuditTopBlocksResult>;
128
+ /**
129
+ * Render the result as a human-readable terminal block. JSON callers
130
+ * bypass this; the rendering is intentionally minimal — a fixed-column
131
+ * table that scans cleanly in a typical terminal.
132
+ */
133
+ export declare function renderAuditTopBlocks(result: AuditTopBlocksResult): string;
134
+ export interface RunAuditTopBlocksOptions {
135
+ since?: string;
136
+ limit?: number;
137
+ json?: boolean;
138
+ /** Test seam — pin "now". */
139
+ now?: Date;
140
+ }
141
+ /** Commander entrypoint. */
142
+ export declare function runAuditTopBlocks(options: RunAuditTopBlocksOptions): Promise<void>;
143
+ /**
144
+ * Strict integer parser for the commander `--limit <n>` option.
145
+ *
146
+ * Mirrors the `parseTopOption` discipline in `audit-by-tool.ts`:
147
+ * reject anything that isn't a bare integer so `Number.parseInt`
148
+ * can't silently truncate (`1.5` → `1`, `10abc` → `10`).
149
+ */
150
+ export declare function parseLimitOption(raw: string): number;
151
+ /**
152
+ * Register `rea audit top-blocks` under the `audit` command group.
153
+ */
154
+ export declare function registerAuditTopBlocksCommand(auditCommand: Command): void;
@@ -0,0 +1,419 @@
1
+ /**
2
+ * `rea audit top-blocks [--since=DUR] [--limit=N] [--json]` — 0.47.0
3
+ * charter item 3.
4
+ *
5
+ * Surface the most recent refusal events from the audit log. Designed
6
+ * for the question "why was that refused?" — operators see the latest
7
+ * blocks at a glance with enough context (timestamp, tool, reason) to
8
+ * grep the offending Bash/Edit/Write call site or fix the policy that
9
+ * tripped the gate.
10
+ *
11
+ * A "refusal" in the rea audit schema is any record whose
12
+ * `InvocationStatus` is NOT `Allowed` — that's `Denied` (policy
13
+ * refused) OR `Error` (middleware exception or downstream failure).
14
+ * Both are interesting to operators debugging "why didn't this run".
15
+ *
16
+ * # Walk scope
17
+ *
18
+ * Mirrors `audit summary` / `audit by-tool` / `audit timeline`: the
19
+ * current `.rea/audit.jsonl` PLUS every rotated `audit-…jsonl` segment
20
+ * is walked regardless of `--since` (the per-record timestamp filter
21
+ * inside the main loop decides what counts). Rotated filename stamps
22
+ * mark the rotation INSTANT, not the earliest record contained
23
+ * (0.41.0 round-3 P2 / 0.42.0 charter item 3) — pruning by filename
24
+ * would silently drop in-window records from conservatively-rotated
25
+ * logs. Walking every segment is the only sound shape.
26
+ *
27
+ * # Output (default)
28
+ *
29
+ * rea audit top-blocks (last 24h, limit 20)
30
+ * ─────────────────────────────────────────
31
+ * a1b2c3d4 2026-05-17T12:34:56.789Z Bash rm -rf bypass attempted (...)
32
+ * deadbeef 2026-05-17T11:20:01.123Z Write blocked-path .env write
33
+ * …
34
+ * total: 4 refusal events in window
35
+ * files scanned: 2
36
+ *
37
+ * # JSON output
38
+ *
39
+ * {
40
+ * "schema_version": 1,
41
+ * "since": "24h",
42
+ * "limit": 20,
43
+ * "window": { "seconds": 86400, "start": "...", "end": "..." },
44
+ * "total_matched": 4,
45
+ * "events": [
46
+ * { "hash": "a1b2c3d4...", "timestamp": "...", "tool": "Bash",
47
+ * "status": "denied", "reason": "rm -rf bypass attempted (...)" },
48
+ * …
49
+ * ],
50
+ * "files_scanned": ["/abs/path/.rea/audit.jsonl"]
51
+ * }
52
+ *
53
+ * `total_matched` is the pre-limit count so dashboards can show "20 of
54
+ * 47 refusals in window". `events` is sorted newest-first and capped at
55
+ * `limit`.
56
+ */
57
+ import fs from 'node:fs/promises';
58
+ import path from 'node:path';
59
+ import { listRotatedAuditFiles } from './audit-specialists.js';
60
+ import { AuditSummarySinceError, parseDurationSeconds, } from './audit-summary.js';
61
+ import { AUDIT_FILE, REA_DIR, err } from './utils.js';
62
+ export const AUDIT_TOP_BLOCKS_SCHEMA_VERSION = 1;
63
+ /** Default `--limit` value. 20 fits a debugging session's eyeballable window. */
64
+ export const DEFAULT_LIMIT = 20;
65
+ /**
66
+ * Hard ceiling on `--limit`. Refusal events are typically a small slice
67
+ * of total traffic, but a 1000 cap keeps the renderer / JSON output
68
+ * bounded under a runaway misconfiguration that's denying everything.
69
+ */
70
+ export const MAX_LIMIT = 1000;
71
+ /** Max characters of refusal reason to display per row before truncation. */
72
+ const REASON_TRUNCATE = 80;
73
+ /** Short-hash prefix length for the displayed event ID. */
74
+ const SHORT_HASH_LEN = 8;
75
+ /**
76
+ * Thrown when `--limit` is outside [1, MAX_LIMIT] or `--since` fails to
77
+ * parse. The commander wrapper catches and exits 1. Distinct from
78
+ * `AuditByToolOptionError` / `AuditTimelineOptionError` so the
79
+ * caller-facing message names the right flag.
80
+ */
81
+ export class AuditTopBlocksOptionError extends Error {
82
+ constructor(message) {
83
+ super(message);
84
+ this.name = 'AuditTopBlocksOptionError';
85
+ }
86
+ }
87
+ /**
88
+ * Resolve the audit files to walk. Identical strategy to the sibling
89
+ * audit commands — inlined to keep the public surface of
90
+ * `audit-summary.ts` narrow.
91
+ */
92
+ async function resolveTopBlocksFileWalk(baseDir) {
93
+ const reaDir = path.join(baseDir, REA_DIR);
94
+ const currentAudit = path.join(reaDir, AUDIT_FILE);
95
+ const files = [];
96
+ const rotated = await listRotatedAuditFiles(reaDir);
97
+ for (const name of rotated)
98
+ files.push(path.join(reaDir, name));
99
+ try {
100
+ const stat = await fs.stat(currentAudit);
101
+ if (stat.isFile())
102
+ files.push(currentAudit);
103
+ }
104
+ catch (e) {
105
+ if (e.code !== 'ENOENT')
106
+ throw e;
107
+ }
108
+ return files;
109
+ }
110
+ function parseTimestamp(raw) {
111
+ if (typeof raw !== 'string')
112
+ return null;
113
+ const d = new Date(raw);
114
+ return Number.isNaN(d.getTime()) ? null : d;
115
+ }
116
+ /**
117
+ * Decide whether an audit record represents a refusal. The rea
118
+ * InvocationStatus enum has three values (`allowed`, `denied`,
119
+ * `error`); refusals are the non-`allowed` set. We accept any other
120
+ * string here too so a future status enum extension (or an unusual
121
+ * consumer-emitted status) surfaces in the report rather than silently
122
+ * dropping — the operator can decide whether the new bucket is signal
123
+ * or noise.
124
+ */
125
+ function isRefusal(status) {
126
+ if (typeof status !== 'string')
127
+ return false;
128
+ return status !== 'allowed';
129
+ }
130
+ /**
131
+ * Compute the top-blocks list. Pure (read-only). Throws
132
+ * `AuditTopBlocksOptionError` on bad `--since` / `--limit`.
133
+ */
134
+ export async function computeAuditTopBlocks(options = {}) {
135
+ const baseDir = options.baseDir ?? process.cwd();
136
+ const now = options.now ?? new Date();
137
+ // Resolve --limit first so a bad value fails fast before any I/O.
138
+ const limit = options.limit ?? DEFAULT_LIMIT;
139
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
140
+ throw new AuditTopBlocksOptionError(`--limit: must be an integer between 1 and ${String(MAX_LIMIT)}; got ${JSON.stringify(limit)}.`);
141
+ }
142
+ let windowSeconds = null;
143
+ let windowStart = null;
144
+ let windowEnd = null;
145
+ if (options.since !== undefined && options.since.length > 0) {
146
+ try {
147
+ windowSeconds = parseDurationSeconds(options.since);
148
+ }
149
+ catch (e) {
150
+ if (e instanceof AuditSummarySinceError) {
151
+ throw new AuditTopBlocksOptionError(e.message);
152
+ }
153
+ throw e;
154
+ }
155
+ windowEnd = now;
156
+ windowStart = new Date(now.getTime() - windowSeconds * 1000);
157
+ }
158
+ const files = await resolveTopBlocksFileWalk(baseDir);
159
+ const filesScanned = [];
160
+ // Codex round-10 P2 (0.47.0): in a policy-storm scenario (many
161
+ // refusals, verbose `error` strings) the prior shape accumulated
162
+ // every match into a flat array, sorted it, then sliced to
163
+ // `--limit`. Memory + runtime scaled with the total refusal count
164
+ // — exactly the case `top-blocks` was designed to debug. The
165
+ // bounded-buffer shape keeps memory O(limit): we maintain a
166
+ // sorted "top K newest" list of size <= limit and discard the
167
+ // oldest entry whenever a newer one displaces it. `totalMatched`
168
+ // counts every in-window refusal so the JSON shape still
169
+ // communicates "N of M shown".
170
+ const topBuf = [];
171
+ let totalMatched = 0;
172
+ // Insert into the bounded buffer, keeping it sorted newest-first
173
+ // by parsed instant (with hash tiebreaker for determinism). Drop
174
+ // the oldest when capacity exceeded.
175
+ const insertIntoTop = (event, parsedTime) => {
176
+ // Find insertion point — small linear scan; for limit=20 (the
177
+ // default) this is cheaper than a heap and keeps the code
178
+ // simple. For limit=1000 (the max) we're still O(limit) per
179
+ // insert in the worst case, well under the prior O(N log N)
180
+ // sort across N matches.
181
+ let idx = topBuf.length;
182
+ for (let i = 0; i < topBuf.length; i += 1) {
183
+ const cur = topBuf[i];
184
+ if (parsedTime > cur.parsedTime ||
185
+ (parsedTime === cur.parsedTime && event.hash.localeCompare(cur.event.hash) < 0)) {
186
+ idx = i;
187
+ break;
188
+ }
189
+ }
190
+ if (idx < limit) {
191
+ topBuf.splice(idx, 0, { event, parsedTime });
192
+ if (topBuf.length > limit)
193
+ topBuf.length = limit;
194
+ }
195
+ };
196
+ for (const filePath of files) {
197
+ let raw;
198
+ try {
199
+ raw = await fs.readFile(filePath, 'utf8');
200
+ }
201
+ catch (e) {
202
+ const errno = e.code;
203
+ if (errno === 'ENOENT')
204
+ continue;
205
+ // Mirror the sibling audit commands' stance: any non-ENOENT read
206
+ // error is fatal. A silent skip on a rotated segment that may
207
+ // contain in-window refusals would let `top-blocks` exit 0 with
208
+ // the operator's question unanswered.
209
+ throw new Error(`rea audit top-blocks: cannot read ${filePath} (${errno ?? 'unknown errno'}). ` +
210
+ `An unreadable audit segment may contain in-window records, so the ` +
211
+ `refusal report would be silently incomplete. Fix permissions ` +
212
+ `(e.g. \`chmod u+r ${filePath}\`), or move the file out of \`.rea/\` ` +
213
+ `if you no longer need it.`);
214
+ }
215
+ filesScanned.push(filePath);
216
+ for (const line of raw.split('\n')) {
217
+ if (line.length === 0)
218
+ continue;
219
+ let parsed;
220
+ try {
221
+ parsed = JSON.parse(line);
222
+ }
223
+ catch {
224
+ // Malformed line — `rea audit verify` is the right tool. Skip
225
+ // so a single corrupt line doesn't tank the report.
226
+ continue;
227
+ }
228
+ if (!isRefusal(parsed.status))
229
+ continue;
230
+ const ts = parseTimestamp(parsed.timestamp);
231
+ if (windowStart !== null && (ts === null || ts < windowStart))
232
+ continue;
233
+ if (windowEnd !== null && (ts === null || ts > windowEnd))
234
+ continue;
235
+ totalMatched += 1;
236
+ const tool = typeof parsed.tool_name === 'string' && parsed.tool_name.length > 0
237
+ ? parsed.tool_name
238
+ : '(unknown)';
239
+ const errorText = typeof parsed.error === 'string' && parsed.error.length > 0
240
+ ? parsed.error
241
+ : `${typeof parsed.status === 'string' ? parsed.status : 'refused'}: ${tool}`;
242
+ const event = {
243
+ hash: typeof parsed.hash === 'string' ? parsed.hash : '',
244
+ timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : '',
245
+ tool,
246
+ status: typeof parsed.status === 'string' ? parsed.status : '(unknown)',
247
+ reason: errorText,
248
+ session_id: typeof parsed.session_id === 'string' ? parsed.session_id : '',
249
+ };
250
+ // Codex round-2 P2 (0.47.0): parse the timestamp before
251
+ // comparing — `appendAuditRecord` accepts any ISO-8601 shape,
252
+ // so `2026-05-17T23:00:00+02:00` (= 21:00:00Z, OLDER) would
253
+ // lex-sort ahead of `2026-05-17T22:30:00Z` (NEWER) under a
254
+ // string compare.
255
+ const parsedTime = Date.parse(event.timestamp);
256
+ insertIntoTop(event, Number.isFinite(parsedTime) ? parsedTime : 0);
257
+ }
258
+ }
259
+ const capped = topBuf.map((entry) => entry.event);
260
+ return {
261
+ schema_version: AUDIT_TOP_BLOCKS_SCHEMA_VERSION,
262
+ since: options.since !== undefined && options.since.length > 0 ? options.since : null,
263
+ limit,
264
+ window: {
265
+ seconds: windowSeconds,
266
+ start: windowStart !== null ? windowStart.toISOString() : null,
267
+ end: windowEnd !== null ? windowEnd.toISOString() : null,
268
+ },
269
+ total_matched: totalMatched,
270
+ events: capped,
271
+ files_scanned: filesScanned,
272
+ };
273
+ }
274
+ /**
275
+ * Truncate a reason string to `REASON_TRUNCATE` chars for the human
276
+ * renderer. JSON consumers get the full string — they can render at
277
+ * any width.
278
+ *
279
+ * Codex round-10 P3 (0.47.0): refusal reasons often contain embedded
280
+ * newlines (shell stderr, Node stack traces). Writing them straight
281
+ * into a fixed-width row spills a single event across multiple
282
+ * terminal lines and breaks the hash/timestamp/tool columns. Collapse
283
+ * `\r`, `\n`, and tabs to single spaces FIRST, then truncate to the
284
+ * column width. The JSON path preserves the raw `reason` field so
285
+ * consumers see the full multiline message.
286
+ */
287
+ function truncateReason(reason) {
288
+ const collapsed = reason.replace(/[\r\n\t]+/g, ' ').replace(/ +/g, ' ').trim();
289
+ if (collapsed.length <= REASON_TRUNCATE)
290
+ return collapsed;
291
+ return collapsed.slice(0, REASON_TRUNCATE - 1) + '…';
292
+ }
293
+ /**
294
+ * Short-hash prefix for the displayed event ID. Falls back to the
295
+ * full string when it's shorter than the prefix length (degenerate
296
+ * inputs only — real hashes are always 64 hex chars).
297
+ */
298
+ function shortHash(hash) {
299
+ if (hash.length <= SHORT_HASH_LEN)
300
+ return hash || '(no-hash)';
301
+ return hash.slice(0, SHORT_HASH_LEN);
302
+ }
303
+ /**
304
+ * Compact human duration label. Mirrors the sibling audit commands.
305
+ */
306
+ function formatDurationShort(seconds) {
307
+ const units = [
308
+ ['w', 60 * 60 * 24 * 7],
309
+ ['d', 60 * 60 * 24],
310
+ ['h', 60 * 60],
311
+ ['m', 60],
312
+ ['s', 1],
313
+ ];
314
+ for (const [unit, factor] of units) {
315
+ if (seconds % factor === 0) {
316
+ return `last ${String(seconds / factor)}${unit}`;
317
+ }
318
+ }
319
+ return `last ${String(seconds)}s`;
320
+ }
321
+ /**
322
+ * Render the result as a human-readable terminal block. JSON callers
323
+ * bypass this; the rendering is intentionally minimal — a fixed-column
324
+ * table that scans cleanly in a typical terminal.
325
+ */
326
+ export function renderAuditTopBlocks(result) {
327
+ const lines = [];
328
+ const windowLabel = result.window.seconds !== null ? formatDurationShort(result.window.seconds) : 'all time';
329
+ lines.push(`rea audit top-blocks (${windowLabel}, limit ${String(result.limit)})`);
330
+ lines.push('─'.repeat(40));
331
+ if (result.total_matched === 0) {
332
+ lines.push(result.window.seconds !== null
333
+ ? 'No refusal events in the requested window.'
334
+ : 'No refusal events in the audit log.');
335
+ if (result.files_scanned.length === 0) {
336
+ lines.push('(no audit files found — has `rea serve` ever run?)');
337
+ }
338
+ lines.push('');
339
+ return lines.join('\n');
340
+ }
341
+ // Stable column widths for the table:
342
+ // short hash (8) + 2 | timestamp (24) + 2 | tool (max in view) + 2 | reason
343
+ const maxToolLen = result.events.reduce((m, ev) => Math.max(m, ev.tool.length), 4);
344
+ for (const ev of result.events) {
345
+ const h = shortHash(ev.hash).padEnd(SHORT_HASH_LEN);
346
+ const ts = ev.timestamp.padEnd(24); // ISO-8601 with ms is 24 chars
347
+ const tool = ev.tool.padEnd(maxToolLen);
348
+ const reason = truncateReason(ev.reason);
349
+ lines.push(`${h} ${ts} ${tool} ${reason}`);
350
+ }
351
+ lines.push('');
352
+ if (result.total_matched > result.events.length) {
353
+ lines.push(`total: ${String(result.events.length)} of ${String(result.total_matched)} refusal events shown (--limit=${String(result.limit)})`);
354
+ }
355
+ else {
356
+ lines.push(`total: ${String(result.total_matched)} refusal event${result.total_matched === 1 ? '' : 's'} in window`);
357
+ }
358
+ lines.push(`files scanned: ${String(result.files_scanned.length)}`);
359
+ lines.push('');
360
+ return lines.join('\n');
361
+ }
362
+ /** Commander entrypoint. */
363
+ export async function runAuditTopBlocks(options) {
364
+ let result;
365
+ try {
366
+ result = await computeAuditTopBlocks({
367
+ ...(options.since !== undefined ? { since: options.since } : {}),
368
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
369
+ ...(options.now !== undefined ? { now: options.now } : {}),
370
+ });
371
+ }
372
+ catch (e) {
373
+ if (e instanceof AuditTopBlocksOptionError) {
374
+ err(`rea audit top-blocks: ${e.message}`);
375
+ process.exit(1);
376
+ }
377
+ throw e;
378
+ }
379
+ if (options.json === true) {
380
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
381
+ return;
382
+ }
383
+ process.stdout.write(renderAuditTopBlocks(result));
384
+ }
385
+ /**
386
+ * Strict integer parser for the commander `--limit <n>` option.
387
+ *
388
+ * Mirrors the `parseTopOption` discipline in `audit-by-tool.ts`:
389
+ * reject anything that isn't a bare integer so `Number.parseInt`
390
+ * can't silently truncate (`1.5` → `1`, `10abc` → `10`).
391
+ */
392
+ export function parseLimitOption(raw) {
393
+ if (!/^-?\d+$/.test(raw.trim())) {
394
+ throw new AuditTopBlocksOptionError(`--limit: expected integer; got ${JSON.stringify(raw)}.`);
395
+ }
396
+ const n = Number.parseInt(raw.trim(), 10);
397
+ if (!Number.isFinite(n)) {
398
+ throw new AuditTopBlocksOptionError(`--limit: expected integer; got ${JSON.stringify(raw)}.`);
399
+ }
400
+ return n;
401
+ }
402
+ /**
403
+ * Register `rea audit top-blocks` under the `audit` command group.
404
+ */
405
+ export function registerAuditTopBlocksCommand(auditCommand) {
406
+ auditCommand
407
+ .command('top-blocks')
408
+ .description('Recent refusal events from the audit log — `--limit=N` (default 20, max 1000), `--since=DUR` window filter, `--json` for dashboards. Read-only.')
409
+ .option('--limit <n>', `cap the rendered / serialized list to the most recent N refusals (default ${String(DEFAULT_LIMIT)}, max ${String(MAX_LIMIT)})`, parseLimitOption)
410
+ .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).')
411
+ .option('--json', 'emit a JSON document instead of the human-readable table')
412
+ .action(async (opts) => {
413
+ await runAuditTopBlocks({
414
+ ...(opts.limit !== undefined ? { limit: opts.limit } : {}),
415
+ ...(opts.since !== undefined ? { since: opts.since } : {}),
416
+ ...(opts.json === true ? { json: true } : {}),
417
+ });
418
+ });
419
+ }
package/dist/cli/index.js CHANGED
@@ -17,6 +17,7 @@ import { runUpgradeCheck } from './upgrade-check.js';
17
17
  import { registerAuditSummaryCommand } from './audit-summary.js';
18
18
  import { registerAuditByToolCommand } from './audit-by-tool.js';
19
19
  import { registerAuditTimelineCommand } from './audit-timeline.js';
20
+ import { registerAuditTopBlocksCommand } from './audit-top-blocks.js';
20
21
  import { registerVerifyClaimCommand } from './verify-claim.js';
21
22
  import { err, getPkgVersion } from './utils.js';
22
23
  async function main() {
@@ -154,6 +155,10 @@ async function main() {
154
155
  // [--since=DUR] [--json]`. Time-bucketed event counts with inline
155
156
  // histogram. Useful for spotting activity spikes + cadence patterns.
156
157
  registerAuditTimelineCommand(audit);
158
+ // 0.47.0 charter item 3 — `rea audit top-blocks [--limit=N]
159
+ // [--since=DUR] [--json]`. Most-recent refusal events (denied/error).
160
+ // The "why was that refused?" debugging lens.
161
+ registerAuditTopBlocksCommand(audit);
157
162
  // Register `rea hook push-gate` — the stateless pre-push Codex gate
158
163
  // called by `.husky/pre-push` and `.git/hooks/pre-push`.
159
164
  registerHookCommand(program);
@@ -301,6 +301,38 @@ export function reaCommandTier(command) {
301
301
  // Write-tier default. Codex round 3 P2 (2026-05-12).
302
302
  if (sub2 === 'specialists')
303
303
  return Tier.Read;
304
+ // 0.47.0 codex round-11 P2: the audit-reader trio
305
+ // (`summary`, `by-tool`, `timeline`, `top-blocks`) all share
306
+ // the read-only contract — they walk audit.jsonl + rotated
307
+ // segments and emit aggregations to stdout. The pre-0.47.0
308
+ // tier-map only downgraded `verify` + `specialists`, leaving
309
+ // 0.41.0+ readers misclassified as Write under TRUSTED
310
+ // invocations — which made them unavailable in L0 sessions
311
+ // run from `/usr/local/bin/rea` or
312
+ // `/proj/node_modules/.bin/rea` despite being purely
313
+ // observational. Close the gap for all four readers here.
314
+ //
315
+ // 0.47.0 codex round-12 P2 (DELIBERATE NON-FIX): the weak-
316
+ // trust branch below intentionally returns null for Read-tier
317
+ // subcommands, including these audit readers. That keeps a
318
+ // bare `rea audit summary` (PATH-lookup or relative path) at
319
+ // generic Bash Write, where an attacker who shadowed `rea` on
320
+ // PATH cannot trick the gateway into downgrading their
321
+ // payload via a fake subcommand. Consumers needing Read
322
+ // semantics under L0 use the trusted invocation shapes
323
+ // (`/usr/local/bin/rea …`, `npx rea …`,
324
+ // `./node_modules/.bin/rea …`) — same contract as `verify`
325
+ // and `specialists` since 0.10.x / 0.29.0. The UX gap is
326
+ // documented in `docs/cli/trust-model.md` (see also the
327
+ // weak-trust branch comment below).
328
+ if (sub2 === 'summary')
329
+ return Tier.Read;
330
+ if (sub2 === 'by-tool')
331
+ return Tier.Read;
332
+ if (sub2 === 'timeline')
333
+ return Tier.Read;
334
+ if (sub2 === 'top-blocks')
335
+ return Tier.Read;
304
336
  if (sub2 === 'rotate')
305
337
  return Tier.Write;
306
338
  return Tier.Write;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",