@bookedsolid/rea 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,479 @@
1
+ /**
2
+ * `rea audit summary` — high-level audit-log overview (0.41.0).
3
+ *
4
+ * The audit log is rich and `rea audit specialists` already exists for
5
+ * one narrow event-class. `rea audit summary` complements it with a
6
+ * broad rollup: total events, counts by `tool_name`, by tier, by
7
+ * session, by status, the time window covered, and a sample-verified
8
+ * chain-integrity check.
9
+ *
10
+ * # Filtering
11
+ *
12
+ * `--since <duration>` accepts a compact duration string
13
+ * (`s`/`m`/`h`/`d`/`w`) and filters records by `timestamp >= now -
14
+ * duration`. Examples: `24h`, `7d`, `90m`, `2w`. This is DIFFERENT
15
+ * from `rea audit verify --since <file>` / `rea audit specialists
16
+ * --since <file>` which take a rotated-file ANCHOR, not a duration —
17
+ * the two `--since` semantics serve different needs (anchor for chain
18
+ * walks, duration for summarization) and we accept the surface area
19
+ * cost. The duration form is what consumers reach for when asking
20
+ * "what happened in the last day?".
21
+ *
22
+ * # Chain integrity
23
+ *
24
+ * `rea audit verify` does the rigorous per-record re-hash. `summary`
25
+ * samples up to `CHAIN_SAMPLE_SIZE` records, evenly spaced through
26
+ * the filtered window, and reports `ok` / `tampered` / `unsampled`
27
+ * (window empty). Operators who suspect tampering should still run
28
+ * `rea audit verify` for an authoritative answer.
29
+ *
30
+ * # JSON output
31
+ *
32
+ * {
33
+ * "schema_version": 1,
34
+ * "window_seconds": 86400,
35
+ * "window_start": "2026-05-15T13:42:00Z",
36
+ * "window_end": "2026-05-16T13:42:00Z",
37
+ * "files_scanned": ["/abs/path/.rea/audit.jsonl"],
38
+ * "total_events": 1247,
39
+ * "by_tool_name": { "Bash": 612, "Edit": 289, … },
40
+ * "by_tier": { "read": 683, "write": 416, "destructive": 148 },
41
+ * "by_status": { "allowed": 1242, "denied": 5, "error": 0 },
42
+ * "by_session": { "session-abc…": 312, "session-def…": 935 },
43
+ * "session_count": 8,
44
+ * "earliest_timestamp": "2026-05-15T13:43:01.103Z",
45
+ * "latest_timestamp": "2026-05-16T13:41:57.842Z",
46
+ * "chain_integrity": "ok",
47
+ * "chain_samples_verified": 12
48
+ * }
49
+ *
50
+ * # Walk scope
51
+ *
52
+ * v1 walks the current `.rea/audit.jsonl` plus EVERY rotated file
53
+ * whose latest record falls within the window. Older rotated files
54
+ * are skipped — they cannot contain in-window records. When `--since`
55
+ * is omitted, no time filter is applied and the walk covers the
56
+ * current `audit.jsonl` only (operators wanting historical depth
57
+ * should pass `--since <DUR>`).
58
+ */
59
+ import fs from 'node:fs/promises';
60
+ import path from 'node:path';
61
+ import { computeHash, GENESIS_HASH } from '../audit/fs.js';
62
+ import { listRotatedAuditFiles } from './audit-specialists.js';
63
+ import { AUDIT_FILE, REA_DIR, err, log } from './utils.js';
64
+ export const AUDIT_SUMMARY_SCHEMA_VERSION = 1;
65
+ /** Hard cap on chain-integrity samples. Keeps `rea audit summary`
66
+ * fast even on large logs while still surfacing obvious tampering. */
67
+ export const CHAIN_SAMPLE_SIZE = 12;
68
+ /**
69
+ * Thrown by `computeAuditSummary` when `--since` cannot be parsed.
70
+ * The commander wrapper exits 1.
71
+ */
72
+ export class AuditSummarySinceError extends Error {
73
+ constructor(message) {
74
+ super(message);
75
+ this.name = 'AuditSummarySinceError';
76
+ }
77
+ }
78
+ /**
79
+ * Parse a compact duration string into seconds. Accepts:
80
+ *
81
+ * - `<N>s` — seconds
82
+ * - `<N>m` — minutes
83
+ * - `<N>h` — hours
84
+ * - `<N>d` — days
85
+ * - `<N>w` — weeks (7 days)
86
+ *
87
+ * `N` must be a positive integer with no whitespace. Returns the
88
+ * number of seconds; throws `AuditSummarySinceError` on parse failure.
89
+ *
90
+ * We deliberately do not accept bare numbers (would be ambiguous) or
91
+ * fractional units (no real use case; complicates rendering).
92
+ */
93
+ export function parseDurationSeconds(raw) {
94
+ const m = /^(\d+)(s|m|h|d|w)$/i.exec(raw.trim());
95
+ if (m === null) {
96
+ throw new AuditSummarySinceError(`--since: cannot parse ${JSON.stringify(raw)}. ` +
97
+ `Expected <N><unit> where unit is s|m|h|d|w (e.g. 24h, 7d, 90m).`);
98
+ }
99
+ const n = Number.parseInt(m[1], 10);
100
+ if (!Number.isFinite(n) || n <= 0) {
101
+ throw new AuditSummarySinceError(`--since: duration must be a positive integer; got ${JSON.stringify(raw)}.`);
102
+ }
103
+ const unit = m[2].toLowerCase();
104
+ const factor = {
105
+ s: 1,
106
+ m: 60,
107
+ h: 60 * 60,
108
+ d: 60 * 60 * 24,
109
+ w: 60 * 60 * 24 * 7,
110
+ };
111
+ return n * factor[unit];
112
+ }
113
+ /**
114
+ * Resolve the audit files to walk. Always includes the current
115
+ * `audit.jsonl` (when it exists).
116
+ *
117
+ * - `windowStart === null` (no `--since`): walk EVERY rotated file
118
+ * PLUS the current `audit.jsonl`. Round-1 P2: the prior shape
119
+ * dropped rotated history silently while the header still
120
+ * advertised "all time", undercounting long-lived repos.
121
+ * - `windowStart` set: walk every rotated file whose basename
122
+ * timestamp >= the cutoff, PLUS one rotated file immediately
123
+ * before the cutoff (the in-flight file at cutoff time may
124
+ * contain in-window records).
125
+ *
126
+ * Sort order is timestamp-ascending; the current `audit.jsonl` is
127
+ * always appended last (it is the newest segment of the chain).
128
+ */
129
+ async function resolveSummaryFileWalk(baseDir, windowStart) {
130
+ const reaDir = path.join(baseDir, REA_DIR);
131
+ const currentAudit = path.join(reaDir, AUDIT_FILE);
132
+ const files = [];
133
+ const rotated = await listRotatedAuditFiles(reaDir);
134
+ if (windowStart === null) {
135
+ // Walk every rotated segment. The "all time" header would be a
136
+ // lie otherwise.
137
+ for (const name of rotated)
138
+ files.push(path.join(reaDir, name));
139
+ }
140
+ else {
141
+ // Rotated filenames are `audit-YYYYMMDD-HHMMSS(-N).jsonl` in UTC.
142
+ // We treat each filename as "rotated at this instant" and include
143
+ // every file rotated >= windowStart, plus one file immediately
144
+ // before windowStart (the in-flight file at cutoff time may
145
+ // contain in-window records).
146
+ const stampToDate = (name) => {
147
+ const m = /^audit-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})/.exec(name);
148
+ if (m === null)
149
+ return null;
150
+ const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
151
+ const d = new Date(iso);
152
+ return Number.isNaN(d.getTime()) ? null : d;
153
+ };
154
+ const cutoffIdx = rotated.findIndex((n) => {
155
+ const d = stampToDate(n);
156
+ return d !== null && d >= windowStart;
157
+ });
158
+ const startIdx = cutoffIdx === -1 ? Math.max(0, rotated.length - 1) : Math.max(0, cutoffIdx - 1);
159
+ for (const name of rotated.slice(startIdx)) {
160
+ files.push(path.join(reaDir, name));
161
+ }
162
+ }
163
+ try {
164
+ const stat = await fs.stat(currentAudit);
165
+ if (stat.isFile())
166
+ files.push(currentAudit);
167
+ }
168
+ catch (e) {
169
+ if (e.code !== 'ENOENT')
170
+ throw e;
171
+ }
172
+ return files;
173
+ }
174
+ /** Map a rea Tier value (or unknown) to a stable bucket key for the
175
+ * by_tier table. Unknown values bucket to `'unknown'` so the rollup
176
+ * surfaces them rather than silently dropping.
177
+ */
178
+ function tierBucket(value) {
179
+ if (typeof value !== 'string' || value.length === 0)
180
+ return 'unknown';
181
+ return value;
182
+ }
183
+ function statusBucket(value) {
184
+ if (typeof value !== 'string' || value.length === 0)
185
+ return 'unknown';
186
+ return value;
187
+ }
188
+ /**
189
+ * Parse an ISO-8601 timestamp into a Date. Returns `null` on failure.
190
+ */
191
+ function parseTimestamp(raw) {
192
+ if (typeof raw !== 'string')
193
+ return null;
194
+ const d = new Date(raw);
195
+ return Number.isNaN(d.getTime()) ? null : d;
196
+ }
197
+ /**
198
+ * Sample-verify per-record hash integrity across the IN-WINDOW
199
+ * records only. Returns `ok`, `tampered`, or `unsampled` (no
200
+ * records in window).
201
+ *
202
+ * Sampling: take indices at offsets 0, n/k, 2n/k, …, (k-1)n/k where
203
+ * n is the in-window count and k is `CHAIN_SAMPLE_SIZE`. Always
204
+ * sample the first and last record.
205
+ *
206
+ * Codex round-2 P1: we do NOT verify `prev_hash` linkage between
207
+ * adjacent records in the filtered list. `appendAuditRecord` accepts
208
+ * caller-supplied timestamps, so a valid chain can look like
209
+ * `in-window → out-of-window → in-window`; in that case the second
210
+ * in-window record's `prev_hash` points to the filtered-out entry,
211
+ * not the previous survivor in our filtered view, and a linkage
212
+ * check would false-positive `tampered` on a healthy log. The
213
+ * authoritative chain walk lives in `rea audit verify`; summary
214
+ * stays advisory.
215
+ */
216
+ function sampleChainIntegrity(records) {
217
+ if (records.length === 0)
218
+ return { result: 'unsampled', samplesVerified: 0 };
219
+ const sampleIndices = new Set();
220
+ const k = Math.min(CHAIN_SAMPLE_SIZE, records.length);
221
+ for (let i = 0; i < k; i += 1) {
222
+ const idx = Math.floor((i * records.length) / k);
223
+ sampleIndices.add(idx);
224
+ }
225
+ sampleIndices.add(records.length - 1);
226
+ let samplesVerified = 0;
227
+ for (const i of sampleIndices) {
228
+ const r = records[i];
229
+ const { hash, ...rest } = r;
230
+ const recomputed = computeHash(rest);
231
+ if (recomputed !== hash) {
232
+ return { result: 'tampered', samplesVerified };
233
+ }
234
+ samplesVerified += 1;
235
+ }
236
+ return { result: 'ok', samplesVerified };
237
+ }
238
+ /**
239
+ * Compute the summary. Pure (read-only). Throws
240
+ * `AuditSummarySinceError` on bad `--since`; everything else is
241
+ * surfaced via the result.
242
+ */
243
+ export async function computeAuditSummary(options = {}) {
244
+ const baseDir = options.baseDir ?? process.cwd();
245
+ const now = options.now ?? new Date();
246
+ let windowSeconds = null;
247
+ let windowStart = null;
248
+ let windowEnd = null;
249
+ if (options.since !== undefined && options.since.length > 0) {
250
+ windowSeconds = parseDurationSeconds(options.since);
251
+ windowEnd = now;
252
+ windowStart = new Date(now.getTime() - windowSeconds * 1000);
253
+ }
254
+ const files = await resolveSummaryFileWalk(baseDir, windowStart);
255
+ const byToolName = {};
256
+ const byTier = {};
257
+ const byStatus = {};
258
+ const bySession = {};
259
+ let totalEvents = 0;
260
+ let earliest = null;
261
+ let latest = null;
262
+ // We only feed in-window records to the chain-sample check.
263
+ const inWindowRecords = [];
264
+ for (const filePath of files) {
265
+ let raw;
266
+ try {
267
+ raw = await fs.readFile(filePath, 'utf8');
268
+ }
269
+ catch (e) {
270
+ if (e.code === 'ENOENT')
271
+ continue;
272
+ throw e;
273
+ }
274
+ for (const line of raw.split('\n')) {
275
+ if (line.length === 0)
276
+ continue;
277
+ let parsed;
278
+ try {
279
+ parsed = JSON.parse(line);
280
+ }
281
+ catch {
282
+ // Malformed line — `rea audit verify` is the tool for that.
283
+ // Summary just skips and moves on.
284
+ continue;
285
+ }
286
+ const ts = parseTimestamp(parsed.timestamp);
287
+ if (windowStart !== null && (ts === null || ts < windowStart))
288
+ continue;
289
+ // upper bound is `now`; future-dated records (skew, replay) are
290
+ // still counted — they're real records that landed in the file.
291
+ totalEvents += 1;
292
+ const toolName = typeof parsed.tool_name === 'string' && parsed.tool_name.length > 0
293
+ ? parsed.tool_name
294
+ : '(unknown)';
295
+ byToolName[toolName] = (byToolName[toolName] ?? 0) + 1;
296
+ const tier = tierBucket(parsed.tier);
297
+ byTier[tier] = (byTier[tier] ?? 0) + 1;
298
+ const status = statusBucket(parsed.status);
299
+ byStatus[status] = (byStatus[status] ?? 0) + 1;
300
+ const session = typeof parsed.session_id === 'string' && parsed.session_id.length > 0
301
+ ? parsed.session_id
302
+ : '(unknown)';
303
+ bySession[session] = (bySession[session] ?? 0) + 1;
304
+ const tsRaw = typeof parsed.timestamp === 'string' ? parsed.timestamp : null;
305
+ if (tsRaw !== null) {
306
+ if (earliest === null || tsRaw < earliest)
307
+ earliest = tsRaw;
308
+ if (latest === null || tsRaw > latest)
309
+ latest = tsRaw;
310
+ }
311
+ inWindowRecords.push(parsed);
312
+ }
313
+ }
314
+ const { result: chainIntegrity, samplesVerified } = sampleChainIntegrity(inWindowRecords);
315
+ // Suppress unused-variable warning for genesis hash by referencing
316
+ // it: the chain-sample check uses it implicitly via the relaxed
317
+ // first-record rule. Kept as an import-time anchor for the linkage
318
+ // contract documented in `sampleChainIntegrity`.
319
+ void GENESIS_HASH;
320
+ return {
321
+ schema_version: AUDIT_SUMMARY_SCHEMA_VERSION,
322
+ window_seconds: windowSeconds,
323
+ window_start: windowStart !== null ? windowStart.toISOString() : null,
324
+ window_end: windowEnd !== null ? windowEnd.toISOString() : null,
325
+ files_scanned: files,
326
+ total_events: totalEvents,
327
+ by_tool_name: byToolName,
328
+ by_tier: byTier,
329
+ by_status: byStatus,
330
+ by_session: bySession,
331
+ session_count: Object.keys(bySession).length,
332
+ earliest_timestamp: earliest,
333
+ latest_timestamp: latest,
334
+ chain_integrity: chainIntegrity,
335
+ chain_samples_verified: samplesVerified,
336
+ };
337
+ }
338
+ function sortBucket(bucket) {
339
+ return Object.entries(bucket).sort((a, b) => {
340
+ if (b[1] !== a[1])
341
+ return b[1] - a[1];
342
+ return a[0].localeCompare(b[0]);
343
+ });
344
+ }
345
+ function renderBucket(d, total) {
346
+ const lines = [];
347
+ lines.push(`${d.title}:`);
348
+ const visible = d.limit !== undefined ? d.entries.slice(0, d.limit) : d.entries;
349
+ const overflow = d.limit !== undefined ? d.entries.slice(d.limit) : [];
350
+ const maxNameLen = visible.reduce((m, [n]) => Math.max(m, n.length), 0);
351
+ for (const [name, count] of visible) {
352
+ const pad = ' '.repeat(maxNameLen - name.length + 2);
353
+ const pct = total > 0 ? ` (${((count * 100) / total).toFixed(1)}%)` : '';
354
+ lines.push(` ${name}${pad}${String(count).padStart(6)}${pct}`);
355
+ }
356
+ if (overflow.length > 0) {
357
+ const overflowSum = overflow.reduce((s, [, n]) => s + n, 0);
358
+ const pad = ' '.repeat(Math.max(0, maxNameLen - 7) + 2);
359
+ const pct = total > 0 ? ` (${((overflowSum * 100) / total).toFixed(1)}%)` : '';
360
+ lines.push(` (other)${pad}${String(overflowSum).padStart(6)}${pct}`);
361
+ }
362
+ return lines.join('\n');
363
+ }
364
+ /**
365
+ * Render the result as a human-readable terminal block. Designed for
366
+ * the default `rea audit summary` invocation; `--json` callers bypass
367
+ * this entirely.
368
+ */
369
+ export function renderAuditSummary(result) {
370
+ const lines = [];
371
+ const windowLabel = result.window_seconds !== null ? formatDurationShort(result.window_seconds) : 'all time';
372
+ lines.push(`rea audit summary (${windowLabel})`);
373
+ lines.push('─'.repeat(40));
374
+ lines.push(`total events: ${String(result.total_events).padStart(6)}`);
375
+ lines.push(`sessions: ${String(result.session_count).padStart(6)}`);
376
+ if (result.session_count > 0) {
377
+ const avg = result.total_events / result.session_count;
378
+ lines.push(`events/session avg: ${avg.toFixed(1).padStart(6)}`);
379
+ }
380
+ lines.push('');
381
+ if (result.total_events === 0) {
382
+ lines.push(result.window_seconds !== null
383
+ ? 'No events in the requested window.'
384
+ : 'No events in the audit log.');
385
+ lines.push('');
386
+ if (result.files_scanned.length === 0) {
387
+ lines.push('(no audit files found — has `rea serve` ever run?)');
388
+ lines.push('');
389
+ }
390
+ return lines.join('\n');
391
+ }
392
+ const total = result.total_events;
393
+ lines.push(renderBucket({ title: 'by tool_name', entries: sortBucket(result.by_tool_name), limit: 12 }, total));
394
+ lines.push('');
395
+ lines.push(renderBucket({ title: 'by tier', entries: sortBucket(result.by_tier) }, total));
396
+ lines.push('');
397
+ lines.push(renderBucket({ title: 'by status', entries: sortBucket(result.by_status) }, total));
398
+ lines.push('');
399
+ // Sessions can balloon — limit to 5 by default.
400
+ lines.push(renderBucket({ title: 'top sessions', entries: sortBucket(result.by_session), limit: 5 }, total));
401
+ lines.push('');
402
+ if (result.earliest_timestamp !== null && result.latest_timestamp !== null) {
403
+ lines.push(`window: ${result.earliest_timestamp} → ${result.latest_timestamp}`);
404
+ }
405
+ const chainLabel = result.chain_integrity === 'ok'
406
+ ? `ok (${String(result.chain_samples_verified)} sample${result.chain_samples_verified === 1 ? '' : 's'} verified)`
407
+ : result.chain_integrity === 'tampered'
408
+ ? 'TAMPERED — run `rea audit verify` for the exact break'
409
+ : 'unsampled (no records in window)';
410
+ lines.push(`chain integrity: ${chainLabel}`);
411
+ lines.push(`files scanned: ${String(result.files_scanned.length)}`);
412
+ lines.push('');
413
+ return lines.join('\n');
414
+ }
415
+ /**
416
+ * Compact human duration label for the header. `86400` → `last 24h`,
417
+ * `604800` → `last 7d`, etc. We pick the coarsest single unit that
418
+ * yields an integer; otherwise fall back to seconds.
419
+ */
420
+ function formatDurationShort(seconds) {
421
+ const units = [
422
+ ['w', 60 * 60 * 24 * 7],
423
+ ['d', 60 * 60 * 24],
424
+ ['h', 60 * 60],
425
+ ['m', 60],
426
+ ['s', 1],
427
+ ];
428
+ for (const [unit, factor] of units) {
429
+ if (seconds % factor === 0) {
430
+ return `last ${String(seconds / factor)}${unit}`;
431
+ }
432
+ }
433
+ return `last ${String(seconds)}s`;
434
+ }
435
+ /** Commander entrypoint. */
436
+ export async function runAuditSummary(options) {
437
+ let result;
438
+ try {
439
+ result = await computeAuditSummary({
440
+ ...(options.since !== undefined ? { since: options.since } : {}),
441
+ ...(options.now !== undefined ? { now: options.now } : {}),
442
+ });
443
+ }
444
+ catch (e) {
445
+ if (e instanceof AuditSummarySinceError) {
446
+ err(`rea audit summary: ${e.message}`);
447
+ process.exit(1);
448
+ }
449
+ throw e;
450
+ }
451
+ if (options.json === true) {
452
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
453
+ return;
454
+ }
455
+ process.stdout.write(renderAuditSummary(result));
456
+ if (result.chain_integrity === 'tampered') {
457
+ // Non-zero exit gives CI users a single signal for "something is
458
+ // off"; the JSON path stays exit 0 so machine consumers can react
459
+ // to the integrity field directly.
460
+ log('summary complete with tampered chain — see message above.');
461
+ process.exit(1);
462
+ }
463
+ }
464
+ /**
465
+ * Register `rea audit summary` under the `audit` command group.
466
+ */
467
+ export function registerAuditSummaryCommand(auditCommand) {
468
+ auditCommand
469
+ .command('summary')
470
+ .description('High-level audit-log summary — counts by tool_name, tier, session, status; window timestamps; sample-verified chain integrity. Read-only.')
471
+ .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). Distinct from `rea audit verify --since <file>` which anchors on a rotated file.')
472
+ .option('--json', 'emit a JSON document instead of the human-readable table')
473
+ .action(async (opts) => {
474
+ await runAuditSummary({
475
+ ...(opts.since !== undefined ? { since: opts.since } : {}),
476
+ ...(opts.json === true ? { json: true } : {}),
477
+ });
478
+ });
479
+ }
package/dist/cli/index.js CHANGED
@@ -13,6 +13,8 @@ import { runServe } from './serve.js';
13
13
  import { runStatus } from './status.js';
