@bookedsolid/rea 0.41.0 → 0.43.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
@@ -389,6 +389,145 @@ per finding and produces more consistent verdicts — fewer
389
389
  same-code-different-verdict round-trips. Trade-off is push-gate
390
390
  latency.
391
391
 
392
+ ## Node-binary hook scanner (added in 0.32.0)
393
+
394
+ Pre-0.32.0 every `.claude/hooks/*.sh` carried the full gate body in
395
+ bash. Adversarial review consistently caught bash-only edge cases that
396
+ were structurally unfixable in shell — multi-line awk encodings,
397
+ ANSI-C escapes, deep nested-shell decoding. 0.32.0 pivoted the entire
398
+ hook surface to a Node-binary scanner: hooks became thin shims (~20-80
399
+ LOC each) that delegate the actual gate work to `rea hook <name>` —
400
+ which runs the canonical scanner inside `dist/cli/index.js`.
401
+
402
+ **Consumer impact:**
403
+
404
+ - Run `pnpm install` (or `npm install`) after upgrading to 0.32.0+ so
405
+ `dist/cli/index.js` is built and the shims have something to call.
406
+ - `.claude/hooks/*.sh` files on disk are noticeably smaller after
407
+ `rea upgrade`; this is the canonical post-0.32.0 shape, not a
408
+ truncation. `rea doctor` will tell you if a shim is the wrong
409
+ vintage.
410
+ - The audit trail is unchanged: hooks still emit `rea.bash_scan`-class
411
+ records to `.rea/audit.jsonl` with the same field shape.
412
+ - Performance is materially better — single Node startup per scan
413
+ instead of an awk/sed pipeline per pattern.
414
+
415
+ If `rea doctor` reports `policy-reader Tier 1 (rea CLI)` as `warn:
416
+ dist not found`, you skipped the build step. Run `pnpm install`.
417
+
418
+ ## Graceful-degradation policy reader (added in 0.37.0)
419
+
420
+ The shimmed hooks need to read `.rea/policy.yaml` from a bash context
421
+ that may or may not have python3, jq, or rea's CLI on PATH. 0.37.0
422
+ formalized a 4-tier reader ladder:
423
+
424
+ 1. **Tier 1** — `rea hook policy-get` (requires `dist/cli/index.js`)
425
+ 2. **Tier 2** — `python3 + stdlib yaml` (PyYAML) — handles flow-form
426
+ 3. **Tier 3** — POSIX `awk` block-form parser (the always-available floor)
427
+ 4. **Fail-closed** — every tier unreachable: shim refuses the action
428
+
429
+ Tier 1 → 2 → 3 fallthrough is silent at hook-runtime; that's
430
+ intentional (graceful degradation), but means an unreachable Tier 1 +
431
+ unreachable Tier 2 can silently downgrade flow-form policy lookups to
432
+ block-form-only. `rea doctor` (0.39.0+) surfaces all three tier
433
+ reachabilities so you can spot the gap.
434
+
435
+ **Consumer impact:**
436
+
437
+ - If you use FLOW-form YAML for any policy block (e.g.
438
+ `blocked_paths: [.env, ".env.*"]`), make sure either the rea CLI
439
+ dist is present OR `python3 + PyYAML` is installed. With ONLY awk
440
+ reachable, flow-form lookups silently no-op on every shim
441
+ fallthrough path and your declared policy isn't enforced.
442
+ - Install PyYAML on CI runners: `pip3 install pyyaml`. On consumer
443
+ developer machines, it's almost always already present (macOS ships
444
+ it; major Linux distros bundle it with python3).
445
+ - For list-valued policy keys (`blocked_paths`, `protected_writes`),
446
+ the loader iterates the resulting JSON via jq OR python3. Have at
447
+ least one on PATH or `rea doctor` (0.42.0+) will report `fail` on
448
+ the `policy-reader Tier 3 (awk)` row with a list-walker-specific
449
+ remediation message.
450
+
451
+ ## Shim runtime extraction (added in 0.38.0)
452
+
453
+ Cosmetic-only refactor: every `.claude/hooks/*.sh` shim now sources
454
+ `hooks/_lib/shim-runtime.sh` for shared boilerplate (env loading,
455
+ tier classification, audit-event emission). **No consumer action
456
+ required** — the change is byte-equivalent at the gate surface. New
457
+ shims you author can adopt the same runtime by sourcing the shared
458
+ helper; documented in the shim authoring guide.
459
+
460
+ ## Doctor health surfaces for the policy reader (added in 0.39.0)
461
+
462
+ `rea doctor` gained explicit reachability checks for the 4-tier
463
+ ladder, the dist invokability probe, and a sandbox-containment check
464
+ on the resolved `dist/cli/index.js` path. Output lines you'll see:
465
+
466
+ - `policy-reader Tier 1 (rea CLI)` — pass/warn based on dist
467
+ presence + actual invocation
468
+ - `policy-reader Tier 2 (python3 + PyYAML)` — pass/warn based on
469
+ python3 + import yaml succeeding
470
+ - `policy-reader Tier 3 (awk)` — pass when awk present; warn or
471
+ fail conditional on whether other tiers cover the gap (0.40.0
472
+ refined the verdict logic; 0.42.0 hardened the list-walker
473
+ predicate)
474
+ - `policy-reader effective floor` — summary verdict across all three
475
+ - `policy-reader jq (JSON accelerator)` — info-level, calls out
476
+ Tier 1/2 perf when jq is absent
477
+
478
+ **Consumer action:** run `rea doctor` after each upgrade. The lines
479
+ above accurately reflect what your shims will do at runtime — a
480
+ `warn` is not a hard failure but signals a posture worth knowing
481
+ about (e.g. flow-form policy silently no-ops). A `fail` on any tier
482
+ row IS a hard failure that the doctor exits non-zero on.
483
+
484
+ ## Upgrade preview + audit summary (added in 0.41.0)
485
+
486
+ Two new consumer-facing commands rolled out:
487
+
488
+ ### `rea upgrade --check`
489
+
490
+ Dry-run preview of what `rea upgrade` would write, file-by-file, with
491
+ unified diffs. JSON output via `--json`. Always exits 0 — this is a
492
+ preview, not a gate. Use it before any non-trivial rea upgrade to
493
+ sanity-check the diff:
494
+
495
+ ```bash
496
+ rea upgrade --check # human-readable table + diffs
497
+ rea upgrade --check --json # machine-readable for CI
498
+ rea upgrade --check --no-diff # counts + paths only
499
+ ```
500
+
501
+ 0.42.0 added the same settings-schema validation that `rea upgrade`
502
+ itself runs — if the merged settings would fail schema parse (typo'd
503
+ hook event, malformed hook command, …), the preview surfaces the
504
+ `WOULD REFUSE` message rather than promising a write the real
505
+ upgrade would refuse. The `settings_validation` field in the JSON
506
+ output carries the structured outcome.
507
+
508
+ ### `rea audit summary`
509
+
510
+ High-level rollup of the audit log: counts by `tool_name`, `tier`,
511
+ `status`, `session`, the time window covered, and a sample-verified
512
+ chain-integrity check. `--since <duration>` (e.g. `24h`, `7d`, `2w`)
513
+ narrows to a recent window:
514
+
515
+ ```bash
516
+ rea audit summary # all time
517
+ rea audit summary --since 24h # last 24 hours
518
+ rea audit summary --since 7d --json # last week, JSON
519
+ ```
520
+
521
+ 0.42.0 hardened the rotated-file walk: pre-0.42.0 `--since` pruned
522
+ rotated audit segments by filename stamp, which is wall-clock at the
523
+ rotation INSTANT — not the earliest record contained. A rotated file
524
+ from N days ago can contain records from N+M days ago when the
525
+ rotation cycle was long, so pruning by filename silently dropped
526
+ in-window records. Post-0.42.0 the walker reads every rotated file
527
+ under `--since` and lets the per-record timestamp filter drop the
528
+ out-of-window entries. Correctness over micro-optimization;
529
+ `rea audit summary` performance is unchanged in practice.
530
+
392
531
  ## Policy knobs worth setting
