@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.
- 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/dist/cli/init.d.ts +109 -27
- package/dist/cli/init.js +191 -34
- package/package.json +3 -1
- package/scripts/profile-hooks.mjs +767 -0
|
@@ -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
|
+
}
|