@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.
@@ -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.43.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)",