393
532
 
394
533
  For consumers with a long-running migration branch (>30 commits since
@@ -81,6 +81,21 @@ export interface AuditSummaryResult {
81
81
  window_end: string | null;
82
82
  /** Absolute paths of audit files walked. */
83
83
  files_scanned: string[];
84
+ /**
85
+ * 0.42.0 codex round 4 P2 + round 6 P2 (2026-05-16) — reserved for
86
+ * future use; ALWAYS EMPTY in 0.42.0. The original intent (round 4)
87
+ * was to soft-skip rotated segments that the operator could not
88
+ * read (e.g. EACCES/EPERM after a backup restore). Round 6 showed
89
+ * the soft-skip was unsound: without per-segment time-range
90
+ * metadata we cannot prove a skipped file is out-of-scope for the
91
+ * `--since` window, so a silent skip risks an undercount + a
92
+ * misleading `chain_integrity: ok`. The current implementation
93
+ * therefore throws on any non-ENOENT read error; this field is
94
+ * kept in the public schema so a future release that ships
95
+ * per-segment time-range metadata can populate it without breaking
96
+ * JSON consumers.
97
+ */
98
+ unreadable_segments: string[];
84
99
  total_events: number;
85
100
  by_tool_name: Record<string, number>;
86
101
  by_tier: Record<string, number>;
@@ -118,48 +118,49 @@ export function parseDurationSeconds(raw) {
118
118
  * PLUS the current `audit.jsonl`. Round-1 P2: the prior shape
119
119
  * dropped rotated history silently while the header still
120
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).
121
+ * - `windowStart` set: walk EVERY rotated file. The per-record
122
+ * timestamp filter inside `computeAuditSummary` then drops
123
+ * out-of-window records during the scan. 0.41.0 round-3 P2 +
124
+ * 0.42.0 charter item 3: rotated filenames are NOT authoritative
125
+ * for "earliest contained record" — they are wall-clock at the
126
+ * ROTATION INSTANT, which can be days after the file's earliest
127
+ * contents when the rotation size cap is reached late. Pruning
128
+ * by filename therefore drops in-window records from
129
+ * conservatively-rotated logs (a rotated file from 7 days ago can
130
+ * still contain records from 14 days ago because the previous
131
+ * rotation event was 14 days ago). The cost of walking every
132
+ * rotated segment under `--since` is bounded by the rotation cap
133
+ * × number of segments — comfortably manageable in the
134
+ * summary-rollup setting where we already read every byte for
135
+ * the in-window scan; the win is correctness.
125
136
  *
126
- * Sort order is timestamp-ascending; the current `audit.jsonl` is
127
- * always appended last (it is the newest segment of the chain).
137
+ * Sort order is timestamp-ascending (by FILENAME stamp); the current
138
+ * `audit.jsonl` is always appended last (it is the newest segment
139
+ * of the chain).
128
140
  */
129
141
  async function resolveSummaryFileWalk(baseDir, windowStart) {
130
142
  const reaDir = path.join(baseDir, REA_DIR);
131
143
  const currentAudit = path.join(reaDir, AUDIT_FILE);
132
144
  const files = [];
133
145
  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
- }
146
+ // Both `windowStart === null` and `windowStart` set: walk every
147
+ // rotated segment. Pre-0.42.0 the `windowStart` branch attempted to
148
+ // prune rotated files by their filename stamp ("rotated at >=
149
+ // windowStart minus one buffer file"). That was wrong: the filename
150
+ // stamp marks the ROTATION event, not the earliest record contained
151
+ // in the file. A rotated file's records can pre-date its filename
152
+ // stamp by days when the previous rotation cycle was long. Walking
153
+ // every rotated file and letting the per-record `timestamp >=
154
+ // windowStart` filter inside `computeAuditSummary` decide is the
155
+ // only correct approach: we never falsely drop an in-window record
156
+ // because of where it happens to live on disk. Reference:
157
+ // 0.41.0 round-3 P2 + 0.42.0 charter item 3.
158
+ //
159
+ // `windowStart === null` (no --since) already walks every rotated
160
+ // segment same code path.
161
+ void windowStart; // intentionally unused — full-walk is correct in both modes
162
+ for (const name of rotated)
163
+ files.push(path.join(reaDir, name));
163
164
  try {
164
165
  const stat = await fs.stat(currentAudit);
165
166
  if (stat.isFile())
@@ -261,16 +262,52 @@ export async function computeAuditSummary(options = {}) {
261
262
  let latest = null;
262
263
  // We only feed in-window records to the chain-sample check.
263
264
  const inWindowRecords = [];
265
+ // 0.42.0 codex round 4 P2 + round 6 P2 (2026-05-16): reserved for
266
+ // future per-segment time-range metadata that would let us prove a
267
+ // skipped file is out of scope. Always empty under 0.42.0 — see
268
+ // the AuditSummaryResult.unreadable_segments docstring.
269
+ const unreadableSegments = [];
270
+ // We rebuild the actually-read file list as we go so the summary
271
+ // never claims to have scanned a file that was silently skipped.
272
+ // (Currently identical to `files` minus ENOENT entries since every
273
+ // other read error throws — kept as a separate accumulator so the
274
+ // shape stays correct when the future `unreadable_segments`
275
+ // soft-skip path lands.)
276
+ const actuallyScanned = [];
264
277
  for (const filePath of files) {
265
278
  let raw;
266
279
  try {
267
280
  raw = await fs.readFile(filePath, 'utf8');
268
281
  }
269
282
  catch (e) {
270
- if (e.code === 'ENOENT')
283
+ const errno = e.code;
284
+ if (errno === 'ENOENT')
271
285
  continue;
272
- throw e;
286
+ // 0.42.0 codex round 4 P2 + round 5 P2 + round 6 P2 (2026-05-16):
287
+ // earlier rounds attempted to soft-skip unreadable rotations to
288
+ // accommodate backup-restore artifacts. Round 6 caught that the
289
+ // soft-skip is unsound: `resolveSummaryFileWalk` now enqueues
290
+ // every rotated segment under `--since` (filename-stamp pruning
291
+ // was correctly removed because the stamp marks the rotation
292
+ // event, not the earliest record contained), so we CANNOT prove
293
+ // an unreadable file is out of scope without reading it. A
294
+ // silent skip would mean `rea audit summary` could exit 0 with
295
+ // an undercount AND `chain_integrity: ok` while real in-window
296
+ // records went uncounted.
297
+ //
298
+ // Throwing with a precise, actionable error is the right call:
299
+ // the operator can chmod the file, move it out of .rea/, or
300
+ // delete it. `unreadable_segments` in the result is reserved
301
+ // for the never-reached future case where we can prove a file
302
+ // is genuinely out of scope (we'd need rotation start/end
303
+ // metadata for that — out of scope here).
304
+ throw new Error(`rea audit summary: cannot read ${filePath} (${errno ?? 'unknown errno'}). ` +
305
+ `An unreadable audit segment may contain in-window records, so the summary ` +
306
+ `would be silently incomplete. Fix permissions (e.g. \`chmod u+r ${filePath}\`), ` +
307
+ `or move the file out of \`.rea/\` if you no longer need it. The current ` +
308
+ `audit.jsonl is always required.`);
273
309
  }
310
+ actuallyScanned.push(filePath);
274
311
  for (const line of raw.split('\n')) {
275
312
  if (line.length === 0)
276
313
  continue;
@@ -322,7 +359,12 @@ export async function computeAuditSummary(options = {}) {
322
359
  window_seconds: windowSeconds,
323
360
  window_start: windowStart !== null ? windowStart.toISOString() : null,
324
361
  window_end: windowEnd !== null ? windowEnd.toISOString() : null,
325
- files_scanned: files,
362
+ // 0.42.0 codex round 4 P2: report only the files actually read.
363
+ // Unreadable rotations are reported separately under
364
+ // `unreadable_segments` so consumers can tell the difference
365
+ // between "scanned and empty" and "skipped because permissions".
366
+ files_scanned: actuallyScanned,
367
+ unreadable_segments: unreadableSegments,
326
368
  total_events: totalEvents,
327
369
  by_tool_name: byToolName,
328
370
  by_tier: byTier,
@@ -387,6 +429,13 @@ export function renderAuditSummary(result) {
387
429
  lines.push('(no audit files found — has `rea serve` ever run?)');
388
430
  lines.push('');
389
431
  }
432
+ // 0.42.0 codex round 4 P2: even in the zero-events early-return,
433
+ // surface unreadable segments so the operator sees the gap.
434
+ if (result.unreadable_segments.length > 0) {
435
+ lines.push(`unreadable rotated segments: ${String(result.unreadable_segments.length)} ` +
436
+ `(see stderr for paths; fix permissions and re-run to include them)`);
437
+ lines.push('');
438
+ }
390
439
  return lines.join('\n');
391
440
  }
392
441
  const total = result.total_events;
@@ -409,6 +458,13 @@ export function renderAuditSummary(result) {
409
458
  : 'unsampled (no records in window)';
410
459
  lines.push(`chain integrity: ${chainLabel}`);
411
460
  lines.push(`files scanned: ${String(result.files_scanned.length)}`);
461
+ // 0.42.0 codex round 4 P2 (2026-05-16): surface unreadable rotated
462
+ // segments so an operator scanning the rendered summary doesn't
463
+ // miss a skipped archive that the JSON consumers can see.
464
+ if (result.unreadable_segments.length > 0) {
465
+ lines.push(`unreadable rotated segments: ${String(result.unreadable_segments.length)} ` +
466
+ `(see stderr for paths; fix permissions and re-run to include them)`);
467
+ }
412
468
  lines.push('');
413
469
  return lines.join('\n');
414
470
  }
@@ -138,6 +138,26 @@ export interface PolicyReaderProbes {
138
138
  * ignore the argument; the default production probe uses it.
139
139
  */
140
140
  python3PyYamlReachable?: (baseDir: string) => boolean;
141
+ /**
142
+ * 0.42.0 codex round 5 P2 (2026-05-16) — execution probe for the
143
+ * python3 list-walker branch in `policy_reader_get_list`. That
144
+ * branch needs to spawn `python3 -c "..."` with `import json` from
145
+ * stdlib; PyYAML is irrelevant. The check is execution-based (not
146
+ * PATH-only) because a `python3` symlink can resolve on PATH but
147
+ * fail to start in the current sandbox (dangling pyenv/asdf stub,
148
+ * permission-denied interpreter, missing dynamic libs). A PATH-only
149
+ * check would let the doctor declare `warn` on a box where the
150
+ * shim will actually fall through to Tier 3 — masking a real
151
+ * enforcement gap for list-valued policy keys.
152
+ *
153
+ * The probe runs `python3 -c "import json; print('ok')"` with the
154
+ * same env scrub as the PyYAML probe (PYTHONPATH/PYTHONHOME/
155
+ * PYTHONSTARTUP unset, PYTHONSAFEPATH=1, sys.path scrubbed) so a
156
+ * malicious repo cannot plant a `./json.py` that shadows stdlib
157
+ * and falsely report `true` while the real loader (which scrubs)
158
+ * fails.
159
+ */
160
+ python3ListWalkerReachable?: (baseDir: string) => boolean;
141
161
  awkOnPath?: () => string | null;
142
162
  jqOnPath?: () => string | null;
143
163
  }
@@ -183,12 +203,12 @@ export declare function checkPolicyReaderTier2(baseDir: string, probes?: PolicyR
183
203
  * Practically always present (POSIX requirement).
184
204
  *
185
205
  * 0.40.0 charter item 2 — conditional verdict, refined by codex
186
- * round 1 P2:
206
+ * round 1 P2 (0.40.0) and round 2 P2 (0.42.0):
187
207
  * - awk present → `pass`
188
208
  * - awk absent AND Tier 2 reachable → `warn`
189
209
  * (Tier 2 implies python3, which is a list-walker)
190
210
  * - awk absent AND Tier 1 reachable AND a list walker
191
- * (jq OR python3) is on PATH → `warn`
211
+ * (jq OR full Tier-2 reachable) is usable → `warn`
192
212
  * - awk absent AND Tier 1 reachable BUT no list walker → `fail`
193
213
  * (codex round 1 P2 — list-valued policy reads silently
194
214
  * fail-closed even though scalar reads work, so the
@@ -207,9 +227,29 @@ export declare function checkPolicyReaderTier2(baseDir: string, probes?: PolicyR
207
227
  * functional box that has python3 + jq + the rea CLI all wired but
208
228
  * happens to lack awk.
209
229
  *
230
+ * List-iteration semantic (clarifying note for codex round 2 P2,
231
+ * 2026-05-16): `policy_reader_get_list` in
232
+ * `hooks/_lib/policy-reader.sh` walks the cached subtree JSON via
233
+ * `jq` OR `python3` (stdlib-only — `json` module, no PyYAML import).
234
+ * PyYAML is only needed for Tier 2 itself (YAML PARSING into JSON),
235
+ * NOT for iterating the already-parsed JSON arrays at list-read time.
236
+ *
237
+ * Codex round 5 P2 (2026-05-16): the "list walker" predicate uses
238
+ * `python3ListWalkerReachable` — an EXECUTION probe that actually
239
+ * spawns `python3 -c "import json"` — instead of `python3OnPath`. A
240
+ * PATH-only check passes for broken pyenv/asdf shims, dangling
241
+ * symlinks, and sandboxed environments where the interpreter cannot
242
+ * start; in those cases the shim's list-walker branch would actually
243
+ * fail and `blocked_paths`/`protected_writes` enforcement would
244
+ * silently break while doctor reported `warn`. The execution probe
245
+ * mirrors `defaultPython3PyYamlReachable` exactly but swaps the
246
+ * `import yaml` for `import json` so it's not gated on PyYAML
247
+ * availability (which is irrelevant to list iteration).
248
+ *
210
249
  * Takes `baseDir` so it can evaluate Tier 1's two-stage check (dist
211
- * present + CLI invokable) and Tier 2's reachability. Probes are
212
- * threaded through identically.
250
+ * present + CLI invokable), Tier 2's reachability, and the
251
+ * list-walker execution probe. All probes are threaded through
252
+ * identically.
213
253
  */
214
254
  export declare function checkPolicyReaderTier3(baseDir: string, probes?: PolicyReaderProbes): CheckResult;
215
255
  /**