@bookedsolid/rea 0.41.0 → 0.42.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
  /**
@@ -1264,18 +1264,74 @@ function defaultPython3PyYamlReachable(baseDir) {
1264
1264
  return false;
1265
1265
  }
1266
1266
  }
1267
+ /**
1268
+ * 0.42.0 codex round 5 P2 (2026-05-16) — execution probe for the
1269
+ * python3 list-walker. Mirrors `defaultPython3PyYamlReachable` exactly
1270
+ * but swaps the `import yaml` for `import json` (the actual stdlib
1271
+ * module the shim's list-walker branch imports). Spawning the
1272
+ * interpreter end-to-end catches the broken-symlink / unreachable-
1273
+ * shim case that a bare PATH check misses.
1274
+ */
1275
+ function defaultPython3ListWalkerReachable(baseDir) {
1276
+ try {
1277
+ const probeEnv = { ...process.env, PYTHONSAFEPATH: '1' };
1278
+ delete probeEnv['PYTHONPATH'];
1279
+ delete probeEnv['PYTHONHOME'];
1280
+ delete probeEnv['PYTHONSTARTUP'];
1281
+ // Same sys.path scrub as the production loader, applied before
1282
+ // `import json`. `json` is stdlib so a malicious `./json.py`
1283
+ // attack would matter the same way `./yaml.py` does.
1284
+ const probeBody = [
1285
+ 'import sys',
1286
+ 'import os',
1287
+ '_cwd = os.getcwd()',
1288
+ '_cwd_real = os.path.realpath(_cwd)',
1289
+ 'sys.path[:] = [p for p in sys.path if p not in ("", ".", _cwd, _cwd_real)]',
1290
+ 'import json',
1291
+ 'sys.stdout.write("ok")',
1292
+ ].join('\n');
1293
+ const res = spawnSync('python3', ['-c', probeBody], {
1294
+ cwd: baseDir,
1295
+ env: probeEnv,
1296
+ timeout: 5_000,
1297
+ stdio: ['ignore', 'pipe', 'ignore'],
1298
+ });
1299
+ if (res.status !== 0)
1300
+ return false;
1301
+ return (res.stdout?.toString().trim() ?? '') === 'ok';
1302
+ }
1303
+ catch {
1304
+ return false;
1305
+ }
1306
+ }
1267
1307
  const DEFAULT_PROBES = {
1268
1308
  cliDistExists: defaultCliDistExists,
1269
1309
  cliInvokable: defaultCliInvokable,
1270
1310
  python3OnPath: () => resolveBinaryOnPath('python3'),
1271
1311
  python3PyYamlReachable: defaultPython3PyYamlReachable,
1312
+ python3ListWalkerReachable: defaultPython3ListWalkerReachable,
1272
1313
  awkOnPath: () => resolveBinaryOnPath('awk'),
1273
1314
  jqOnPath: () => resolveBinaryOnPath('jq'),
1274
1315
  };
1275
1316
  function resolveProbes(probes) {
1276
1317
  if (probes === undefined)
1277
1318
  return DEFAULT_PROBES;
1278
- return { ...DEFAULT_PROBES, ...probes };
1319
+ // 0.42.0 codex round 5 P2 (2026-05-16): when a caller (typically
1320
+ // a unit test) stubs `python3OnPath` but does NOT stub the new
1321
+ // `python3ListWalkerReachable` execution probe, derive a faithful
1322
+ // fallback from `python3OnPath` instead of falling through to the
1323
+ // real `defaultPython3ListWalkerReachable` (which would spawn a
1324
+ // python3 subprocess and break test determinism). The convention
1325
+ // matches `python3PyYamlReachable` in the existing test suite:
1326
+ // stubs that say "python3 is present" want both downstream probes
1327
+ // to report reachable, and stubs that say "python3 is absent" want
1328
+ // both downstream probes to report unreachable.
1329
+ const overrides = { ...probes };
1330
+ if (overrides.python3ListWalkerReachable === undefined && overrides.python3OnPath !== undefined) {
1331
+ const stubbedPython3OnPath = overrides.python3OnPath;
1332
+ overrides.python3ListWalkerReachable = () => stubbedPython3OnPath() !== null;
1333
+ }
1334
+ return { ...DEFAULT_PROBES, ...overrides };
1279
1335
  }
