@bookedsolid/rea 0.21.0 → 0.21.1

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.
Files changed (2) hide show
  1. package/dist/cli/init.js +126 -15
  2. package/package.json +1 -1
package/dist/cli/init.js CHANGED
@@ -85,7 +85,7 @@ function resolveLayered(profileName, reagentTranslated) {
85
85
  }
86
86
  return layered;
87
87
  }
88
- async function runWizard(options, targetDir, reagentPolicyPath, layeredBase) {
88
+ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, existingPolicy = undefined) {
89
89
  const projectName = detectProjectName(targetDir);
90
90
  p.intro(`rea init — ${projectName}`);
91
91
  let fromReagent = options.fromReagent === true;
@@ -124,9 +124,15 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase) {
124
124
  cancel('Init cancelled.');
125
125
  profileName = picked;
126
126
  }
127
- const autonomyDefault = layeredBase.autonomy_level ?? AutonomyLevel.L1;
127
+ // 0.21.1: prefer the existing on-disk value over the profile default
128
+ // so re-running `rea init` doesn't reset an operator's manual edit.
129
+ const autonomyDefault = existingPolicy?.autonomyLevel
130
+ ?? layeredBase.autonomy_level
131
+ ?? AutonomyLevel.L1;
128
132
  const autonomyPick = await p.select({
129
- message: 'Starting autonomy_level',
133
+ message: existingPolicy?.autonomyLevel !== undefined
134
+ ? `Starting autonomy_level (current: ${existingPolicy.autonomyLevel})`
135
+ : 'Starting autonomy_level',
130
136
  initialValue: autonomyDefault,
131
137
  options: [
132
138
  { value: AutonomyLevel.L0, label: 'L0', hint: 'read-only; every write needs approval' },
@@ -139,9 +145,13 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase) {
139
145
  cancel('Init cancelled.');
140
146
  const autonomyLevel = autonomyPick;
141
147
  const maxCandidates = AUTONOMY_LEVELS.filter((lvl) => levelRank(lvl) >= levelRank(autonomyLevel));
142
- const defaultMax = (layeredBase.max_autonomy_level !== undefined &&
143
- maxCandidates.includes(layeredBase.max_autonomy_level) &&
144
- layeredBase.max_autonomy_level) ||
148
+ // 0.21.1: prefer existing on-disk max_autonomy_level over profile default.
149
+ const defaultMax = (existingPolicy?.maxAutonomyLevel !== undefined &&
150
+ maxCandidates.includes(existingPolicy.maxAutonomyLevel) &&
151
+ existingPolicy.maxAutonomyLevel) ||
152
+ (layeredBase.max_autonomy_level !== undefined &&
153
+ maxCandidates.includes(layeredBase.max_autonomy_level) &&
154
+ layeredBase.max_autonomy_level) ||
145
155
  maxCandidates.find((l) => l === AutonomyLevel.L2) ||
146
156
  autonomyLevel;
147
157
  const maxOptions = maxCandidates.map((lvl) => {
@@ -233,6 +243,79 @@ async function printCodexInstallAssist() {
233
243
  console.log(' Install via the Claude Code Codex plugin helper: `/codex:setup`,');
234
244
  console.log(' or set `review.codex_required: false` in .rea/policy.yaml to opt out.');
235
245
  }
246
+ /**
247
+ * Read user-mutable values from an existing `.rea/policy.yaml`.
248
+ * Returns undefined when the file doesn't exist or fails to parse.
249
+ *
250
+ * The reader is permissive — any field that fails to extract is
251
+ * dropped from the result; the caller falls back to the profile
252
+ * default for that one field. This is the idempotency contract
253
+ * extension introduced in 0.17.0 (`installed_at` preservation),
254
+ * extended in 0.21.1 to cover every field an operator might
255
+ * manually edit between init runs.
256
+ *
257
+ * Profile-switch is allowed but advisory: when the existing
258
+ * `profile:` value disagrees with the requested one, the existing
259
+ * VALUES are still preserved. Operators who want full reset pass
260
+ * `--force` to bypass the file-existence check entirely.
261
+ */
262
+ function readExistingPolicyForPreservation(targetDir) {
263
+ const policyPath = path.join(targetDir, REA_DIR, POLICY_FILE);
264
+ if (!fs.existsSync(policyPath))
265
+ return undefined;
266
+ try {
267
+ const raw = fs.readFileSync(policyPath, 'utf8');
268
+ const out = {};
269
+ // Profile (informational; used for stderr advisory).
270
+ const pm = raw.match(/^profile:\s*['"]?([a-z0-9-]+)['"]?\s*$/m);
271
+ if (pm)
272
+ out.profile = pm[1];
273
+ // Autonomy + ceiling (enum).
274
+ const am = raw.match(/^autonomy_level:\s*(L[0-3])\s*$/m);
275
+ const amVal = am?.[1];
276
+ if (amVal !== undefined && Object.values(AutonomyLevel).includes(amVal)) {
277
+ out.autonomyLevel = amVal;
278
+ }
279
+ const mm = raw.match(/^max_autonomy_level:\s*(L[0-3])\s*$/m);
280
+ const mmVal = mm?.[1];
281
+ if (mmVal !== undefined && Object.values(AutonomyLevel).includes(mmVal)) {
282
+ out.maxAutonomyLevel = mmVal;
283
+ }
284
+ // block_ai_attribution.
285
+ const bm = raw.match(/^block_ai_attribution:\s*(true|false)\s*$/m);
286
+ if (bm?.[1] !== undefined)
287
+ out.blockAiAttribution = bm[1] === 'true';
288
+ // blocked_paths block-sequence — line-by-line scan.
289
+ const bpStart = raw.match(/^blocked_paths:\s*$/m);
290
+ if (bpStart) {
291
+ const after = raw.slice((bpStart.index ?? 0) + bpStart[0].length + 1);
292
+ const lines = after.split('\n');
293
+ const collected = [];
294
+ for (const line of lines) {
295
+ const m2 = line.match(/^\s*-\s+(?:['"]([^'"]+)['"]|(\S.*?))\s*$/);
296
+ if (!m2)
297
+ break;
298
+ const v = m2[1] ?? m2[2];
299
+ if (v !== undefined)
300
+ collected.push(v);
301
+ }
302
+ if (collected.length > 0)
303
+ out.blockedPaths = collected;
304
+ }
305
+ // notification_channel.
306
+ const nm = raw.match(/^notification_channel:\s*['"]?([^'"\n]*)['"]?\s*$/m);
307
+ if (nm?.[1] !== undefined)
308
+ out.notificationChannel = nm[1];
309
+ // review.codex_required (under nested `review:` block).
310
+ const cm = raw.match(/^\s+codex_required:\s*(true|false)\s*$/m);
311
+ if (cm?.[1] !== undefined)
312
+ out.codexRequired = cm[1] === 'true';
313
+ return out;
314
+ }
315
+ catch {
316
+ return undefined;
317
+ }
318
+ }
236
319
  function readExistingInstalledAt(policyPath) {
237
320
  try {
238
321
  if (!fs.existsSync(policyPath))
@@ -480,30 +563,58 @@ export async function runInit(options) {
480
563
  }
481
564
  }
482
565
  const layeredBase = resolveLayered(profileName, reagentTranslated);
566
+ // 0.21.1: preserve user-mutable policy values across re-init (idempotency
567
+ // class — same as the `installed_at` fix from 0.17.0). Pre-fix, every
568
+ // `rea init` re-applied profile defaults, silently resetting an
569
+ // operator's `autonomy_level: L2` back to the profile's L1, etc.
570
+ // Read the existing policy if present and merge: explicit existing
571
+ // value wins over profile default. Operator opts out with --force
572
+ // (existing flag — bypass the file-existence check entirely).
573
+ // Profile-switch case: when the existing profile name disagrees with
574
+ // the requested profile, the existing values are STILL preserved by
575
+ // default but a stderr advisory names what was kept; operator can
576
+ // pass --force to fully reset.
577
+ const existingPolicy = readExistingPolicyForPreservation(targetDir);
483
578
  let config;
484
579
  if (options.yes === true) {
485
580
  // G11.4 non-interactive codex resolution:
486
581
  // 1. Explicit --codex / --no-codex flag wins.
487
- // 2. Otherwise derive from the profile name (`*-no-codex` false).
582
+ // 2. Otherwise existing policy value wins (preserves operator edit).
583
+ // 3. Otherwise derive from the profile name (`*-no-codex` → false).
488
584
  const codexRequired = options.codex !== undefined
489
585
  ? options.codex
490
- : profileDefaultCodexRequired(profileName);
586
+ : (existingPolicy?.codexRequired ?? profileDefaultCodexRequired(profileName));
491
587
  config = {
492
588
  profile: profileName,
493
- autonomyLevel: layeredBase.autonomy_level ?? AutonomyLevel.L1,
494
- maxAutonomyLevel: layeredBase.max_autonomy_level ?? AutonomyLevel.L2,
495
- blockAiAttribution: layeredBase.block_ai_attribution ?? true,
496
- blockedPaths: layeredBase.blocked_paths ?? ['.env', '.env.*'],
497
- notificationChannel: layeredBase.notification_channel ?? '',
589
+ autonomyLevel: existingPolicy?.autonomyLevel
590
+ ?? layeredBase.autonomy_level
591
+ ?? AutonomyLevel.L1,
592
+ maxAutonomyLevel: existingPolicy?.maxAutonomyLevel
593
+ ?? layeredBase.max_autonomy_level
594
+ ?? AutonomyLevel.L2,
595
+ blockAiAttribution: existingPolicy?.blockAiAttribution
596
+ ?? layeredBase.block_ai_attribution
597
+ ?? true,
598
+ blockedPaths: existingPolicy?.blockedPaths
599
+ ?? layeredBase.blocked_paths
600
+ ?? ['.env', '.env.*'],
601
+ notificationChannel: existingPolicy?.notificationChannel
602
+ ?? layeredBase.notification_channel
603
+ ?? '',
498
604
  codexRequired,
499
605
  fromReagent,
500
606
  reagentPolicyPath,
501
607
  reagentNotices,
502
608
  };
503
- log(`Non-interactive init: profile=${profileName}, autonomy=${config.autonomyLevel}, max=${config.maxAutonomyLevel}, attribution-block=${config.blockAiAttribution}, codex_required=${config.codexRequired}`);
609
+ if (existingPolicy !== undefined) {
610
+ log(`Non-interactive init (re-run): preserving existing autonomy=${config.autonomyLevel}, max=${config.maxAutonomyLevel}, attribution-block=${config.blockAiAttribution}, codex_required=${config.codexRequired}. Pass --force to reset to profile defaults.`);
611
+ }
612
+ else {
613
+ log(`Non-interactive init: profile=${profileName}, autonomy=${config.autonomyLevel}, max=${config.maxAutonomyLevel}, attribution-block=${config.blockAiAttribution}, codex_required=${config.codexRequired}`);
614
+ }
504
615
  }
505
616
  else {
506
- config = await runWizard(options, targetDir, reagentPolicyPath, layeredBase);
617
+ config = await runWizard(options, targetDir, reagentPolicyPath, layeredBase, existingPolicy);
507
618
  config.reagentNotices = reagentNotices;
508
619
  }
509
620
  if (!fs.existsSync(reaDir))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
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)",