@bookedsolid/rea 0.44.0 → 0.46.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,160 @@
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 type { Command } from 'commander';
78
+ export declare const AUDIT_TIMELINE_SCHEMA_VERSION = 1;
79
+ /**
80
+ * Hard ceiling on the number of buckets the command will produce. A
81
+ * `--since=7d` with `--bucket=1m` would emit 10,080 buckets — well
82
+ * past what a terminal renderer handles gracefully. Capping at 2000
83
+ * still allows `--bucket=15m --since=21d` (`~2016 buckets`) which
84
+ * covers the realistic ops use cases.
85
+ */
86
+ export declare const MAX_BUCKETS = 2000;
87
+ export declare class AuditTimelineOptionError extends Error {
88
+ constructor(message: string);
89
+ }
90
+ export interface AuditTimelineBucket {
91
+ /** Inclusive start (ISO-8601 UTC). */
92
+ start: string;
93
+ /** Exclusive end (ISO-8601 UTC). `end - start === bucket.seconds`. */
94
+ end: string;
95
+ count: number;
96
+ }
97
+ export interface AuditTimelineResult {
98
+ schema_version: typeof AUDIT_TIMELINE_SCHEMA_VERSION;
99
+ bucket: {
100
+ /** The raw `--bucket` value (e.g. `HOUR`, `15m`). */
101
+ raw: string;
102
+ /** Resolved bucket size in seconds. */
103
+ seconds: number;
104
+ };
105
+ window: {
106
+ seconds: number | null;
107
+ start: string | null;
108
+ end: string | null;
109
+ };
110
+ buckets: AuditTimelineBucket[];
111
+ total_events: number;
112
+ /** Index of the bucket with the highest count. `-1` when no events. */
113
+ peak_index: number;
114
+ files_scanned: string[];
115
+ }
116
+ export interface ComputeAuditTimelineOptions {
117
+ /** Override CWD. Tests set this; production uses `process.cwd()`. */
118
+ baseDir?: string;
119
+ /** `--since` (e.g. `24h`, `7d`). Parsed via parseDuration. */
120
+ since?: string;
121
+ /** `--bucket` value (`HOUR`, `DAY`, or duration). Default `HOUR`. */
122
+ bucket?: string;
123
+ /** Test seam — pin "now". */
124
+ now?: Date;
125
+ }
126
+ /**
127
+ * Resolve `--bucket` to a number of seconds. Accepts:
128
+ * - `HOUR` / `H` / `1H` (case-insensitive) → 3600
129
+ * - `DAY` / `D` / `1D` (case-insensitive) → 86400
130
+ * - duration form (`15m`, `30s`, `2h`, `1d`, `1w`) → parsed via
131
+ * `parseDurationSeconds` for shape compatibility with `--since`
132
+ *
133
+ * Bucket size must be >= 1 second; on the upper end we accept any
134
+ * value but `MAX_BUCKETS` will bound the rendered output.
135
+ */
136
+ export declare function resolveBucketSeconds(raw: string): number;
137
+ /**
138
+ * Compute the bucketed timeline. Pure (read-only). Throws
139
+ * `AuditTimelineOptionError` on bad `--since` / `--bucket`; throws on
140
+ * unreadable rotated segments (mirror of audit-summary's stance).
141
+ */
142
+ export declare function computeAuditTimeline(options?: ComputeAuditTimelineOptions): Promise<AuditTimelineResult>;
143
+ /**
144
+ * Render the result as a human-readable terminal block with inline
145
+ * histogram bars. See module docstring for the rendering choices.
146
+ */
147
+ export declare function renderAuditTimeline(result: AuditTimelineResult): string;
148
+ export interface RunAuditTimelineOptions {
149
+ since?: string;
150
+ bucket?: string;
151
+ json?: boolean;
152
+ /** Test seam — pin "now". */
153
+ now?: Date;
154
+ }
155
+ /** Commander entrypoint. */
156
+ export declare function runAuditTimeline(options: RunAuditTimelineOptions): Promise<void>;
157
+ /**
158
+ * Register `rea audit timeline` under the `audit` command group.
159
+ */
160
+ export declare function registerAuditTimelineCommand(auditCommand: Command): void;
@@ -0,0 +1,481 @@
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
+ * Compute the bucketed timeline. Pure (read-only). Throws
170
+ * `AuditTimelineOptionError` on bad `--since` / `--bucket`; throws on
171
+ * unreadable rotated segments (mirror of audit-summary's stance).
172
+ */
173
+ export async function computeAuditTimeline(options = {}) {
174
+ const baseDir = options.baseDir ?? process.cwd();
175
+ const now = options.now ?? new Date();
176
+ const bucketRaw = options.bucket ?? 'HOUR';
177
+ const bucketSeconds = resolveBucketSeconds(bucketRaw);
178
+ if (bucketSeconds < 1) {
179
+ throw new AuditTimelineOptionError(`--bucket: resolved bucket size must be >= 1 second; got ${String(bucketSeconds)}.`);
180
+ }
181
+ let windowSeconds = null;
182
+ let windowStart = null;
183
+ let windowEnd = null;
184
+ if (options.since !== undefined && options.since.length > 0) {
185
+ try {
186
+ windowSeconds = parseDurationSeconds(options.since);
187
+ }
188
+ catch (e) {
189
+ if (e instanceof AuditSummarySinceError) {
190
+ throw new AuditTimelineOptionError(e.message);
191
+ }
192
+ throw e;
193
+ }
194
+ windowEnd = now;
195
+ windowStart = new Date(now.getTime() - windowSeconds * 1000);
196
+ // 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.
199
+ const projected = Math.ceil(windowSeconds / bucketSeconds);
200
+ 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.`);
204
+ }
205
+ }
206
+ const files = await resolveTimelineFileWalk(baseDir);
207
+ // Bucket key is the aligned epoch-ms boundary; value is the count.
208
+ const buckets = new Map();
209
+ let totalEvents = 0;
210
+ let earliestRecordMs = null;
211
+ let latestRecordMs = null;
212
+ const filesScanned = [];
213
+ for (const filePath of files) {
214
+ let raw;
215
+ try {
216
+ raw = await fs.readFile(filePath, 'utf8');
217
+ }
218
+ catch (e) {
219
+ const errno = e.code;
220
+ if (errno === 'ENOENT')
221
+ continue;
222
+ throw new Error(`rea audit timeline: cannot read ${filePath} (${errno ?? 'unknown errno'}). ` +
223
+ `An unreadable audit segment may contain in-window records, so the ` +
224
+ `timeline would be silently incomplete. Fix permissions ` +
225
+ `(e.g. \`chmod u+r ${filePath}\`), or move the file out of \`.rea/\` ` +
226
+ `if you no longer need it.`);
227
+ }
228
+ filesScanned.push(filePath);
229
+ for (const line of raw.split('\n')) {
230
+ if (line.length === 0)
231
+ continue;
232
+ let parsed;
233
+ try {
234
+ parsed = JSON.parse(line);
235
+ }
236
+ catch {
237
+ continue;
238
+ }
239
+ const ts = parseTimestamp(parsed.timestamp);
240
+ if (ts === null)
241
+ continue;
242
+ if (windowStart !== null && ts < windowStart)
243
+ continue;
244
+ // Upper bound: when --since is set, also drop records strictly
245
+ // AFTER `now` so a future-dated record doesn't bend the
246
+ // peak/heat. The summary path counts them; the timeline path
247
+ // would have nowhere coherent to place them under a fixed-end
248
+ // window (their bucket falls outside the rendered range).
249
+ if (windowEnd !== null && ts > windowEnd)
250
+ continue;
251
+ totalEvents += 1;
252
+ const tsMs = ts.getTime();
253
+ const bucketKey = alignToBucket(tsMs, bucketSeconds);
254
+ buckets.set(bucketKey, (buckets.get(bucketKey) ?? 0) + 1);
255
+ if (earliestRecordMs === null || tsMs < earliestRecordMs)
256
+ earliestRecordMs = tsMs;
257
+ if (latestRecordMs === null || tsMs > latestRecordMs)
258
+ latestRecordMs = tsMs;
259
+ }
260
+ }
261
+ // Determine the bucket span we'll emit.
262
+ // - --since set → emit every bucket from `windowStart`'s aligned
263
+ // boundary up through `windowEnd`'s aligned boundary, inclusive
264
+ // of zero-count intervals (silence is signal).
265
+ // - --since unset → emit only buckets that actually contained a
266
+ // record (no implicit filler).
267
+ const result = [];
268
+ if (windowStart !== null && windowEnd !== null) {
269
+ const startKey = alignToBucket(windowStart.getTime(), bucketSeconds);
270
+ const endKey = alignToBucket(windowEnd.getTime(), bucketSeconds);
271
+ const stepMs = bucketSeconds * 1000;
272
+ // Hard re-check after alignment — pathological inputs (huge
273
+ // --since, tiny --bucket) would already have failed at the
274
+ // projected-count guard above, but a runaway here would freeze
275
+ // the renderer.
276
+ const emit = Math.floor((endKey - startKey) / stepMs) + 1;
277
+ 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.`);
280
+ }
281
+ for (let k = startKey; k <= endKey; k += stepMs) {
282
+ result.push({
283
+ start: new Date(k).toISOString(),
284
+ end: new Date(k + stepMs).toISOString(),
285
+ count: buckets.get(k) ?? 0,
286
+ });
287
+ }
288
+ }
289
+ else if (buckets.size > 0) {
290
+ const keys = Array.from(buckets.keys()).sort((a, b) => a - b);
291
+ 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.`);
297
+ }
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
+ });
305
+ }
306
+ }
307
+ // Peak index. -1 when no events at all (every bucket is 0 or the
308
+ // list is empty). Ties go to first occurrence — `findIndex` does
309
+ // that for free.
310
+ let peakIndex = -1;
311
+ if (totalEvents > 0) {
312
+ let peakCount = -1;
313
+ for (let i = 0; i < result.length; i += 1) {
314
+ if (result[i].count > peakCount) {
315
+ peakCount = result[i].count;
316
+ peakIndex = i;
317
+ }
318
+ }
319
+ }
320
+ return {
321
+ schema_version: AUDIT_TIMELINE_SCHEMA_VERSION,
322
+ bucket: { raw: bucketRaw, seconds: bucketSeconds },
323
+ window: {
324
+ seconds: windowSeconds,
325
+ start: windowStart !== null ? windowStart.toISOString() : null,
326
+ end: windowEnd !== null ? windowEnd.toISOString() : null,
327
+ },
328
+ buckets: result,
329
+ total_events: totalEvents,
330
+ peak_index: peakIndex,
331
+ files_scanned: filesScanned,
332
+ };
333
+ }
334
+ /**
335
+ * Format a bucket-start timestamp for the human renderer. Uses
336
+ * `YYYY-MM-DD HH:MM` (UTC) so the columns stay narrow.
337
+ */
338
+ function formatBucketTimestamp(iso, bucketSeconds) {
339
+ const d = new Date(iso);
340
+ const yyyy = String(d.getUTCFullYear());
341
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
342
+ const dd = String(d.getUTCDate()).padStart(2, '0');
343
+ const hh = String(d.getUTCHours()).padStart(2, '0');
344
+ const mi = String(d.getUTCMinutes()).padStart(2, '0');
345
+ // Day buckets don't need the HH:MM noise (always `00:00`); show
346
+ // just the date to reduce visual clutter.
347
+ if (bucketSeconds % 86400 === 0)
348
+ return `${yyyy}-${mm}-${dd}`;
349
+ return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
350
+ }
351
+ function bucketLabel(seconds, raw) {
352
+ // Honor explicit `HOUR` / `DAY` so the header reads naturally.
353
+ const upper = raw.toUpperCase();
354
+ if (upper === 'HOUR' || upper === 'H' || upper === '1H')
355
+ return 'hourly';
356
+ if (upper === 'DAY' || upper === 'D' || upper === '1D')
357
+ return 'daily';
358
+ // Duration form — show the raw value the operator typed.
359
+ return `every ${raw}`;
360
+ }
361
+ function formatWindowLabel(seconds) {
362
+ if (seconds === null)
363
+ return 'all time';
364
+ const units = [
365
+ ['w', 60 * 60 * 24 * 7],
366
+ ['d', 60 * 60 * 24],
367
+ ['h', 60 * 60],
368
+ ['m', 60],
369
+ ['s', 1],
370
+ ];
371
+ for (const [unit, factor] of units) {
372
+ if (seconds % factor === 0) {
373
+ return `last ${String(seconds / factor)}${unit}`;
374
+ }
375
+ }
376
+ return `last ${String(seconds)}s`;
377
+ }
378
+ /**
379
+ * Render the result as a human-readable terminal block with inline
380
+ * histogram bars. See module docstring for the rendering choices.
381
+ */
382
+ export function renderAuditTimeline(result) {
383
+ const lines = [];
384
+ const windowLabel = formatWindowLabel(result.window.seconds);
385
+ const cadenceLabel = bucketLabel(result.bucket.seconds, result.bucket.raw);
386
+ lines.push(`rea audit timeline (${windowLabel}, ${cadenceLabel})`);
387
+ lines.push('─'.repeat(40));
388
+ // Codex round-1 P2 (0.46.0): the zero-events case has two distinct
389
+ // shapes and the renderer must NOT collapse them.
390
+ //
391
+ // - `--since` set + zero events + `buckets.length > 0` — operator
392
+ // asked for an explicit window; we already built the zero-filled
393
+ // bucket lattice in computeAuditTimeline. Show it so silence is
394
+ // visible as flat ▁-less rows rather than a generic
395
+ // "No events" line. That's the WHOLE POINT of the timeline
396
+ // command under --since: distinguish "idle window" from "command
397
+ // never ran".
398
+ // - Otherwise (no --since, or --since with `buckets.length === 0`
399
+ // which means the operator gave us nothing to draw) — render the
400
+ // concise no-events notice. The empty `buckets` path also
401
+ // handles the truly-empty-repo case.
402
+ if (result.total_events === 0 && result.buckets.length === 0) {
403
+ lines.push(result.window.seconds !== null
404
+ ? 'No events in the requested window.'
405
+ : 'No events in the audit log.');
406
+ if (result.files_scanned.length === 0) {
407
+ lines.push('(no audit files found — has `rea serve` ever run?)');
408
+ }
409
+ lines.push('');
410
+ return lines.join('\n');
411
+ }
412
+ // Compute peak count for bar scaling. Default to 1 when all buckets
413
+ // are empty so the bar-width math below stays well-defined (0 / 1
414
+ // = 0 → empty bar, which is what we want in the idle-window case).
415
+ const peakCount = result.buckets.reduce((m, b) => (b.count > m ? b.count : m), 0) || 1;
416
+ // Stable timestamp-column width based on the bucket cadence.
417
+ const sampleTs = formatBucketTimestamp(result.buckets[0].start, result.bucket.seconds);
418
+ const tsWidth = sampleTs.length;
419
+ // Stable count-column width — widest count in the result.
420
+ const maxCountWidth = result.buckets.reduce((m, b) => Math.max(m, String(b.count).length), 1);
421
+ for (let i = 0; i < result.buckets.length; i += 1) {
422
+ const b = result.buckets[i];
423
+ const ts = formatBucketTimestamp(b.start, result.bucket.seconds).padEnd(tsWidth);
424
+ const barWidth = b.count === 0
425
+ ? 0
426
+ : Math.max(1, Math.round((b.count * MAX_BAR_WIDTH) / peakCount));
427
+ const bar = BAR_CHAR.repeat(barWidth).padEnd(MAX_BAR_WIDTH);
428
+ const count = String(b.count).padStart(maxCountWidth);
429
+ // Codex round-1 P2 (0.46.0) follow-up: peak marker only when
430
+ // there were actual events. peak_index is -1 when total_events
431
+ // is 0, but be defensive — never mark a 0-count bucket as peak.
432
+ const peakMarker = i === result.peak_index && b.count > 0 ? ' ← peak' : '';
433
+ lines.push(`${ts} ${bar} ${count} event${b.count === 1 ? ' ' : 's'}${peakMarker}`);
434
+ }
435
+ lines.push('');
436
+ lines.push(`total: ${String(result.total_events)} events across ${String(result.buckets.length)} bucket${result.buckets.length === 1 ? '' : 's'}`);
437
+ lines.push(`files scanned: ${String(result.files_scanned.length)}`);
438
+ lines.push('');
439
+ return lines.join('\n');
440
+ }
441
+ /** Commander entrypoint. */
442
+ export async function runAuditTimeline(options) {
443
+ let result;
444
+ try {
445
+ result = await computeAuditTimeline({
446
+ ...(options.since !== undefined ? { since: options.since } : {}),
447
+ ...(options.bucket !== undefined ? { bucket: options.bucket } : {}),
448
+ ...(options.now !== undefined ? { now: options.now } : {}),
449
+ });
450
+ }
451
+ catch (e) {
452
+ if (e instanceof AuditTimelineOptionError) {
453
+ err(`rea audit timeline: ${e.message}`);
454
+ process.exit(1);
455
+ }
456
+ throw e;
457
+ }
458
+ if (options.json === true) {
459
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
460
+ return;
461
+ }
462
+ process.stdout.write(renderAuditTimeline(result));
463
+ }
464
+ /**
465
+ * Register `rea audit timeline` under the `audit` command group.
466
+ */
467
+ export function registerAuditTimelineCommand(auditCommand) {
468
+ auditCommand
469
+ .command('timeline')
470
+ .description('Time-bucketed event counts — `--bucket=HOUR|DAY` (or duration like `15m`), `--since=DUR`, `--json`. Histogram bar inline. Read-only.')
471
+ .option('--bucket <size>', 'bucket size — `HOUR` (default), `DAY`, or a duration like `15m`, `30m`, `1h`, `1d`')
472
+ .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).')
473
+ .option('--json', 'emit a JSON document instead of the human-readable histogram')
474
+ .action(async (opts) => {
475
+ await runAuditTimeline({
476
+ ...(opts.bucket !== undefined ? { bucket: opts.bucket } : {}),
477
+ ...(opts.since !== undefined ? { since: opts.since } : {}),
478
+ ...(opts.json === true ? { json: true } : {}),
479
+ });
480
+ });
481
+ }
package/dist/cli/index.js CHANGED
@@ -15,6 +15,8 @@ import { runTofuAccept, runTofuList } from './tofu.js';
15
15
  import { runUpgrade } from './upgrade.js';