1280
1336
  /**
1281
1337
  * Tier 1 — `rea hook policy-get`. Reachable when the rea CLI is
@@ -1374,12 +1430,12 @@ export function checkPolicyReaderTier2(baseDir, probes) {
1374
1430
  * Practically always present (POSIX requirement).
1375
1431
  *
1376
1432
  * 0.40.0 charter item 2 — conditional verdict, refined by codex
1377
- * round 1 P2:
1433
+ * round 1 P2 (0.40.0) and round 2 P2 (0.42.0):
1378
1434
  * - awk present → `pass`
1379
1435
  * - awk absent AND Tier 2 reachable → `warn`
1380
1436
  * (Tier 2 implies python3, which is a list-walker)
1381
1437
  * - awk absent AND Tier 1 reachable AND a list walker
1382
- * (jq OR python3) is on PATH → `warn`
1438
+ * (jq OR full Tier-2 reachable) is usable → `warn`
1383
1439
  * - awk absent AND Tier 1 reachable BUT no list walker → `fail`
1384
1440
  * (codex round 1 P2 — list-valued policy reads silently
1385
1441
  * fail-closed even though scalar reads work, so the
@@ -1398,9 +1454,29 @@ export function checkPolicyReaderTier2(baseDir, probes) {
1398
1454
  * functional box that has python3 + jq + the rea CLI all wired but
1399
1455
  * happens to lack awk.
1400
1456
  *
1457
+ * List-iteration semantic (clarifying note for codex round 2 P2,
1458
+ * 2026-05-16): `policy_reader_get_list` in
1459
+ * `hooks/_lib/policy-reader.sh` walks the cached subtree JSON via
1460
+ * `jq` OR `python3` (stdlib-only — `json` module, no PyYAML import).
1461
+ * PyYAML is only needed for Tier 2 itself (YAML PARSING into JSON),
1462
+ * NOT for iterating the already-parsed JSON arrays at list-read time.
1463
+ *
1464
+ * Codex round 5 P2 (2026-05-16): the "list walker" predicate uses
1465
+ * `python3ListWalkerReachable` — an EXECUTION probe that actually
1466
+ * spawns `python3 -c "import json"` — instead of `python3OnPath`. A
1467
+ * PATH-only check passes for broken pyenv/asdf shims, dangling
1468
+ * symlinks, and sandboxed environments where the interpreter cannot
1469
+ * start; in those cases the shim's list-walker branch would actually
1470
+ * fail and `blocked_paths`/`protected_writes` enforcement would
1471
+ * silently break while doctor reported `warn`. The execution probe
1472
+ * mirrors `defaultPython3PyYamlReachable` exactly but swaps the
1473
+ * `import yaml` for `import json` so it's not gated on PyYAML
1474
+ * availability (which is irrelevant to list iteration).
1475
+ *
1401
1476
  * Takes `baseDir` so it can evaluate Tier 1's two-stage check (dist
1402
- * present + CLI invokable) and Tier 2's reachability. Probes are
1403
- * threaded through identically.
1477
+ * present + CLI invokable), Tier 2's reachability, and the
1478
+ * list-walker execution probe. All probes are threaded through
1479
+ * identically.
1404
1480
  */