14
14
  import { runTofuAccept, runTofuList } from './tofu.js';
15
15
  import { runUpgrade } from './upgrade.js';
16
+ import { runUpgradeCheck } from './upgrade-check.js';
17
+ import { registerAuditSummaryCommand } from './audit-summary.js';
16
18
  import { registerVerifyClaimCommand } from './verify-claim.js';
17
19
  import { err, getPkgVersion } from './utils.js';
18
20
  async function main() {
@@ -51,7 +53,34 @@ async function main() {
51
53
  .option('--dry-run', 'show what would change; write nothing')
52
54
  .option('-y, --yes', 'non-interactive — keep drifted files, skip removed-upstream')
53
55
  .option('--force', 'non-interactive — overwrite drift, delete removed-upstream')
56
+ // 0.41.0 — `--check` is a non-interactive, structured preview that
57
+ // emits unified diffs per modified file and exits 0 regardless of
58
+ // what would change. Distinct from `--dry-run`, which rehearses the
59
+ // FULL interactive flow with writes suppressed. Use `--check` in CI
60
+ // to surface the changes a `rea upgrade` PR would produce; use
61
+ // `--dry-run` locally to walk through the same prompts you'd see
62
+ // during a real upgrade.
63
+ .option('--check', '0.41.0 — preview-only mode: classify files, emit unified diffs, exit 0')
64
+ .option('--json', '(with --check) emit a single JSON document instead of the text summary')
65
+ .option('--no-diff', '(with --check) omit unified-diff bodies (counts + paths only)')
54
66
  .action(async (opts) => {
67
+ if (opts.check === true) {
68
+ await runUpgradeCheck({
69
+ json: opts.json === true,
70
+ noDiff: opts.diff === false,
71
+ });
72
+ return;
73
+ }
74
+ // Codex round-2 P1: `--json` / `--no-diff` are preview-only.
75
+ // Before this PR they were unknown flags and commander rejected
76
+ // them; now they exist on the command. Refuse them without
77
+ // `--check` rather than silently performing a real upgrade —
78
+ // a CI typo (`rea upgrade --json` without `--check`) must not
79
+ // rewrite `.claude/` / `.husky/` / managed fragments.
80
+ if (opts.json === true || opts.diff === false) {
81
+ err('`--json` / `--no-diff` are preview-only flags; pass `--check` to use them.');
82
+ process.exit(2);
83
+ }
55
84
  await runUpgrade({
56
85
  dryRun: opts.dryRun,
57
86
  yes: opts.yes,
@@ -111,6 +140,10 @@ async function main() {
111
140
  // records. Read-only; honors $CLAUDE_SESSION_ID for current-session
112
141
  // filtering. v1 omits --since / --session (deferred to 0.29.1).
113
142
  registerAuditSpecialistsSubcommand(audit);
143
+ // 0.41.0 — `rea audit summary [--since=DUR] [--json]` high-level
144
+ // overview reader. Counts events by tool_name, tier, session,
145
+ // status; samples chain integrity. Tier-Read; never mutates.
146
+ registerAuditSummaryCommand(audit);
114
147
  // Register `rea hook push-gate` — the stateless pre-push Codex gate
115
148
  // called by `.husky/pre-push` and `.git/hooks/pre-push`.
116
149
  registerHookCommand(program);
@@ -102,6 +102,28 @@ export interface EnsureGitignoreResult {
102
102
  addedEntries: string[];
103
103
  /** Non-fatal operator-facing messages (e.g. symlink refused, duplicate blocks). */
104
104
  warnings: string[];
105
+ /**
106
+ * 0.41.0 — when computed under `dryRun: true`, the full would-be
107
+ * on-disk content. Omitted in real-write mode (the file IS the
108
+ * authoritative copy). Callers like `rea upgrade --check` use this
109
+ * to render a unified diff against the current on-disk content.
110
+ */
111
+ previewContent?: string;
112
+ /**
113
+ * 0.41.0 — current on-disk content at planning time. `null` when
114
+ * the file does not exist. Omitted in real-write mode.
115
+ */
116
+ previousContent?: string | null;
117
+ }
118
+ export interface EnsureGitignoreOptions {
119
+ /**
120
+ * When `true`, compute the action + would-be content without
121
+ * writing. Returns `previewContent` and `previousContent`. Default
122
+ * `false` (write-on).
123
+ */
124
+ dryRun?: boolean;
125
+ /** Override the canonical entry list. Tests use this. */
126
+ entries?: readonly string[];
105
127
  }
106
128
  /**
107
129
  * Main entry point. Idempotent: calling twice in a row produces `unchanged`
@@ -111,4 +133,4 @@ export interface EnsureGitignoreResult {
111
133
  * init` and `rea upgrade` pass the default. Tests override to verify
112
134
  * reconciliation.
113
135
  */
114
- export declare function ensureReaGitignore(targetDir: string, entries?: readonly string[]): Promise<EnsureGitignoreResult>;
136
+ export declare function ensureReaGitignore(targetDir: string, optionsOrEntries?: EnsureGitignoreOptions | readonly string[]): Promise<EnsureGitignoreResult>;
@@ -271,7 +271,15 @@ async function writeAtomic(absPath, content) {
271
271
  * init` and `rea upgrade` pass the default. Tests override to verify
272
272
  * reconciliation.
273
273
  */
274
- export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTRIES) {
274
+ export async function ensureReaGitignore(targetDir, optionsOrEntries = {}) {
275
+ // Back-compat: pre-0.41.0 callers passed `entries` as the second
276
+ // positional argument. Detect the legacy shape and forward to the
277
+ // options form so we don't break existing call sites.
278
+ const options = Array.isArray(optionsOrEntries)
279
+ ? { entries: optionsOrEntries }
280
+ : optionsOrEntries;
281
+ const entries = options.entries ?? REA_GITIGNORE_ENTRIES;
282
+ const dryRun = options.dryRun === true;
275
283
  const absPath = path.resolve(targetDir, GITIGNORE);
276
284
  const warnings = [];
277
285
  let existing;
@@ -280,18 +288,26 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
280
288
  }
281
289
  catch (err) {
282
290
  warnings.push(err.message);
283
- return { path: absPath, action: 'unchanged', addedEntries: [], warnings };
291
+ return {
292
+ path: absPath,
293
+ action: 'unchanged',
294
+ addedEntries: [],
295
+ warnings,
296
+ ...(dryRun ? { previousContent: null, previewContent: '' } : {}),
297
+ };
284
298
  }
285
299
  // Detect EOL so a CRLF repo stays CRLF and doesn't get torn. Codex F3.
286
300
  const eol = existing !== null && existing.includes('\r\n') ? '\r\n' : '\n';
287
301
  if (existing === null) {
288
302
  const content = buildManagedBlock(entries, '\n') + '\n';
289
- await writeAtomic(absPath, content);
303
+ if (!dryRun)
304
+ await writeAtomic(absPath, content);
290
305
  return {
291
306
  path: absPath,
292
307
  action: 'created',
293
308
  addedEntries: [...entries],
294
309
  warnings,
310
+ ...(dryRun ? { previousContent: null, previewContent: content } : {}),
295
311
  };
296
312
  }
297
313
  const lines = existing.split(/\r?\n/);
@@ -300,7 +316,13 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
300
316
  if (block === 'duplicate') {
301
317
  warnings.push(`${absPath} contains multiple '# === rea managed' blocks — refusing to modify. ` +
302
318
  `Consolidate the managed blocks manually and rerun.`);
303
- return { path: absPath, action: 'unchanged', addedEntries: [], warnings };
319
+ return {
320
+ path: absPath,
321
+ action: 'unchanged',
322
+ addedEntries: [],
323
+ warnings,
324
+ ...(dryRun ? { previousContent: existing, previewContent: existing } : {}),
325
+ };
304
326
  }
305
327
  if (block === null) {
306
328
  // No managed block. Append one after a blank-line separator (unless the
@@ -315,12 +337,14 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
315
337
  const separator = bodyLines.length === 0 ? [] : [''];
316
338
  const newLines = [...bodyLines, ...separator, buildManagedBlock(entries, eol)];
317
339
  const content = newLines.join(eol) + eol;
318
- await writeAtomic(absPath, content);
340
+ if (!dryRun)
341
+ await writeAtomic(absPath, content);
319
342
  return {
320
343
  path: absPath,
321
344
  action: 'updated',
322
345
  addedEntries: [...entries],
323
346
  warnings,
347
+ ...(dryRun ? { previousContent: existing, previewContent: content } : {}),
324
348
  };
325
349
  }
326
350
  // Managed block exists — reconcile body lines.
@@ -332,6 +356,7 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
332
356
  action: 'unchanged',
333
357
  addedEntries: [],
334
358
  warnings,
359
+ ...(dryRun ? { previousContent: existing, previewContent: existing } : {}),
335
360
  };
336
361
  }
337
362
  const newLines = [
@@ -342,11 +367,13 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
342
367
  let content = newLines.join(eol);
343
368
  if (hadTrailingNewline && !content.endsWith(eol))
344
369
  content += eol;
345
- await writeAtomic(absPath, content);
370
+ if (!dryRun)
371
+ await writeAtomic(absPath, content);
346
372
  return {
347
373
  path: absPath,
348
374
  action: 'updated',
349
375
  addedEntries: added,
350
376
  warnings,
377
+ ...(dryRun ? { previousContent: existing, previewContent: content } : {}),
351
378
  };
352
379
  }