@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 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
+ }