1405
1481
  export function checkPolicyReaderTier3(baseDir, probes) {
1406
1482
  const label = 'policy-reader Tier 3 (awk)';
@@ -1420,22 +1496,25 @@ export function checkPolicyReaderTier3(baseDir, probes) {
1420
1496
  // ladder would actually do at runtime.
1421
1497
  const tier1 = p.cliDistExists(baseDir) && p.cliInvokable(baseDir);
1422
1498
  const tier2 = p.python3OnPath() !== null && p.python3PyYamlReachable(baseDir);
1423
- // Codex round 1 P2 (2026-05-16): the downgrade-to-warn branch needs
1424
- // a list walker too. `policy_reader_get_list` (the helper that reads
1425
- // list-valued keys like `blocked_paths`) iterates the parsed JSON
1426
- // array via jq OR python3, falling back to Tier 3 awk for inline
1427
- // arrays. With awk gone AND no jq AND no python3, list-valued
1428
- // policy reads silently fail-closed even when Tier 1 is reachable
1429
- // `blocked-paths-bash-gate.sh` etc. would see an EMPTY blocked-paths
1430
- // set and stop enforcing entries the operator declared. Pre-fix
1431
- // this concrete shape (cliInvokable + no python3 + no jq + no awk)
1432
- // returned `warn` and the doctor exited 0 on a broken install.
1433
- // Post-fix the downgrade requires a list walker; otherwise we stay
1434
- // on `fail`. Tier 2 implies python3 on PATH (the interpreter that
1435
- // ran PyYAML), so Tier 2 always brings list-walker support — no
1436
- // additional check needed for the Tier-2 branch.
1437
- const py = p.python3OnPath();
1438
- const listWalker = p.jqOnPath() !== null || py !== null;
1499
+ // Codex round 1 P2 (0.40.0) + round 2 P2 corrected (0.42.0,
1500
+ // 2026-05-16) + round 5 P2 (0.42.0, 2026-05-16): the
1501
+ // downgrade-to-warn branch needs a list walker too.
1502
+ // `policy_reader_get_list` iterates the parsed JSON array via jq
1503
+ // OR python3. The python3 branch uses `json` from stdlib only
1504
+ // PyYAML is NOT required (it's only needed for Tier 2's YAML
1505
+ // parsing step, which has already run by the time list iteration
1506
+ // executes).
1507
+ //
1508
+ // Round 5 P2 hardening: the python3 leg of this predicate uses an
1509
+ // EXECUTION probe (`python3ListWalkerReachable`), not just a PATH
1510
+ // check. A `python3` symlink can resolve on PATH while the
1511
+ // interpreter itself fails to start (dangling pyenv/asdf shim,
1512
+ // sandboxed runner without dynamic libs, permission denied on the
1513
+ // resolved binary). PATH-only would let doctor declare `warn` on
1514
+ // a box where the shim's list walker would actually fail —
1515
+ // silently breaking `blocked_paths` / `protected_writes`
1516
+ // enforcement while doctor exits 0.
1517
+ const listWalker = p.jqOnPath() !== null || p.python3ListWalkerReachable(baseDir);
1439
1518
  if (tier2 || (tier1 && listWalker)) {
1440
1519
  const reachable = [];
1441
1520
  if (tier1)
@@ -1451,21 +1530,43 @@ export function checkPolicyReaderTier3(baseDir, probes) {
1451
1530
  'to restore the last-resort fallback.',
1452
1531
  };
1453
1532
  }
1454
- // Codex round 1 P2: separate "no list walker" diagnosis from the
1455
- // catastrophic "no tier at all" case. Tier 1 reachable but no jq
1456
- // AND no python3 AND no awk means list-valued policy reads
1457
- // fail-closed silently — distinct from the truly-empty
1458
- // no-CLI-no-python-no-awk shape, and worth a precise remediation.
1533
+ // Codex round 1 P2 (0.40.0) + round 2 P2 (0.42.0): separate "no list
1534
+ // walker" diagnosis from the catastrophic "no tier at all" case.
1535
+ // Tier 1 reachable but no jq AND no python3 AND no awk means
1536
+ // list-valued policy reads fail-closed silently — distinct from the
1537
+ // truly-empty no-CLI-no-python-no-awk shape, and worth a precise
1538
+ // remediation. The python3-as-list-walker signal is plain
1539
+ // `python3OnPath` (the `json` module is stdlib — PyYAML is NOT
1540
+ // required for list iteration).
1459
1541
  if (tier1) {
1542
+ // 0.42.0 codex round 6 P3 (2026-05-16): distinguish "python3 not
1543
+ // on PATH" from "python3 on PATH but execution fails". Pre-fix
1544
+ // this branch always reported "python3 is not on PATH" even when
1545
+ // a python3 binary was resolvable but a broken pyenv/asdf shim
1546
+ // or sandboxed interpreter failed the execution probe — that
1547
+ // sent operators toward the wrong remediation. Round 5 added
1548
+ // the execution probe specifically to surface this case; the
1549
+ // diagnostic needs to follow.
1550
+ const pythonOnPath = p.python3OnPath();
1551
+ const pythonState = pythonOnPath === null
1552
+ ? 'python3 is not on PATH'
1553
+ : `python3 at ${pythonOnPath} cannot execute \`import json\` (broken pyenv/asdf shim, ` +
1554
+ 'sandboxed interpreter, or permission-denied binary — fix the interpreter or ' +
1555
+ 'remove the shim)';
1556
+ const remediation = pythonOnPath === null
1557
+ ? 'Install awk OR jq OR python3 to restore list-iteration.'
1558
+ : `Install awk OR jq, or repair the python3 interpreter at ${pythonOnPath}, ` +
1559
+ 'to restore list-iteration.';
1460
1560
  return {
1461
1561
  label,
1462
1562
  status: 'fail',
1463
- detail: 'awk not on PATH AND neither jq nor python3 is on PATH Tier 1 (rea CLI) parses ' +
1464
- 'flow-form scalars, but `policy_reader_get_list` cannot iterate list-valued keys ' +
1465
- '(e.g. `blocked_paths: [.env, ...]`) without jq, python3, OR awk to walk the ' +
1466
- 'resulting JSON arrays. Affected hooks (`blocked-paths-bash-gate.sh`, ' +
1467
- '`blocked-paths-enforcer.sh`, …) see an EMPTY list and silently stop enforcing. ' +
1468
- 'Install awk OR jq OR python3 to restore list-iteration.',
1563
+ detail: `awk not on PATH AND jq is not on PATH AND ${pythonState} ` +
1564
+ 'Tier 1 (rea CLI) parses flow-form scalars, but `policy_reader_get_list` ' +
1565
+ 'cannot iterate list-valued keys (e.g. `blocked_paths: [.env, ...]`) ' +
1566
+ 'without jq OR python3 OR awk to walk the resulting JSON arrays. ' +
1567
+ 'Affected hooks (`blocked-paths-bash-gate.sh`, ' +
1568
+ `\`blocked-paths-enforcer.sh\`, …) see an EMPTY list and silently stop ` +
1569
+ `enforcing. ${remediation}`,
1469
1570
  };
1470
1571
  }
1471
1572
  return {
@@ -1542,11 +1643,14 @@ export function checkPolicyReaderTierSummary(baseDir, probes) {
1542
1643
  const tier2 = py !== null && p.python3PyYamlReachable(baseDir);
1543
1644
  const tier3 = p.awkOnPath() !== null;
1544
1645
  const jq = p.jqOnPath();
1545
- // List iteration after Tier 1/2 needs jq OR python3 to walk the
1546
- // JSON. Tier 2 implies python3 on PATH (the interpreter that ran
1547
- // the loader); so the only "lists broken" shape is Tier 1 reachable
1548
- // but neither jq nor python3 on PATH.
1549
- const listWalker = jq !== null || py !== null;
1646
+ // 0.42.0 codex round 5 P2 (2026-05-16): list iteration after Tier
1647
+ // 1/2 needs jq OR a python3 that can ACTUALLY execute (not just
1648
+ // resolve on PATH). The execution probe catches the broken-shim
1649
+ // case where `python3` resolves but the interpreter cannot start
1650
+ // PATH-only would falsely declare the list walker "usable" on a
1651
+ // box where the shim's python3 branch will fall through to Tier 3
1652
+ // and silently miss flow-form arrays.
1653
+ const listWalker = jq !== null || p.python3ListWalkerReachable(baseDir);
1550
1654
  const reachable = [];
1551
1655
  if (tier1)
1552
1656
  reachable.push('Tier 1 (CLI)');
@@ -97,6 +97,24 @@ export interface UpgradeCheckFile {
97
97
  * row (e.g. "removed-upstream — kept by default unless --force"). */
98
98
  note?: string;
99
99
  }
100
+ /**
101
+ * 0.42.0 charter item 2 — surface the same settings-schema validation
102
+ * the real upgrade flow runs. `runUpgrade` calls `validateSettings`
103
+ * on the merged result and refuses the write (throws) when it fails;
104
+ * pre-0.42.0 `rea upgrade --check` never invoked that check, so a
105
+ * preview could promise a write that the real upgrade would refuse.
106
+ *
107
+ * - `parsed: true` — schema validation succeeded; `errors` is empty.
108
+ * The real upgrade WOULD write the merged settings on demand.
109
+ * - `parsed: false` — schema validation failed. `errors` carries the
110
+ * same zod-issue strings `runUpgrade` would surface in its throw
111
+ * message. The real upgrade would refuse and leave settings.json
112
+ * untouched.
113
+ */
114
+ export interface UpgradeCheckSettingsValidation {
115
+ parsed: boolean;
116
+ errors: string[];
117
+ }
100
118
  export interface UpgradeCheckPlan {
101
119
  schema_version: typeof UPGRADE_CHECK_SCHEMA_VERSION;
102
120
  rea_version: string;
@@ -112,6 +130,26 @@ export interface UpgradeCheckPlan {
112
130
  removed_upstream: number;
113
131
  };
114
132
  files: UpgradeCheckFile[];
133
+ /** 0.42.0 — schema-validation outcome on the merged settings.json
134
+ * the real `rea upgrade` would write. `null` when the synthetic
135
+ * settings classification did not produce a merged result (should
136
+ * not happen in practice; defensive). */
137
+ settings_validation: UpgradeCheckSettingsValidation | null;
138
+ /**
139
+ * 0.42.0 codex round 3 P2 (2026-05-16) — top-level "preview = real"
140
+ * verdict. `true` when `rea upgrade` would actually start mutating
141
+ * the install; `false` when the new pre-flight (settings-validation)
142
+ * gate would refuse the upgrade before any file is written.
143
+ *
144
+ * Why this matters: `counts` + `files` still describe what WOULD be
145
+ * written if validation passed (operators want the diff so they can
146
+ * fix the underlying invalid setting and see the upgrade preview in
147
+ * one shot). But CI and automation consuming the JSON need a single
148
+ * unambiguous signal that the real upgrade will write nothing in
149
+ * the current state. Use `would_apply` as that signal; treat
150
+ * `files[]` + `counts` as conditional on `would_apply === true`.
151
+ */
152
+ would_apply: boolean;
115
153
  }
116
154
  export interface ComputeUpgradeCheckOptions {
117
155
  /** Defaults to `process.cwd()`. */
@@ -65,6 +65,7 @@ import { manifestExists, readManifest } from './install/manifest-io.js';
65
65
  import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
66
66
  import { diffUnified, DIFF_TOO_LARGE_NOTICE } from './install/unified-diff.js';
67
67
  import { loadPolicy } from '../policy/loader.js';
68
+ import { validateSettings } from '../config/settings-schema.js';
68
69
  import { err, getPkgVersion, log } from './utils.js';
69
70
  /** Hard cap on the diff input size. Mirrors `DIFF_SIZE_CAP_BYTES` in
70
71
  * upgrade.ts so the two preview surfaces agree on the "too big to
@@ -288,6 +289,11 @@ async function classifyClaudeMd(resolvedRoot, includeDiffs) {
288
289
  * upgrade flow, we prune known-stale hook tokens BEFORE merging the
289
290
  * default-desired hook set — the order matters so the merge sees a
290
291
  * clean baseline.
292
+ *
293
+ * 0.42.0 — also returns the merged object so the caller can run the
294
+ * same `validateSettings` check the real `runUpgrade` runs. We hand
295
+ * back the merged shape directly (not the file rendering) so the
296
+ * caller can decide whether to thread it into the schema check.
291
297
  */
292
298
  async function classifySettings(resolvedRoot, includeDiffs) {
293
299
  const desired = defaultDesiredHooks();
@@ -344,7 +350,7 @@ async function classifySettings(resolvedRoot, includeDiffs) {
344
350
  Object.assign(file, safeDiff(oldText, newText, file.path));
345
351
  }
346
352
  }
347
- return file;
353
+ return { file, merged: mergeResult.merged };
348
354
  }
349
355
  /**
350
356
  * Build a `removed_upstream` entry from a manifest record that has no
@@ -480,14 +486,37 @@ export async function computeUpgradeCheck(options = {}) {
480
486
  }
481
487
  }
482
488
  // Synthetic entries.
483
- const settingsFile = await classifySettings(resolvedRoot, includeDiffs);
484
- files.push(settingsFile);
489
+ const settingsClassification = await classifySettings(resolvedRoot, includeDiffs);
490
+ files.push(settingsClassification.file);
485
491
  const claudeMd = await classifyClaudeMd(resolvedRoot, includeDiffs);
486
492
  if (claudeMd !== null)
487
493
  files.push(claudeMd);
488
494
  const gitignoreFile = await classifyGitignore(resolvedRoot, includeDiffs);
489
495
  if (gitignoreFile !== null)
490
496
  files.push(gitignoreFile);
497
+ // 0.42.0 charter item 2 — schema-validate the merged settings the
498
+ // real `runUpgrade` would write. `runUpgrade` calls `validateSettings`
499
+ // (non-strict, matching the upgrade flow's posture) and throws when
500
+ // the merged result fails — refusing the write. Pre-0.42.0 the
501
+ // preview never ran this check, so the planner could promise a write
502
+ // that the real upgrade would refuse. Reproduce the exact validation
503
+ // shape here so the JSON `settings_validation` field is byte-for-byte
504
+ // what `runUpgrade` would see.
505
+ const validation = validateSettings(settingsClassification.merged, { strict: false });
506
+ const settingsValidation = {
507
+ parsed: validation.parsed,
508
+ errors: validation.errors,
509
+ };
510
+ // If validation failed, annotate the settings file row so the
511
+ // human-readable rendering surfaces the refusal alongside the count
512
+ // table (operators reading the table without scrolling to the
513
+ // footer still see the warning). Note appends rather than overwrites
514
+ // so the existing merge / prune annotations remain visible.
515
+ if (!validation.parsed) {
516
+ const refusalNote = `WOULD REFUSE: schema validation failed — ${validation.errors.join('; ')}`;
517
+ const f = settingsClassification.file;
518
+ f.note = f.note !== undefined ? `${f.note}; ${refusalNote}` : refusalNote;
519
+ }
491
520
  // Stable sort: action priority (modified → created → removed_upstream
492
521
  // → unchanged) then path. Operators reviewing the table want to see
493
522
  // the changed entries first.
@@ -513,6 +542,13 @@ export async function computeUpgradeCheck(options = {}) {
513
542
  bootstrap: isBootstrap,
514
543
  counts,
515
544
  files,
545
+ settings_validation: settingsValidation,
546
+ // Codex round 3 P2 (2026-05-16): mirror runUpgrade's pre-flight
547
+ // gate. `would_apply` is true when the real upgrade would actually
548
+ // start mutating disk — currently the only gate is settings-schema
549
+ // validation, but more pre-flight checks may land in future
550
+ // releases (in which case they get ANDed in here).
551
+ would_apply: settingsValidation.parsed,
516
552
  };
517
553
  }
518
554
  /**
@@ -528,8 +564,20 @@ export function renderUpgradeCheck(plan) {
528
564
  lines.push(` bootstrap mode: no install-manifest found yet`);
529
565
  }
530
566
  lines.push('');
567
+ // Codex round 3 P2 (2026-05-16): when a pre-flight gate would
568
+ // refuse the upgrade, the counts/files below describe what WOULD
569
+ // happen if the gate passed — but `rea upgrade` will actually
570
+ // write nothing in the current state. Lead with that banner so
571
+ // operators don't read the summary table as a promise of action.
572
+ if (!plan.would_apply) {
573
+ lines.push('BLOCKED — `rea upgrade` would refuse to apply this plan in its current state. ' +
574
+ 'The summary below describes the would-be plan IF the refusal were fixed first; ' +
575
+ 'the real upgrade writes nothing until the refusal clears.');
576
+ lines.push('');
577
+ }
531
578
  const totalChanges = plan.counts.created + plan.counts.modified + plan.counts.removed_upstream;
532
- lines.push(`Summary ${String(totalChanges)} planned change(s):`);
579
+ const summaryLabel = plan.would_apply ? 'planned change(s)' : 'change(s) blocked by refusal';
580
+ lines.push(`Summary — ${String(totalChanges)} ${summaryLabel}:`);
533
581
  lines.push(` created: ${String(plan.counts.created)}`);
534
582
  lines.push(` modified: ${String(plan.counts.modified)}`);
535
583
  lines.push(` removed-upstream: ${String(plan.counts.removed_upstream)}`);
@@ -538,6 +586,17 @@ export function renderUpgradeCheck(plan) {
538
586
  if (totalChanges === 0) {
539
587
  lines.push('No changes — your install is already in sync with this rea version.');
540
588
  lines.push('');
589
+ // 0.42.0 — even with zero planned changes, surface a validation
590
+ // failure here so an operator doesn't see "in sync" and miss the
591
+ // settings refusal.
592
+ if (plan.settings_validation !== null && !plan.settings_validation.parsed) {
593
+ lines.push('WARNING: `rea upgrade` would REFUSE to run — the merged ' +
594
+ '.claude/settings.json would fail schema validation:');
595
+ for (const e of plan.settings_validation.errors) {
596
+ lines.push(` - ${e}`);
597
+ }
598
+ lines.push('');
599
+ }
541
600
  return lines.join('\n');
542
601
  }
543
602
  // Per-file detail — skip `unchanged` entries.
@@ -545,7 +604,12 @@ export function renderUpgradeCheck(plan) {
545
604
  if (file.action === 'unchanged')
546
605
  continue;
547
606
  const marker = file.action === 'created' ? '+' : file.action === 'removed_upstream' ? '-' : '~';
548
- const label = file.action === 'removed_upstream' ? 'removed-upstream' : file.action;
607
+ const baseLabel = file.action === 'removed_upstream' ? 'removed-upstream' : file.action;
608
+ // Codex round 3 P2 (2026-05-16): when a refusal is active, every
609
+ // would-be-mutated file row gets a BLOCKED suffix so a quick scroll
610
+ // through the per-file detail cannot miss the fact that no write
611
+ // will happen until the refusal clears.
612
+ const label = plan.would_apply ? baseLabel : `${baseLabel} (BLOCKED — refusal active)`;
549
613
  const syntheticTag = file.synthetic !== undefined ? ` [${file.synthetic}]` : '';
550
614
  lines.push(`${marker} ${file.path}${syntheticTag} — ${label}`);
551
615
  if (file.note !== undefined)
@@ -572,8 +636,30 @@ export function renderUpgradeCheck(plan) {
572
636
  }
573
637
  lines.push('');
574
638
  }
575
- lines.push('No changes were written. Run `rea upgrade` (without --check) to apply.');
576
- lines.push('');
639
+ // 0.42.0 surface settings-schema validation outcome alongside the
640
+ // footer. When the merged settings would fail validation, `rea
641
+ // upgrade` would refuse to start at all (codex round 2 P2 moved the
642
+ // validation to a pre-flight check BEFORE any file writes); we
643
+ // report that here so consumers can fix policy + settings before
644
+ // invoking the real upgrade.
645
+ if (plan.settings_validation !== null && !plan.settings_validation.parsed) {
646
+ lines.push('');
647
+ lines.push('WARNING: `rea upgrade` would REFUSE to run — the merged ' +
648
+ '.claude/settings.json would fail schema validation:');
649
+ for (const e of plan.settings_validation.errors) {
650
+ lines.push(` - ${e}`);
651
+ }
652
+ lines.push('No files will be written by `rea upgrade` while this is true — the ' +
653
+ 'pre-flight check runs before any canonical hook or agent file is ' +
654
+ 'installed AND before the 0.11.0 .rea/policy.yaml migration, so your ' +
655
+ 'existing install stays untouched. Fix the settings entries flagged ' +
656
+ 'above and re-run `rea upgrade --check`.');
657
+ lines.push('');
658
+ }
659
+ else {
660
+ lines.push('No changes were written. Run `rea upgrade` (without --check) to apply.');
661
+ lines.push('');
662
+ }
577
663
  return lines.join('\n');
578
664
  }
579
665
  /**
@@ -460,10 +460,52 @@ export async function runUpgrade(options = {}) {
460
460
  if (options.force === true && !dryRun) {
461
461
  warn('--force: overwriting locally-modified files and deleting removed-upstream entries without prompt.');
462
462
  }
463
+ // 0.42.0 codex round 2 P2 (2026-05-16): pre-flight the merged
464
+ // settings validation BEFORE any file writes happen. Pre-correction,
465
+ // `upgradeSettings` ran AFTER the canonical-file write loop and only
466
+ // then validated the merged result; throwing here meant canonical
467
+ // hook + agent files had already been written to disk, breaking
468
+ // `rea upgrade --check`'s implicit "preview = real" contract (the
469
+ // check footer correctly claimed validation would refuse, but did
470
+ // not warn that hook files would still be written first).
471
+ //
472
+ // Codex round 2 P2 (2026-05-16, follow-up): this MUST run before
473
+ // `migrateReviewPolicyFor0110` as well — the 0.11.0 policy migration
474
+ // rewrites `.rea/policy.yaml` and creates a `policy.yaml.bak-*`
475
+ // sibling. If the pre-flight runs after that migration, the
476
+ // "no files have been written" claim in the refusal message
477
+ // becomes false for pre-0.11 consumers with malformed settings.
478
+ // Ordering: pre-flight FIRST, then migration, then canonical
479
+ // enumeration + write loop.
480
+ //
481
+ // The pre-flight reuses the same helpers as `upgradeSettings` —
482
+ // `readSettings` / `pruneHookCommands` / `mergeSettings` /
483
+ // `validateSettings` are all pure functions over existing on-disk
484
+ // state and `defaultDesiredHooks()`, so running them twice is
485
+ // cheap (microseconds — single JSON parse + small object merge)
486
+ // and the second run inside `upgradeSettings` re-derives the same
487
+ // result before writing. Atomicity guarantee: if validation fails,
488
+ // `runUpgrade` aborts here with ZERO mutations to disk.
489
+ {
490
+ const desired = defaultDesiredHooks();
491
+ const { settings: existingSettings } = readSettings(resolvedRoot);
492
+ const pruned = pruneHookCommands(existingSettings, STALE_HOOK_COMMAND_TOKENS);
493
+ const mergeResult = mergeSettings(pruned.merged, desired);
494
+ const validation = validateSettings(mergeResult.merged);
495
+ if (!validation.parsed) {
496
+ throw new Error(`rea upgrade: refusing to start because the merged .claude/settings.json would ` +
497
+ `fail schema validation. This is a safety guardrail — no files have been written ` +
498
+ `(including no .rea/policy.yaml 0.11.0 migration). Your existing install is ` +
499
+ `unchanged. zod errors: ${validation.errors.join('; ')}`);
500
+ }
501
+ }
463
502
  // 0.11.0 migration — strip removed review.* fields and backfill the new
464
503
  // concerns_blocks default. Runs before canonical file reconciliation so a
465
504
  // policy that fails strict schema load (which happens on upgrade from
466
505
  // 0.10.x the moment we re-read `.rea/policy.yaml`) is cleaned up first.
506
+ // 0.42.0 round 2 P2 follow-up: ordered AFTER the pre-flight validation
507
+ // above so a settings-validation refusal does not leave a half-migrated
508
+ // .rea/policy.yaml + backup behind.
467
509
  await migrateReviewPolicyFor0110(resolvedRoot, { dryRun });
468
510
  const canonicalFiles = await enumerateCanonicalFiles();
469
511
  if (canonicalFiles.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",