@bookedsolid/rea 0.45.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.
- package/dist/cli/audit-by-tool.d.ts +173 -0
- package/dist/cli/audit-by-tool.js +373 -0
- package/dist/cli/audit-timeline.d.ts +160 -0
- package/dist/cli/audit-timeline.js +481 -0
- package/dist/cli/index.js +10 -0
- package/package.json +1 -1
- package/scripts/profile-hooks.mjs +377 -88
|
@@ -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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.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)",
|