16
16
  import { runUpgradeCheck } from './upgrade-check.js';
17
17
  import { registerAuditSummaryCommand } from './audit-summary.js';
18
+ import { registerAuditByToolCommand } from './audit-by-tool.js';
19
+ import { registerAuditTimelineCommand } from './audit-timeline.js';
18
20
  import { registerVerifyClaimCommand } from './verify-claim.js';
19
21
  import { err, getPkgVersion } from './utils.js';
20
22
  async function main() {
@@ -144,6 +146,14 @@ async function main() {
144
146
  // overview reader. Counts events by tool_name, tier, session,
145
147
  // status; samples chain integrity. Tier-Read; never mutates.
146
148
  registerAuditSummaryCommand(audit);
149
+ // 0.46.0 charter item 1 — `rea audit by-tool [--top=N] [--since=DUR]
150
+ // [--json]`. Higher-fidelity tool_name distribution than `summary`
151
+ // (which caps at 12 + `(other)`). Reads the same rotated-file walk.
152
+ registerAuditByToolCommand(audit);
153
+ // 0.46.0 charter item 2 — `rea audit timeline [--bucket=HOUR|DAY]
154
+ // [--since=DUR] [--json]`. Time-bucketed event counts with inline
155
+ // histogram. Useful for spotting activity spikes + cadence patterns.
156
+ registerAuditTimelineCommand(audit);
147
157
  // Register `rea hook push-gate` — the stateless pre-push Codex gate
148
158
  // called by `.husky/pre-push` and `.git/hooks/pre-push`.
149
159
  registerHookCommand(program);