@bookedsolid/rea 0.45.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.
@@ -0,0 +1,723 @@
1
+ /**
2
+ * `rea audit timeline [--bucket=HOUR|DAY|<DUR>] [--since=DUR] [--json]`
3
+ * — 0.46.0 charter item 2.
4
+ *
5
+ * Time-bucketed event counts over the audit log. Useful for spotting
6
+ * activity spikes ("what happened during the 3pm CI build?") and
7
+ * day/week cadence patterns.
8
+ *
9
+ * # Bucket sizes
10
+ *
11
+ * - `HOUR` (default) — bucket boundaries align to the UTC hour
12
+ * (`HH:00:00.000Z`)
13
+ * - `DAY` — bucket boundaries align to the UTC day
14
+ * (`YYYY-MM-DDT00:00:00.000Z`)
15
+ * - `<DUR>` (`15m`, `30m`, `1h`, `2h`, `1d`, etc) — arbitrary
16
+ * duration. Boundaries align to the UTC epoch (multiples of the
17
+ * bucket size from `1970-01-01T00:00:00Z`). The `<DUR>` form is
18
+ * useful for sub-hour cadence (`--bucket=15m`) or unusual cuts
19
+ * (`--bucket=6h` for "morning / afternoon / evening / night").
20
+ *
21
+ * Bucket boundaries are half-open `[start, end)` so a record at
22
+ * `15:00:00.000Z` lands in the `15:00 → 16:00` bucket, not the
23
+ * `14:00 → 15:00` one.
24
+ *
25
+ * # Window
26
+ *
27
+ * - `--since=DUR` with same shape as `audit summary` / `audit
28
+ * by-tool` (`24h`, `7d`, etc). When set, the timeline emits a
29
+ * bucket for every interval intersecting `[now - DUR, now]`, even
30
+ * zero-count ones — silence is signal too. Without `--since`,
31
+ * buckets are emitted only for intervals that actually contain a
32
+ * record (no implicit filler — we don't know the operator's
33
+ * intended window).
34
+ *
35
+ * # Output (default `--bucket=HOUR`, last 24h)
36
+ *
37
+ * rea audit timeline (last 24h, hourly)
38
+ * ──────────────────────────────────────
39
+ * 2026-05-16 14:00 ▁▁▁▁▁ 23 events
40
+ * 2026-05-16 15:00 ▁▁▁▁▁▁▁▁ 47 events
41
+ * 2026-05-16 16:00 ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 127 events ← peak
42
+ * 2026-05-16 17:00 ▁▁▁▁▁▁▁▁▁▁▁▁ 89 events
43
+ * …
44
+ *
45
+ * The histogram bar uses a single Unicode block char (▁) repeated
46
+ * proportionally to peak — chosen for terminal friendliness over the
47
+ * staircase forms (▁▂▃▄▅▆▇█) because the staircase forms render
48
+ * unevenly in many terminals and the proportional bar carries the
49
+ * same information at-a-glance. Bar width is capped at 32 chars so
50
+ * the line still fits in a typical 100-col terminal alongside the
51
+ * timestamp and count.
52
+ *
53
+ * Peak marker (`← peak`) sits next to the bucket with the highest
54
+ * count. Ties go to the first occurrence.
55
+ *
56
+ * # JSON output
57
+ *
58
+ * {
59
+ * "schema_version": 1,
60
+ * "bucket": { "raw": "HOUR", "seconds": 3600 },
61
+ * "window": {
62
+ * "seconds": 86400,
63
+ * "start": "2026-05-16T14:00:00.000Z",
64
+ * "end": "2026-05-17T14:00:00.000Z"
65
+ * },
66
+ * "buckets": [
67
+ * { "start": "2026-05-16T14:00:00.000Z",
68
+ * "end": "2026-05-16T15:00:00.000Z",
69
+ * "count": 23 },
70
+ * …
71
+ * ],
72
+ * "total_events": 287,
73
+ * "peak_index": 2,
74
+ * "files_scanned": ["/abs/path/.rea/audit.jsonl"]
75
+ * }
76
+ */
77
+ import fs from 'node:fs/promises';
78
+ import path from 'node:path';
79
+ import { listRotatedAuditFiles } from './audit-specialists.js';
80
+ import { AuditSummarySinceError, parseDurationSeconds, } from './audit-summary.js';
81
+ import { AUDIT_FILE, REA_DIR, err } from './utils.js';
82
+ export const AUDIT_TIMELINE_SCHEMA_VERSION = 1;
83
+ /** Histogram bar character. See module docstring for rationale. */
84
+ const BAR_CHAR = '▁';
85
+ /** Maximum bar width in characters. */
86
+ const MAX_BAR_WIDTH = 32;
87
+ /**
88
+ * Hard ceiling on the number of buckets the command will produce. A
89
+ * `--since=7d` with `--bucket=1m` would emit 10,080 buckets — well
90
+ * past what a terminal renderer handles gracefully. Capping at 2000
91
+ * still allows `--bucket=15m --since=21d` (`~2016 buckets`) which
92
+ * covers the realistic ops use cases.
93
+ */
94
+ export const MAX_BUCKETS = 2000;
95
+ export class AuditTimelineOptionError extends Error {
96
+ constructor(message) {
97
+ super(message);
98
+ this.name = 'AuditTimelineOptionError';
99
+ }
100
+ }
101
+ /**
102
+ * Resolve `--bucket` to a number of seconds. Accepts:
103
+ * - `HOUR` / `H` / `1H` (case-insensitive) → 3600
104
+ * - `DAY` / `D` / `1D` (case-insensitive) → 86400
105
+ * - duration form (`15m`, `30s`, `2h`, `1d`, `1w`) → parsed via
106
+ * `parseDurationSeconds` for shape compatibility with `--since`
107
+ *
108
+ * Bucket size must be >= 1 second; on the upper end we accept any
109
+ * value but `MAX_BUCKETS` will bound the rendered output.
110
+ */
111
+ export function resolveBucketSeconds(raw) {
112
+ const t = raw.trim();
113
+ if (t.length === 0) {
114
+ throw new AuditTimelineOptionError('--bucket: must not be empty.');
115
+ }
116
+ const upper = t.toUpperCase();
117
+ if (upper === 'HOUR' || upper === 'H' || upper === '1H')
118
+ return 3600;
119
+ if (upper === 'DAY' || upper === 'D' || upper === '1D')
120
+ return 86400;
121
+ // Fall through to duration shape. `parseDurationSeconds` throws
122
+ // `AuditSummarySinceError` on bad input; re-throw under our class.
123
+ try {
124
+ return parseDurationSeconds(t);
125
+ }
126
+ catch (e) {
127
+ if (e instanceof AuditSummarySinceError) {
128
+ throw new AuditTimelineOptionError(`--bucket: ${e.message.replace(/^--since: */, '')}`);
129
+ }
130
+ throw e;
131
+ }
132
+ }
133
+ async function resolveTimelineFileWalk(baseDir) {
134
+ const reaDir = path.join(baseDir, REA_DIR);
135
+ const currentAudit = path.join(reaDir, AUDIT_FILE);
136
+ const files = [];
137
+ const rotated = await listRotatedAuditFiles(reaDir);
138
+ for (const name of rotated)
139
+ files.push(path.join(reaDir, name));
140
+ try {
141
+ const stat = await fs.stat(currentAudit);
142
+ if (stat.isFile())
143
+ files.push(currentAudit);
144
+ }
145
+ catch (e) {
146
+ if (e.code !== 'ENOENT')
147
+ throw e;
148
+ }
149
+ return files;
150
+ }
151
+ function parseTimestamp(raw) {
152
+ if (typeof raw !== 'string')
153
+ return null;
154
+ const d = new Date(raw);
155
+ return Number.isNaN(d.getTime()) ? null : d;
156
+ }
157
+ /**
158
+ * Align an epoch-millisecond instant DOWN to a bucket boundary of
159
+ * `bucketSeconds`. The boundary lattice is anchored at the UTC epoch
160
+ * (`1970-01-01T00:00:00Z`) so day/hour buckets fall on natural UTC
161
+ * boundaries and sub-hour buckets (15m / 30m / 5m) align to natural
162
+ * sub-hour boundaries.
163
+ */
164
+ function alignToBucket(epochMs, bucketSeconds) {
165
+ const bucketMs = bucketSeconds * 1000;
166
+ return Math.floor(epochMs / bucketMs) * bucketMs;
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`.
269
+ /**
270
+ * Compute the bucketed timeline. Pure (read-only). Throws
271
+ * `AuditTimelineOptionError` on bad `--since` / `--bucket`; throws on
272
+ * unreadable rotated segments (mirror of audit-summary's stance).
273
+ */
274
+ export async function computeAuditTimeline(options = {}) {
275
+ const baseDir = options.baseDir ?? process.cwd();
276
+ const now = options.now ?? new Date();
277
+ const bucketRaw = options.bucket ?? 'HOUR';
278
+ const bucketSeconds = resolveBucketSeconds(bucketRaw);
279
+ if (bucketSeconds < 1) {
280
+ throw new AuditTimelineOptionError(`--bucket: resolved bucket size must be >= 1 second; got ${String(bucketSeconds)}.`);
281
+ }
282
+ let windowSeconds = null;
283
+ let windowStart = null;
284
+ let windowEnd = null;
285
+ let clampedSince = null;
286
+ if (options.since !== undefined && options.since.length > 0) {
287
+ try {
288
+ windowSeconds = parseDurationSeconds(options.since);
289
+ }
290
+ catch (e) {
291
+ if (e instanceof AuditSummarySinceError) {
292
+ throw new AuditTimelineOptionError(e.message);
293
+ }
294
+ throw e;
295
+ }
296
+ windowEnd = now;
297
+ windowStart = new Date(now.getTime() - windowSeconds * 1000);
298
+ // Guard against runaway bucket counts under a wide --since with a
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.
303
+ const projected = Math.ceil(windowSeconds / bucketSeconds);
304
+ if (projected > MAX_BUCKETS) {
305
+ throw new AuditTimelineOptionError(bucketOverflowMessage(windowSeconds, bucketSeconds, bucketRaw, options.since, false));
306
+ }
307
+ }
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.
321
+ // Bucket key is the aligned epoch-ms boundary; value is the count.
322
+ const buckets = new Map();
323
+ let totalEvents = 0;
324
+ let earliestRecordMs = null;
325
+ let latestRecordMs = null;
326
+ const filesScanned = [];
327
+ for (const filePath of files) {
328
+ let raw;
329
+ try {
330
+ raw = await fs.readFile(filePath, 'utf8');
331
+ }
332
+ catch (e) {
333
+ const errno = e.code;
334
+ if (errno === 'ENOENT')
335
+ continue;
336
+ throw new Error(`rea audit timeline: cannot read ${filePath} (${errno ?? 'unknown errno'}). ` +
337
+ `An unreadable audit segment may contain in-window records, so the ` +
338
+ `timeline would be silently incomplete. Fix permissions ` +
339
+ `(e.g. \`chmod u+r ${filePath}\`), or move the file out of \`.rea/\` ` +
340
+ `if you no longer need it.`);
341
+ }
342
+ filesScanned.push(filePath);
343
+ for (const line of raw.split('\n')) {
344
+ if (line.length === 0)
345
+ continue;
346
+ let parsed;
347
+ try {
348
+ parsed = JSON.parse(line);
349
+ }
350
+ catch {
351
+ continue;
352
+ }
353
+ const ts = parseTimestamp(parsed.timestamp);
354
+ if (ts === null)
355
+ continue;
356
+ if (windowStart !== null && ts < windowStart)
357
+ continue;
358
+ // Upper bound: when --since is set, also drop records strictly
359
+ // AFTER `now` so a future-dated record doesn't bend the
360
+ // peak/heat. The summary path counts them; the timeline path
361
+ // would have nowhere coherent to place them under a fixed-end
362
+ // window (their bucket falls outside the rendered range).
363
+ if (windowEnd !== null && ts > windowEnd)
364
+ continue;
365
+ totalEvents += 1;
366
+ const tsMs = ts.getTime();
367
+ const bucketKey = alignToBucket(tsMs, bucketSeconds);
368
+ buckets.set(bucketKey, (buckets.get(bucketKey) ?? 0) + 1);
369
+ if (earliestRecordMs === null || tsMs < earliestRecordMs)
370
+ earliestRecordMs = tsMs;
371
+ if (latestRecordMs === null || tsMs > latestRecordMs)
372
+ latestRecordMs = tsMs;
373
+ }
374
+ }
375
+ // Determine the bucket span we'll emit.
376
+ // - --since set → emit every bucket from `windowStart`'s aligned
377
+ // boundary up through `windowEnd`'s aligned boundary, inclusive
378
+ // of zero-count intervals (silence is signal).
379
+ // - --since unset → emit only buckets that actually contained a
380
+ // record (no implicit filler).
381
+ const result = [];
382
+ if (windowStart !== null && windowEnd !== null) {
383
+ const startKey = alignToBucket(windowStart.getTime(), bucketSeconds);
384
+ const endKey = alignToBucket(windowEnd.getTime(), bucketSeconds);
385
+ const stepMs = bucketSeconds * 1000;
386
+ // Hard re-check after alignment — pathological inputs (huge
387
+ // --since, tiny --bucket) would already have failed at the
388
+ // projected-count guard above, but a runaway here would freeze
389
+ // the renderer.
390
+ const emit = Math.floor((endKey - startKey) / stepMs) + 1;
391
+ if (emit > MAX_BUCKETS) {
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));
405
+ }
406
+ for (let k = startKey; k <= endKey; k += stepMs) {
407
+ result.push({
408
+ start: new Date(k).toISOString(),
409
+ end: new Date(k + stepMs).toISOString(),
410
+ count: buckets.get(k) ?? 0,
411
+ });
412
+ }
413
+ }
414
+ else if (buckets.size > 0) {
415
+ const keys = Array.from(buckets.keys()).sort((a, b) => a - b);
416
+ const stepMs = bucketSeconds * 1000;
417
+ if (keys.length > MAX_BUCKETS) {
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;
508
+ }
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
+ }
517
+ }
518
+ }
519
+ // Peak index. -1 when no events at all (every bucket is 0 or the
520
+ // list is empty). Ties go to first occurrence — `findIndex` does
521
+ // that for free.
522
+ let peakIndex = -1;
523
+ if (totalEvents > 0) {
524
+ let peakCount = -1;
525
+ for (let i = 0; i < result.length; i += 1) {
526
+ if (result[i].count > peakCount) {
527
+ peakCount = result[i].count;
528
+ peakIndex = i;
529
+ }
530
+ }
531
+ }
532
+ return {
533
+ schema_version: AUDIT_TIMELINE_SCHEMA_VERSION,
534
+ bucket: { raw: bucketRaw, seconds: bucketSeconds },
535
+ window: {
536
+ seconds: windowSeconds,
537
+ start: windowStart !== null ? windowStart.toISOString() : null,
538
+ end: windowEnd !== null ? windowEnd.toISOString() : null,
539
+ },
540
+ buckets: result,
541
+ total_events: totalEvents,
542
+ peak_index: peakIndex,
543
+ files_scanned: filesScanned,
544
+ clamped_since: clampedSince,
545
+ };
546
+ }
547
+ /**
548
+ * Format a bucket-start timestamp for the human renderer. Uses
549
+ * `YYYY-MM-DD HH:MM` (UTC) so the columns stay narrow.
550
+ */
551
+ function formatBucketTimestamp(iso, bucketSeconds) {
552
+ const d = new Date(iso);
553
+ const yyyy = String(d.getUTCFullYear());
554
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
555
+ const dd = String(d.getUTCDate()).padStart(2, '0');
556
+ const hh = String(d.getUTCHours()).padStart(2, '0');
557
+ const mi = String(d.getUTCMinutes()).padStart(2, '0');
558
+ // Day buckets don't need the HH:MM noise (always `00:00`); show
559
+ // just the date to reduce visual clutter.
560
+ if (bucketSeconds % 86400 === 0)
561
+ return `${yyyy}-${mm}-${dd}`;
562
+ return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
563
+ }
564
+ function bucketLabel(seconds, raw) {
565
+ // Honor explicit `HOUR` / `DAY` so the header reads naturally.
566
+ const upper = raw.toUpperCase();
567
+ if (upper === 'HOUR' || upper === 'H' || upper === '1H')
568
+ return 'hourly';
569
+ if (upper === 'DAY' || upper === 'D' || upper === '1D')
570
+ return 'daily';
571
+ // Duration form — show the raw value the operator typed.
572
+ return `every ${raw}`;
573
+ }
574
+ function formatWindowLabel(seconds) {
575
+ if (seconds === null)
576
+ return 'all time';
577
+ const units = [
578
+ ['w', 60 * 60 * 24 * 7],
579
+ ['d', 60 * 60 * 24],
580
+ ['h', 60 * 60],
581
+ ['m', 60],
582
+ ['s', 1],
583
+ ];
584
+ for (const [unit, factor] of units) {
585
+ if (seconds % factor === 0) {
586
+ return `last ${String(seconds / factor)}${unit}`;
587
+ }
588
+ }
589
+ return `last ${String(seconds)}s`;
590
+ }
591
+ /**
592
+ * Render the result as a human-readable terminal block with inline
593
+ * histogram bars. See module docstring for the rendering choices.
594
+ */
595
+ export function renderAuditTimeline(result) {
596
+ const lines = [];
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.
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
+ }
611
+ lines.push(`rea audit timeline (${windowLabel}, ${cadenceLabel})`);
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
+ }
630
+ // Codex round-1 P2 (0.46.0): the zero-events case has two distinct
631
+ // shapes and the renderer must NOT collapse them.
632
+ //
633
+ // - `--since` set + zero events + `buckets.length > 0` — operator
634
+ // asked for an explicit window; we already built the zero-filled
635
+ // bucket lattice in computeAuditTimeline. Show it so silence is
636
+ // visible as flat ▁-less rows rather than a generic
637
+ // "No events" line. That's the WHOLE POINT of the timeline
638
+ // command under --since: distinguish "idle window" from "command
639
+ // never ran".
640
+ // - Otherwise (no --since, or --since with `buckets.length === 0`
641
+ // which means the operator gave us nothing to draw) — render the
642
+ // concise no-events notice. The empty `buckets` path also
643
+ // handles the truly-empty-repo case.
644
+ if (result.total_events === 0 && result.buckets.length === 0) {
645
+ lines.push(result.window.seconds !== null
646
+ ? 'No events in the requested window.'
647
+ : 'No events in the audit log.');
648
+ if (result.files_scanned.length === 0) {
649
+ lines.push('(no audit files found — has `rea serve` ever run?)');
650
+ }
651
+ lines.push('');
652
+ return lines.join('\n');
653
+ }
654
+ // Compute peak count for bar scaling. Default to 1 when all buckets
655
+ // are empty so the bar-width math below stays well-defined (0 / 1
656
+ // = 0 → empty bar, which is what we want in the idle-window case).
657
+ const peakCount = result.buckets.reduce((m, b) => (b.count > m ? b.count : m), 0) || 1;
658
+ // Stable timestamp-column width based on the bucket cadence.
659
+ const sampleTs = formatBucketTimestamp(result.buckets[0].start, result.bucket.seconds);
660
+ const tsWidth = sampleTs.length;
661
+ // Stable count-column width — widest count in the result.
662
+ const maxCountWidth = result.buckets.reduce((m, b) => Math.max(m, String(b.count).length), 1);
663
+ for (let i = 0; i < result.buckets.length; i += 1) {
664
+ const b = result.buckets[i];
665
+ const ts = formatBucketTimestamp(b.start, result.bucket.seconds).padEnd(tsWidth);
666
+ const barWidth = b.count === 0
667
+ ? 0
668
+ : Math.max(1, Math.round((b.count * MAX_BAR_WIDTH) / peakCount));
669
+ const bar = BAR_CHAR.repeat(barWidth).padEnd(MAX_BAR_WIDTH);
670
+ const count = String(b.count).padStart(maxCountWidth);
671
+ // Codex round-1 P2 (0.46.0) follow-up: peak marker only when
672
+ // there were actual events. peak_index is -1 when total_events
673
+ // is 0, but be defensive — never mark a 0-count bucket as peak.
674
+ const peakMarker = i === result.peak_index && b.count > 0 ? ' ← peak' : '';
675
+ lines.push(`${ts} ${bar} ${count} event${b.count === 1 ? ' ' : 's'}${peakMarker}`);
676
+ }
677
+ lines.push('');
678
+ lines.push(`total: ${String(result.total_events)} events across ${String(result.buckets.length)} bucket${result.buckets.length === 1 ? '' : 's'}`);
679
+ lines.push(`files scanned: ${String(result.files_scanned.length)}`);
680
+ lines.push('');
681
+ return lines.join('\n');
682
+ }
683
+ /** Commander entrypoint. */
684
+ export async function runAuditTimeline(options) {
685
+ let result;
686
+ try {
687
+ result = await computeAuditTimeline({
688
+ ...(options.since !== undefined ? { since: options.since } : {}),
689
+ ...(options.bucket !== undefined ? { bucket: options.bucket } : {}),
690
+ ...(options.now !== undefined ? { now: options.now } : {}),
691
+ });
692
+ }
693
+ catch (e) {
694
+ if (e instanceof AuditTimelineOptionError) {
695
+ err(`rea audit timeline: ${e.message}`);
696
+ process.exit(1);
697
+ }
698
+ throw e;
699
+ }
700
+ if (options.json === true) {
701
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
702
+ return;
703
+ }
704
+ process.stdout.write(renderAuditTimeline(result));
705
+ }
706
+ /**
707
+ * Register `rea audit timeline` under the `audit` command group.
708
+ */
709
+ export function registerAuditTimelineCommand(auditCommand) {
710
+ auditCommand
711
+ .command('timeline')
712
+ .description('Time-bucketed event counts — `--bucket=HOUR|DAY` (or duration like `15m`), `--since=DUR`, `--json`. Histogram bar inline. Read-only.')
713
+ .option('--bucket <size>', 'bucket size — `HOUR` (default), `DAY`, or a duration like `15m`, `30m`, `1h`, `1d`')
714
+ .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).')
715
+ .option('--json', 'emit a JSON document instead of the human-readable histogram')
716
+ .action(async (opts) => {
717
+ await runAuditTimeline({
718
+ ...(opts.bucket !== undefined ? { bucket: opts.bucket } : {}),
719
+ ...(opts.since !== undefined ? { since: opts.since } : {}),
720
+ ...(opts.json === true ? { json: true } : {}),
721
+ });
722
+ });
723
+ }