@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.
- package/MIGRATING.md +89 -0
- 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 +180 -0
- package/dist/cli/audit-timeline.js +723 -0
- package/dist/cli/audit-top-blocks.d.ts +154 -0
- package/dist/cli/audit-top-blocks.js +419 -0
- package/dist/cli/index.js +15 -0
- package/dist/config/tier-map.js +32 -0
- package/package.json +1 -1
- package/scripts/profile-hooks.mjs +377 -88
package/MIGRATING.md
CHANGED
|
@@ -528,6 +528,95 @@ under `--since` and lets the per-record timestamp filter drop the
|
|
|
528
528
|
out-of-window entries. Correctness over micro-optimization;
|
|
529
529
|
`rea audit summary` performance is unchanged in practice.
|
|
530
530
|
|
|
531
|
+
## Audit observability completion (added in 0.47.0)
|
|
532
|
+
|
|
533
|
+
0.46.0 shipped `rea audit by-tool` and `rea audit timeline`. 0.47.0
|
|
534
|
+
rounds out the observability surface with two timeline ergonomics fixes
|
|
535
|
+
and a new refusal-debugging reader:
|
|
536
|
+
|
|
537
|
+
### `rea audit timeline` — helpful MAX_BUCKETS errors + auto-clamp
|
|
538
|
+
|
|
539
|
+
Pre-0.47.0, `rea audit timeline --bucket=15m --since=21d` (= 2016
|
|
540
|
+
buckets, just past the 2000-bucket ceiling) rejected with a generic
|
|
541
|
+
"use a larger --bucket or narrower --since" message. The 0.47.0 error
|
|
542
|
+
now carries concrete remediation:
|
|
543
|
+
|
|
544
|
+
```text
|
|
545
|
+
rea audit timeline: --bucket=15m × --since=21d = 2016 buckets exceeds
|
|
546
|
+
MAX_BUCKETS=2000. Try --bucket=1h (504 buckets) or --since=20d 20h
|
|
547
|
+
(1999 buckets).
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
For the related "I omitted `--since` and the audit log spans a year"
|
|
551
|
+
case, the timeline now AUTO-CLAMPS to the widest window that fits at
|
|
552
|
+
the requested cadence rather than throwing. The clamp is surfaced
|
|
553
|
+
inline in human output:
|
|
554
|
+
|
|
555
|
+
```text
|
|
556
|
+
rea audit timeline (clamped to ~1999h of newest activity, hourly)
|
|
557
|
+
────────────────────────────────────────
|
|
558
|
+
note: --since not specified; auto-clamped to newest 2000 buckets
|
|
559
|
+
(~1999h span at --bucket=1h). Pass --since=DUR to anchor at
|
|
560
|
+
now, or rerun with a WIDER --bucket (current 1h) to fit the
|
|
561
|
+
full log.
|
|
562
|
+
…
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
JSON consumers see the clamp as a new `clamped_since` field — `null`
|
|
566
|
+
in the common case, a duration string (e.g. `"1999h"`) when the
|
|
567
|
+
clamp fired. The field is informational, not reproducible: `--since`
|
|
568
|
+
always anchors at `now`, so a clamp anchored at an older record
|
|
569
|
+
cannot be round-tripped through `--since=<clamped_since>`. Use the
|
|
570
|
+
field to detect that clamping occurred and to size the rendered
|
|
571
|
+
window in dashboards. For a fully reproducible view, pass `--since`
|
|
572
|
+
or `--bucket` explicitly. Schema version is unchanged (still v1) —
|
|
573
|
+
the field is purely additive. `window.start/end/seconds` is also
|
|
574
|
+
nulled out on sparse-log clamps where the kept buckets don't form a
|
|
575
|
+
contiguous time lattice, so `total_events / window.seconds` never
|
|
576
|
+
derives a misleading rate.
|
|
577
|
+
|
|
578
|
+
### `rea audit top-blocks` — debugging "why was that refused?"
|
|
579
|
+
|
|
580
|
+
A new subcommand surfaces the most recent refusal events (any record
|
|
581
|
+
whose `status` is `denied` or `error`) from the audit log:
|
|
582
|
+
|
|
583
|
+
```bash
|
|
584
|
+
rea audit top-blocks # last 20 refusals, all time
|
|
585
|
+
rea audit top-blocks --since=24h # last 24h
|
|
586
|
+
rea audit top-blocks --since=7d --limit=50 # last week, top 50
|
|
587
|
+
rea audit top-blocks --json # dashboard shape
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
Each row carries the short hash (first 8 chars), full timestamp, tool
|
|
591
|
+
name, and the refusal reason (sourced from the record's `error` field;
|
|
592
|
+
truncated to ~80 chars in human output, full text in JSON). Sorted
|
|
593
|
+
newest-first so the most recent refusals are at the top.
|
|
594
|
+
|
|
595
|
+
Use this when an agent reports "the hook blocked my push" or "the
|
|
596
|
+
write was refused" and you need the exact reason without grepping
|
|
597
|
+
`.rea/audit.jsonl` by hand.
|
|
598
|
+
|
|
599
|
+
JSON shape (stable, v1):
|
|
600
|
+
|
|
601
|
+
```json
|
|
602
|
+
{
|
|
603
|
+
"schema_version": 1,
|
|
604
|
+
"since": "24h",
|
|
605
|
+
"limit": 20,
|
|
606
|
+
"window": { "seconds": 86400, "start": "...", "end": "..." },
|
|
607
|
+
"total_matched": 4,
|
|
608
|
+
"events": [
|
|
609
|
+
{ "hash": "...", "timestamp": "...", "tool": "Bash",
|
|
610
|
+
"status": "denied", "reason": "...", "session_id": "..." }
|
|
611
|
+
],
|
|
612
|
+
"files_scanned": ["/abs/path/.rea/audit.jsonl"]
|
|
613
|
+
}
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
`total_matched` is the pre-limit count, so dashboards can show
|
|
617
|
+
"20 of 47 refusals in window". Walk scope mirrors the sibling audit
|
|
618
|
+
readers — current `.rea/audit.jsonl` PLUS every rotated segment.
|
|
619
|
+
|
|
531
620
|
## Policy knobs worth setting
|
|
532
621
|
|
|
533
622
|
For consumers with a long-running migration branch (>30 commits since
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rea audit by-tool [--top=N] [--since=DUR] [--json]` — 0.46.0
|
|
3
|
+
* charter item 1.
|
|
4
|
+
*
|
|
5
|
+
* Surface the audit log's tool-name distribution at higher fidelity
|
|
6
|
+
* than `audit summary`. `summary` caps its `by tool_name` rendering at
|
|
7
|
+
* 12 rows and rolls the rest into `(other)`; `by-tool` is the focused
|
|
8
|
+
* lens — it walks the same files but lets the operator pick a deeper
|
|
9
|
+
* cut (`--top=N` up to 1000) and only emits the tool-name distribution
|
|
10
|
+
* + the surrounding window metadata. Useful for:
|
|
11
|
+
*
|
|
12
|
+
* - Spotting unexpected tool usage patterns ("why is Write firing
|
|
13
|
+
* 10x more than Edit?")
|
|
14
|
+
* - Identifying the top consumers of a session's activity
|
|
15
|
+
* - CI dashboards / monitoring pipelines (the `--json` shape is
|
|
16
|
+
* stable and minimal)
|
|
17
|
+
*
|
|
18
|
+
* # Walk scope
|
|
19
|
+
*
|
|
20
|
+
* Mirrors `rea audit summary` exactly: the current `.rea/audit.jsonl`
|
|
21
|
+
* PLUS every rotated `audit-YYYYMMDD-HHMMSS(-N).jsonl` segment is
|
|
22
|
+
* walked regardless of whether `--since` is set, and the per-record
|
|
23
|
+
* `timestamp >= now - duration` filter decides what counts (0.42.0
|
|
24
|
+
* charter item 3 / 0.41.0 round-3 P2 — rotated filenames mark the
|
|
25
|
+
* rotation instant, not the earliest record contained). Walking every
|
|
26
|
+
* segment is the only sound way to count tools under a window when a
|
|
27
|
+
* late-rotation may have absorbed days of records under a single
|
|
28
|
+
* trailing filename stamp.
|
|
29
|
+
*
|
|
30
|
+
* # Output (default)
|
|
31
|
+
*
|
|
32
|
+
* rea audit by-tool (last 24h, top 20)
|
|
33
|
+
* ─────────────────────────────────────
|
|
34
|
+
* Bash 612 (49.1%)
|
|
35
|
+
* Edit 289 (23.2%)
|
|
36
|
+
* Write 127 (10.2%)
|
|
37
|
+
* Agent 47 ( 3.8%)
|
|
38
|
+
* rea.delegation_signal 42 ( 3.4%)
|
|
39
|
+
* …
|
|
40
|
+
*
|
|
41
|
+
* Tools beyond `--top` are summarized as `(other: N tools, M events)`
|
|
42
|
+
* so the operator can see the long-tail at a glance without scrolling.
|
|
43
|
+
*
|
|
44
|
+
* # JSON output
|
|
45
|
+
*
|
|
46
|
+
* {
|
|
47
|
+
* "schema_version": 1,
|
|
48
|
+
* "window": {
|
|
49
|
+
* "seconds": 86400,
|
|
50
|
+
* "start": "2026-05-16T13:42:00Z",
|
|
51
|
+
* "end": "2026-05-17T13:42:00Z"
|
|
52
|
+
* },
|
|
53
|
+
* "total_events": 1247,
|
|
54
|
+
* "unique_tools": 18,
|
|
55
|
+
* "top": 20,
|
|
56
|
+
* "tools": [
|
|
57
|
+
* { "name": "Bash", "count": 612, "pct": 49.08 },
|
|
58
|
+
* { "name": "Edit", "count": 289, "pct": 23.18 },
|
|
59
|
+
* …
|
|
60
|
+
* ],
|
|
61
|
+
* "files_scanned": ["/abs/path/.rea/audit.jsonl"]
|
|
62
|
+
* }
|
|
63
|
+
*
|
|
64
|
+
* `pct` is the share of TOTAL events (not the share of the visible
|
|
65
|
+
* top-N) so dashboards can compose multiple windows without re-deriving
|
|
66
|
+
* denominators. `tools` is truncated to `--top`; the long tail's
|
|
67
|
+
* cardinality / event total is computable as `unique_tools - top` /
|
|
68
|
+
* `total_events - sum(top.count)`.
|
|
69
|
+
*/
|
|
70
|
+
import type { Command } from 'commander';
|
|
71
|
+
export declare const AUDIT_BY_TOOL_SCHEMA_VERSION = 1;
|
|
72
|
+
/**
|
|
73
|
+
* Default `--top` value. 20 is enough to fit a typical session's
|
|
74
|
+
* distinct tool set comfortably while keeping the table short enough
|
|
75
|
+
* to scan at a glance. Operators wanting a deeper cut pass `--top=N`;
|
|
76
|
+
* we cap at `MAX_TOP` to keep the renderer from blowing up on a
|
|
77
|
+
* runaway log.
|
|
78
|
+
*/
|
|
79
|
+
export declare const DEFAULT_TOP = 20;
|
|
80
|
+
/**
|
|
81
|
+
* Hard ceiling on `--top`. A single audit file shouldn't realistically
|
|
82
|
+
* grow past a few hundred distinct tool_names in normal use; 1000 is
|
|
83
|
+
* a generous limit that still bounds the renderer / JSON output.
|
|
84
|
+
*/
|
|
85
|
+
export declare const MAX_TOP = 1000;
|
|
86
|
+
/**
|
|
87
|
+
* Thrown when `--top` is outside the [1, MAX_TOP] range or `--since`
|
|
88
|
+
* fails to parse. The commander wrapper catches it and exits 1.
|
|
89
|
+
*
|
|
90
|
+
* We do NOT reuse `AuditSummarySinceError` for `--top` because the
|
|
91
|
+
* caller-facing message names a different flag — keeping the error
|
|
92
|
+
* class distinct keeps the error text from cross-contaminating.
|
|
93
|
+
*/
|
|
94
|
+
export declare class AuditByToolOptionError extends Error {
|
|
95
|
+
constructor(message: string);
|
|
96
|
+
}
|
|
97
|
+
export interface AuditByToolToolEntry {
|
|
98
|
+
name: string;
|
|
99
|
+
count: number;
|
|
100
|
+
/** Share of TOTAL events (not share of visible top-N). 2 decimals. */
|
|
101
|
+
pct: number;
|
|
102
|
+
}
|
|
103
|
+
export interface AuditByToolResult {
|
|
104
|
+
schema_version: typeof AUDIT_BY_TOOL_SCHEMA_VERSION;
|
|
105
|
+
window: {
|
|
106
|
+
/** Window length in seconds. `null` when no `--since` filter. */
|
|
107
|
+
seconds: number | null;
|
|
108
|
+
/** Inclusive window start. `null` when no filter. */
|
|
109
|
+
start: string | null;
|
|
110
|
+
/** Window end — always `now` when filter set; `null` otherwise. */
|
|
111
|
+
end: string | null;
|
|
112
|
+
};
|
|
113
|
+
total_events: number;
|
|
114
|
+
/** Cardinality of the FULL tool_name set (not just the top-N). */
|
|
115
|
+
unique_tools: number;
|
|
116
|
+
/** The resolved `--top` value actually used. */
|
|
117
|
+
top: number;
|
|
118
|
+
/** Pre-sorted (desc by count, then alpha on tie) — capped at `top`. */
|
|
119
|
+
tools: AuditByToolToolEntry[];
|
|
120
|
+
/** Absolute paths of audit files actually read. */
|
|
121
|
+
files_scanned: string[];
|
|
122
|
+
}
|
|
123
|
+
export interface ComputeAuditByToolOptions {
|
|
124
|
+
/** Override CWD. Tests set this; production uses `process.cwd()`. */
|
|
125
|
+
baseDir?: string;
|
|
126
|
+
/** Raw `--since` value (e.g. `24h`, `7d`). Parsed via parseDuration. */
|
|
127
|
+
since?: string;
|
|
128
|
+
/** Raw `--top` value. Default `DEFAULT_TOP`. */
|
|
129
|
+
top?: number;
|
|
130
|
+
/** Test seam — pin "now" for deterministic window calculations. */
|
|
131
|
+
now?: Date;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Compute the by-tool rollup. Pure (read-only). Throws
|
|
135
|
+
* `AuditByToolOptionError` on bad `--since` / `--top`; surfaces
|
|
136
|
+
* everything else via the result.
|
|
137
|
+
*/
|
|
138
|
+
export declare function computeAuditByTool(options?: ComputeAuditByToolOptions): Promise<AuditByToolResult>;
|
|
139
|
+
/**
|
|
140
|
+
* Render the result as a human-readable terminal block. JSON callers
|
|
141
|
+
* bypass this; the rendering is intentionally simple (no Unicode bars
|
|
142
|
+
* — that's the `timeline` command's job).
|
|
143
|
+
*/
|
|
144
|
+
export declare function renderAuditByTool(result: AuditByToolResult): string;
|
|
145
|
+
export interface RunAuditByToolOptions {
|
|
146
|
+
since?: string;
|
|
147
|
+
top?: number;
|
|
148
|
+
json?: boolean;
|
|
149
|
+
/** Test seam — pin "now". */
|
|
150
|
+
now?: Date;
|
|
151
|
+
}
|
|
152
|
+
/** Commander entrypoint. */
|
|
153
|
+
export declare function runAuditByTool(options: RunAuditByToolOptions): Promise<void>;
|
|
154
|
+
/**
|
|
155
|
+
* Strict integer parser for the commander `--top <n>` option.
|
|
156
|
+
*
|
|
157
|
+
* Codex round-1 P3 (0.46.0): reject any input that isn't a bare
|
|
158
|
+
* integer. `Number.parseInt` silently truncates `1.5` to `1` and
|
|
159
|
+
* accepts `10abc` as `10`, which would change the requested top-N
|
|
160
|
+
* under the operator's nose without an error signal. We require the
|
|
161
|
+
* raw string to match `^-?\d+$` so the numeric parse can't drop
|
|
162
|
+
* characters. The downstream range validation in `computeAuditByTool`
|
|
163
|
+
* still enforces [1, MAX_TOP].
|
|
164
|
+
*
|
|
165
|
+
* Exported for direct test coverage — commander's option-parser
|
|
166
|
+
* callback shape doesn't compose well with the in-process testing we
|
|
167
|
+
* want here, so we pin the parser as a unit instead.
|
|
168
|
+
*/
|
|
169
|
+
export declare function parseTopOption(raw: string): number;
|
|
170
|
+
/**
|
|
171
|
+
* Register `rea audit by-tool` under the `audit` command group.
|
|
172
|
+
*/
|
|
173
|
+
export declare function registerAuditByToolCommand(auditCommand: Command): void;
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rea audit by-tool [--top=N] [--since=DUR] [--json]` — 0.46.0
|
|
3
|
+
* charter item 1.
|
|
4
|
+
*
|
|
5
|
+
* Surface the audit log's tool-name distribution at higher fidelity
|
|
6
|
+
* than `audit summary`. `summary` caps its `by tool_name` rendering at
|
|
7
|
+
* 12 rows and rolls the rest into `(other)`; `by-tool` is the focused
|
|
8
|
+
* lens — it walks the same files but lets the operator pick a deeper
|
|
9
|
+
* cut (`--top=N` up to 1000) and only emits the tool-name distribution
|
|
10
|
+
* + the surrounding window metadata. Useful for:
|
|
11
|
+
*
|
|
12
|
+
* - Spotting unexpected tool usage patterns ("why is Write firing
|
|
13
|
+
* 10x more than Edit?")
|
|
14
|
+
* - Identifying the top consumers of a session's activity
|
|
15
|
+
* - CI dashboards / monitoring pipelines (the `--json` shape is
|
|
16
|
+
* stable and minimal)
|
|
17
|
+
*
|
|
18
|
+
* # Walk scope
|
|
19
|
+
*
|
|
20
|
+
* Mirrors `rea audit summary` exactly: the current `.rea/audit.jsonl`
|
|
21
|
+
* PLUS every rotated `audit-YYYYMMDD-HHMMSS(-N).jsonl` segment is
|
|
22
|
+
* walked regardless of whether `--since` is set, and the per-record
|
|
23
|
+
* `timestamp >= now - duration` filter decides what counts (0.42.0
|
|
24
|
+
* charter item 3 / 0.41.0 round-3 P2 — rotated filenames mark the
|
|
25
|
+
* rotation instant, not the earliest record contained). Walking every
|
|
26
|
+
* segment is the only sound way to count tools under a window when a
|
|
27
|
+
* late-rotation may have absorbed days of records under a single
|
|
28
|
+
* trailing filename stamp.
|
|
29
|
+
*
|
|
30
|
+
* # Output (default)
|
|
31
|
+
*
|
|
32
|
+
* rea audit by-tool (last 24h, top 20)
|
|
33
|
+
* ─────────────────────────────────────
|
|
34
|
+
* Bash 612 (49.1%)
|
|
35
|
+
* Edit 289 (23.2%)
|
|
36
|
+
* Write 127 (10.2%)
|
|
37
|
+
* Agent 47 ( 3.8%)
|
|
38
|
+
* rea.delegation_signal 42 ( 3.4%)
|
|
39
|
+
* …
|
|
40
|
+
*
|
|
41
|
+
* Tools beyond `--top` are summarized as `(other: N tools, M events)`
|
|
42
|
+
* so the operator can see the long-tail at a glance without scrolling.
|
|
43
|
+
*
|
|
44
|
+
* # JSON output
|
|
45
|
+
*
|
|
46
|
+
* {
|
|
47
|
+
* "schema_version": 1,
|
|
48
|
+
* "window": {
|
|
49
|
+
* "seconds": 86400,
|
|
50
|
+
* "start": "2026-05-16T13:42:00Z",
|
|
51
|
+
* "end": "2026-05-17T13:42:00Z"
|
|
52
|
+
* },
|
|
53
|
+
* "total_events": 1247,
|
|
54
|
+
* "unique_tools": 18,
|
|
55
|
+
* "top": 20,
|
|
56
|
+
* "tools": [
|
|
57
|
+
* { "name": "Bash", "count": 612, "pct": 49.08 },
|
|
58
|
+
* { "name": "Edit", "count": 289, "pct": 23.18 },
|
|
59
|
+
* …
|
|
60
|
+
* ],
|
|
61
|
+
* "files_scanned": ["/abs/path/.rea/audit.jsonl"]
|
|
62
|
+
* }
|
|
63
|
+
*
|
|
64
|
+
* `pct` is the share of TOTAL events (not the share of the visible
|
|
65
|
+
* top-N) so dashboards can compose multiple windows without re-deriving
|
|
66
|
+
* denominators. `tools` is truncated to `--top`; the long tail's
|
|
67
|
+
* cardinality / event total is computable as `unique_tools - top` /
|
|
68
|
+
* `total_events - sum(top.count)`.
|
|
69
|
+
*/
|
|
70
|
+
import fs from 'node:fs/promises';
|
|
71
|
+
import path from 'node:path';
|
|
72
|
+
import { listRotatedAuditFiles } from './audit-specialists.js';
|
|
73
|
+
import { AuditSummarySinceError, parseDurationSeconds, } from './audit-summary.js';
|
|
74
|
+
import { AUDIT_FILE, REA_DIR, err } from './utils.js';
|
|
75
|
+
export const AUDIT_BY_TOOL_SCHEMA_VERSION = 1;
|
|
76
|
+
/**
|
|
77
|
+
* Default `--top` value. 20 is enough to fit a typical session's
|
|
78
|
+
* distinct tool set comfortably while keeping the table short enough
|
|
79
|
+
* to scan at a glance. Operators wanting a deeper cut pass `--top=N`;
|
|
80
|
+
* we cap at `MAX_TOP` to keep the renderer from blowing up on a
|
|
81
|
+
* runaway log.
|
|
82
|
+
*/
|
|
83
|
+
export const DEFAULT_TOP = 20;
|
|
84
|
+
/**
|
|
85
|
+
* Hard ceiling on `--top`. A single audit file shouldn't realistically
|
|
86
|
+
* grow past a few hundred distinct tool_names in normal use; 1000 is
|
|
87
|
+
* a generous limit that still bounds the renderer / JSON output.
|
|
88
|
+
*/
|
|
89
|
+
export const MAX_TOP = 1000;
|
|
90
|
+
/**
|
|
91
|
+
* Thrown when `--top` is outside the [1, MAX_TOP] range or `--since`
|
|
92
|
+
* fails to parse. The commander wrapper catches it and exits 1.
|
|
93
|
+
*
|
|
94
|
+
* We do NOT reuse `AuditSummarySinceError` for `--top` because the
|
|
95
|
+
* caller-facing message names a different flag — keeping the error
|
|
96
|
+
* class distinct keeps the error text from cross-contaminating.
|
|
97
|
+
*/
|
|
98
|
+
export class AuditByToolOptionError extends Error {
|
|
99
|
+
constructor(message) {
|
|
100
|
+
super(message);
|
|
101
|
+
this.name = 'AuditByToolOptionError';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the audit files to walk. Identical strategy to
|
|
106
|
+
* `audit-summary.ts` — walk every rotated segment regardless of
|
|
107
|
+
* `--since` (the per-record timestamp filter inside the main loop
|
|
108
|
+
* decides what counts). We inline a small helper instead of importing
|
|
109
|
+
* the summary's `resolveSummaryFileWalk` to keep the public surface of
|
|
110
|
+
* `audit-summary.ts` narrow; the logic is small and unlikely to drift.
|
|
111
|
+
*/
|
|
112
|
+
async function resolveByToolFileWalk(baseDir) {
|
|
113
|
+
const reaDir = path.join(baseDir, REA_DIR);
|
|
114
|
+
const currentAudit = path.join(reaDir, AUDIT_FILE);
|
|
115
|
+
const files = [];
|
|
116
|
+
const rotated = await listRotatedAuditFiles(reaDir);
|
|
117
|
+
for (const name of rotated)
|
|
118
|
+
files.push(path.join(reaDir, name));
|
|
119
|
+
try {
|
|
120
|
+
const stat = await fs.stat(currentAudit);
|
|
121
|
+
if (stat.isFile())
|
|
122
|
+
files.push(currentAudit);
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
if (e.code !== 'ENOENT')
|
|
126
|
+
throw e;
|
|
127
|
+
}
|
|
128
|
+
return files;
|
|
129
|
+
}
|
|
130
|
+
function parseTimestamp(raw) {
|
|
131
|
+
if (typeof raw !== 'string')
|
|
132
|
+
return null;
|
|
133
|
+
const d = new Date(raw);
|
|
134
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Compute the by-tool rollup. Pure (read-only). Throws
|
|
138
|
+
* `AuditByToolOptionError` on bad `--since` / `--top`; surfaces
|
|
139
|
+
* everything else via the result.
|
|
140
|
+
*/
|
|
141
|
+
export async function computeAuditByTool(options = {}) {
|
|
142
|
+
const baseDir = options.baseDir ?? process.cwd();
|
|
143
|
+
const now = options.now ?? new Date();
|
|
144
|
+
// Resolve --top first so a bad value fails fast before any I/O.
|
|
145
|
+
const top = options.top ?? DEFAULT_TOP;
|
|
146
|
+
if (!Number.isInteger(top) || top < 1 || top > MAX_TOP) {
|
|
147
|
+
throw new AuditByToolOptionError(`--top: must be an integer between 1 and ${String(MAX_TOP)}; got ${JSON.stringify(top)}.`);
|
|
148
|
+
}
|
|
149
|
+
let windowSeconds = null;
|
|
150
|
+
let windowStart = null;
|
|
151
|
+
let windowEnd = null;
|
|
152
|
+
if (options.since !== undefined && options.since.length > 0) {
|
|
153
|
+
try {
|
|
154
|
+
windowSeconds = parseDurationSeconds(options.since);
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
if (e instanceof AuditSummarySinceError) {
|
|
158
|
+
// Re-throw under our own error class so the commander wrapper's
|
|
159
|
+
// catch matches a single type.
|
|
160
|
+
throw new AuditByToolOptionError(e.message);
|
|
161
|
+
}
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
windowEnd = now;
|
|
165
|
+
windowStart = new Date(now.getTime() - windowSeconds * 1000);
|
|
166
|
+
}
|
|
167
|
+
const files = await resolveByToolFileWalk(baseDir);
|
|
168
|
+
const byToolName = new Map();
|
|
169
|
+
let totalEvents = 0;
|
|
170
|
+
const filesScanned = [];
|
|
171
|
+
for (const filePath of files) {
|
|
172
|
+
let raw;
|
|
173
|
+
try {
|
|
174
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
const errno = e.code;
|
|
178
|
+
if (errno === 'ENOENT')
|
|
179
|
+
continue;
|
|
180
|
+
// Mirror audit-summary's stance: any non-ENOENT read error is
|
|
181
|
+
// fatal. A silent soft-skip on a rotated segment that may
|
|
182
|
+
// contain in-window records would let `by-tool` report a
|
|
183
|
+
// misleading distribution with no signal to the operator.
|
|
184
|
+
throw new Error(`rea audit by-tool: cannot read ${filePath} (${errno ?? 'unknown errno'}). ` +
|
|
185
|
+
`An unreadable audit segment may contain in-window records, so the ` +
|
|
186
|
+
`distribution would be silently incomplete. Fix permissions ` +
|
|
187
|
+
`(e.g. \`chmod u+r ${filePath}\`), or move the file out of \`.rea/\` ` +
|
|
188
|
+
`if you no longer need it.`);
|
|
189
|
+
}
|
|
190
|
+
filesScanned.push(filePath);
|
|
191
|
+
for (const line of raw.split('\n')) {
|
|
192
|
+
if (line.length === 0)
|
|
193
|
+
continue;
|
|
194
|
+
let parsed;
|
|
195
|
+
try {
|
|
196
|
+
parsed = JSON.parse(line);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// Malformed line — `rea audit verify` is the right tool. by-tool
|
|
200
|
+
// skips and continues so a corrupt mid-file line doesn't tank
|
|
201
|
+
// the distribution.
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const ts = parseTimestamp(parsed.timestamp);
|
|
205
|
+
if (windowStart !== null && (ts === null || ts < windowStart))
|
|
206
|
+
continue;
|
|
207
|
+
totalEvents += 1;
|
|
208
|
+
const toolName = typeof parsed.tool_name === 'string' && parsed.tool_name.length > 0
|
|
209
|
+
? parsed.tool_name
|
|
210
|
+
: '(unknown)';
|
|
211
|
+
byToolName.set(toolName, (byToolName.get(toolName) ?? 0) + 1);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Sort desc by count, then alpha on tie. Cap at `top` — the long
|
|
215
|
+
// tail is summarized in the renderer / surfaced via the
|
|
216
|
+
// `unique_tools` field in the JSON shape.
|
|
217
|
+
const sorted = Array.from(byToolName.entries())
|
|
218
|
+
.sort((a, b) => {
|
|
219
|
+
if (b[1] !== a[1])
|
|
220
|
+
return b[1] - a[1];
|
|
221
|
+
return a[0].localeCompare(b[0]);
|
|
222
|
+
})
|
|
223
|
+
.slice(0, top)
|
|
224
|
+
.map(([name, count]) => ({
|
|
225
|
+
name,
|
|
226
|
+
count,
|
|
227
|
+
pct: totalEvents > 0 ? Math.round((count * 10000) / totalEvents) / 100 : 0,
|
|
228
|
+
}));
|
|
229
|
+
return {
|
|
230
|
+
schema_version: AUDIT_BY_TOOL_SCHEMA_VERSION,
|
|
231
|
+
window: {
|
|
232
|
+
seconds: windowSeconds,
|
|
233
|
+
start: windowStart !== null ? windowStart.toISOString() : null,
|
|
234
|
+
end: windowEnd !== null ? windowEnd.toISOString() : null,
|
|
235
|
+
},
|
|
236
|
+
total_events: totalEvents,
|
|
237
|
+
unique_tools: byToolName.size,
|
|
238
|
+
top,
|
|
239
|
+
tools: sorted,
|
|
240
|
+
files_scanned: filesScanned,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Compact human duration label for the header. Mirrors
|
|
245
|
+
* `audit-summary.ts`'s `formatDurationShort` — kept local so the two
|
|
246
|
+
* modules don't share rendering internals. `86400` → `last 24h`,
|
|
247
|
+
* `604800` → `last 7d`, etc.
|
|
248
|
+
*/
|
|
249
|
+
function formatDurationShort(seconds) {
|
|
250
|
+
const units = [
|
|
251
|
+
['w', 60 * 60 * 24 * 7],
|
|
252
|
+
['d', 60 * 60 * 24],
|
|
253
|
+
['h', 60 * 60],
|
|
254
|
+
['m', 60],
|
|
255
|
+
['s', 1],
|
|
256
|
+
];
|
|
257
|
+
for (const [unit, factor] of units) {
|
|
258
|
+
if (seconds % factor === 0) {
|
|
259
|
+
return `last ${String(seconds / factor)}${unit}`;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return `last ${String(seconds)}s`;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Render the result as a human-readable terminal block. JSON callers
|
|
266
|
+
* bypass this; the rendering is intentionally simple (no Unicode bars
|
|
267
|
+
* — that's the `timeline` command's job).
|
|
268
|
+
*/
|
|
269
|
+
export function renderAuditByTool(result) {
|
|
270
|
+
const lines = [];
|
|
271
|
+
const windowLabel = result.window.seconds !== null ? formatDurationShort(result.window.seconds) : 'all time';
|
|
272
|
+
lines.push(`rea audit by-tool (${windowLabel}, top ${String(result.top)})`);
|
|
273
|
+
lines.push('─'.repeat(40));
|
|
274
|
+
if (result.total_events === 0) {
|
|
275
|
+
lines.push(result.window.seconds !== null
|
|
276
|
+
? 'No events in the requested window.'
|
|
277
|
+
: 'No events in the audit log.');
|
|
278
|
+
if (result.files_scanned.length === 0) {
|
|
279
|
+
lines.push('(no audit files found — has `rea serve` ever run?)');
|
|
280
|
+
}
|
|
281
|
+
lines.push('');
|
|
282
|
+
return lines.join('\n');
|
|
283
|
+
}
|
|
284
|
+
const maxNameLen = result.tools.reduce((m, t) => Math.max(m, t.name.length), 0);
|
|
285
|
+
for (const t of result.tools) {
|
|
286
|
+
const pad = ' '.repeat(maxNameLen - t.name.length + 2);
|
|
287
|
+
// Pad pct to a stable 5-char field (e.g. " 4.3" / "49.1") so the
|
|
288
|
+
// columns line up regardless of magnitude.
|
|
289
|
+
const pctStr = t.pct.toFixed(1).padStart(5);
|
|
290
|
+
lines.push(` ${t.name}${pad}${String(t.count).padStart(6)} (${pctStr}%)`);
|
|
291
|
+
}
|
|
292
|
+
// Long-tail summary — present when more tools exist than the top-N
|
|
293
|
+
// shows, so the operator sees what was elided.
|
|
294
|
+
if (result.unique_tools > result.tools.length) {
|
|
295
|
+
const shownEvents = result.tools.reduce((s, t) => s + t.count, 0);
|
|
296
|
+
const otherTools = result.unique_tools - result.tools.length;
|
|
297
|
+
const otherEvents = result.total_events - shownEvents;
|
|
298
|
+
const otherPct = result.total_events > 0 ? ((otherEvents * 100) / result.total_events).toFixed(1) : '0.0';
|
|
299
|
+
lines.push(` (other: ${String(otherTools)} tool${otherTools === 1 ? '' : 's'}, ` +
|
|
300
|
+
`${String(otherEvents)} event${otherEvents === 1 ? '' : 's'}, ${otherPct}%)`);
|
|
301
|
+
}
|
|
302
|
+
lines.push('');
|
|
303
|
+
lines.push(`total: ${String(result.total_events)} events across ${String(result.unique_tools)} distinct tool${result.unique_tools === 1 ? '' : 's'}`);
|
|
304
|
+
lines.push(`files scanned: ${String(result.files_scanned.length)}`);
|
|
305
|
+
lines.push('');
|
|
306
|
+
return lines.join('\n');
|
|
307
|
+
}
|
|
308
|
+
/** Commander entrypoint. */
|
|
309
|
+
export async function runAuditByTool(options) {
|
|
310
|
+
let result;
|
|
311
|
+
try {
|
|
312
|
+
result = await computeAuditByTool({
|
|
313
|
+
...(options.since !== undefined ? { since: options.since } : {}),
|
|
314
|
+
...(options.top !== undefined ? { top: options.top } : {}),
|
|
315
|
+
...(options.now !== undefined ? { now: options.now } : {}),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
catch (e) {
|
|
319
|
+
if (e instanceof AuditByToolOptionError) {
|
|
320
|
+
err(`rea audit by-tool: ${e.message}`);
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
throw e;
|
|
324
|
+
}
|
|
325
|
+
if (options.json === true) {
|
|
326
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
process.stdout.write(renderAuditByTool(result));
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Strict integer parser for the commander `--top <n>` option.
|
|
333
|
+
*
|
|
334
|
+
* Codex round-1 P3 (0.46.0): reject any input that isn't a bare
|
|
335
|
+
* integer. `Number.parseInt` silently truncates `1.5` to `1` and
|
|
336
|
+
* accepts `10abc` as `10`, which would change the requested top-N
|
|
337
|
+
* under the operator's nose without an error signal. We require the
|
|
338
|
+
* raw string to match `^-?\d+$` so the numeric parse can't drop
|
|
339
|
+
* characters. The downstream range validation in `computeAuditByTool`
|
|
340
|
+
* still enforces [1, MAX_TOP].
|
|
341
|
+
*
|
|
342
|
+
* Exported for direct test coverage — commander's option-parser
|
|
343
|
+
* callback shape doesn't compose well with the in-process testing we
|
|
344
|
+
* want here, so we pin the parser as a unit instead.
|
|
345
|
+
*/
|
|
346
|
+
export function parseTopOption(raw) {
|
|
347
|
+
if (!/^-?\d+$/.test(raw.trim())) {
|
|
348
|
+
throw new AuditByToolOptionError(`--top: expected integer; got ${JSON.stringify(raw)}.`);
|
|
349
|
+
}
|
|
350
|
+
const n = Number.parseInt(raw.trim(), 10);
|
|
351
|
+
if (!Number.isFinite(n)) {
|
|
352
|
+
throw new AuditByToolOptionError(`--top: expected integer; got ${JSON.stringify(raw)}.`);
|
|
353
|
+
}
|
|
354
|
+
return n;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Register `rea audit by-tool` under the `audit` command group.
|
|
358
|
+
*/
|
|
359
|
+
export function registerAuditByToolCommand(auditCommand) {
|
|
360
|
+
auditCommand
|
|
361
|
+
.command('by-tool')
|
|
362
|
+
.description('Tool-name distribution at higher fidelity than `audit summary` — `--top=N` (default 20, max 1000), `--since=DUR` window filter, `--json` for dashboards. Read-only.')
|
|
363
|
+
.option('--top <n>', `cap the rendered / serialized list to the top N tools by count (default ${String(DEFAULT_TOP)}, max ${String(MAX_TOP)})`, parseTopOption)
|
|
364
|
+
.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).')
|
|
365
|
+
.option('--json', 'emit a JSON document instead of the human-readable table')
|
|
366
|
+
.action(async (opts) => {
|
|
367
|
+
await runAuditByTool({
|
|
368
|
+
...(opts.top !== undefined ? { top: opts.top } : {}),
|
|
369
|
+
...(opts.since !== undefined ? { since: opts.since } : {}),
|
|
370
|
+
...(opts.json === true ? { json: true } : {}),
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
}
|