@bookedsolid/rea 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,853 @@
1
+ /**
2
+ * `rea init` self-pin (0.49.0).
3
+ *
4
+ * # Problem
5
+ *
6
+ * `rea init` writes hook shims under `.claude/hooks/` that depend on the
7
+ * `@bookedsolid/rea` CLI being resolvable from `node_modules/`. The
8
+ * pre-0.49.0 init flow did NOT add the dep to the consumer's
9
+ * `package.json`, so any fresh clone + `pnpm install` produced a repo
10
+ * where the shims found no CLI and (correctly) refused every Bash
11
+ * call — including the very `pnpm add -D @bookedsolid/rea` that would
12
+ * recover the install. The bash-gate bootstrap allowlist (Fix B) is the
13
+ * paired safety net; this module is the structural fix.
14
+ *
15
+ * # Contract
16
+ *
17
+ * - Caret-pinned (`^<current-CLI-version>`) entry in `devDependencies`.
18
+ * - Lands in workspace ROOT `package.json`. Walks up from `targetDir`
19
+ * until a `package.json` is found; refuses to mutate a parent that
20
+ * the operator did not explicitly target (we only mutate when the
21
+ * FIRST `package.json` we find IS the target).
22
+ * - Existing different version: warn + skip (do NOT mutate). The
23
+ * operator owns their pin.
24
+ * - Idempotent: re-runs are byte-identical when the pin already
25
+ * matches. Detects and preserves indent / EOL / trailing-newline so
26
+ * no spurious diff churn lands in the consumer's repo.
27
+ * - Dogfood short-circuit: when the consumer's `pkg.name` is
28
+ * `@bookedsolid/rea` itself, skip silently — the dogfood install
29
+ * pins the version via the build, not via the manifest.
30
+ *
31
+ * # Why caret
32
+ *
33
+ * A caret pin (`^0.49.0` → satisfies 0.49.x AND 0.50.0+) gives
34
+ * consumers automatic minor-version uptake without breaking when the
35
+ * shim ABI bumps. Major bumps remain a deliberate operator action.
36
+ *
37
+ * # Why warn-and-skip on existing different version
38
+ *
39
+ * Three scenarios where this matters:
40
+ *
41
+ * 1. Operator explicitly pinned an exact version (`"0.48.1"`) for
42
+ * reproducibility — `rea init` overwriting that to caret would
43
+ * silently widen their pin.
44
+ * 2. Operator is running an OLDER `rea init` against a `package.json`
45
+ * that has a NEWER `rea` pin. Downgrading the pin would brick the
46
+ * install once the operator's lockfile resolves against it.
47
+ * 3. Operator deliberately pinned a workspace-relative path (`"workspace:^"`,
48
+ * `"file:../rea"`). Replacing that with a registry pin breaks the
49
+ * monorepo wiring.
50
+ *
51
+ * Warn-and-skip preserves operator intent in all three cases.
52
+ */
53
+ import fs from 'node:fs/promises';
54
+ import fsSync from 'node:fs';
55
+ import path from 'node:path';
56
+ import semver from 'semver';
57
+ /** Package name we self-pin. */
58
+ export const REA_PACKAGE_NAME = '@bookedsolid/rea';
59
+ /**
60
+ * Look for `package.json` in the explicit target directory ONLY. No upward
61
+ * walk.
62
+ *
63
+ * P2-4 (codex round 1 / locked design): the architect's "pin lands in
64
+ * workspace root package.json (refuse to mutate parent the operator did
65
+ * not explicitly target)" rule is implemented here as a hard refusal to
66
+ * walk past `start`. Earlier revisions walked upward up to 64 directories
67
+ * — that meant `rea init` invoked from a workspace subdirectory (e.g.
68
+ * `apps/web/`) with no `package.json` of its own would silently land on
69
+ * the monorepo root's manifest and mutate it. That violates the locked
70
+ * intent: the operator picked the cwd; if there's no `package.json`
71
+ * there, we refuse rather than guessing which parent to touch.
72
+ *
73
+ * Concretely:
74
+ * - `apps/web/` with no package.json → return `null`, caller maps to
75
+ * `skipped-no-package-json` (operator-facing message says
76
+ * "no package.json in the target directory").
77
+ * - `apps/web/` with its own package.json → pin there.
78
+ * - Monorepo root with package.json → pin there.
79
+ *
80
+ * The walk-up semantics also applied to `checkSelfPinDeclaredSync` (the
81
+ * doctor brick-state detector). Same tightening — doctor reports the
82
+ * absence rather than scanning the parent chain. Operators who want
83
+ * doctor to validate a parent's pin run `rea doctor` from that parent.
84
+ */
85
+ function findPackageJson(start) {
86
+ const cur = path.resolve(start);
87
+ const candidate = path.join(cur, 'package.json');
88
+ if (fsSync.existsSync(candidate))
89
+ return candidate;
90
+ return null;
91
+ }
92
+ /**
93
+ * Detect indent / EOL / trailing-newline so re-serialization preserves
94
+ * the operator's existing formatting verbatim. JSON.stringify with a
95
+ * numeric `space` argument always emits `\n` separators, so we
96
+ * post-process to apply CRLF when the source used it.
97
+ */
98
+ function detectShape(raw) {
99
+ // EOL: take the first newline pair we encounter. Default to LF.
100
+ let eol = '\n';
101
+ const lf = raw.indexOf('\n');
102
+ if (lf > 0 && raw[lf - 1] === '\r')
103
+ eol = '\r\n';
104
+ // Indent: find the first line that begins with a space-or-tab and
105
+ // count the leading whitespace. Falls back to 2 (the package.json
106
+ // convention) when the file has no nested indentation visible.
107
+ let indent = 2;
108
+ const lines = raw.split(/\r?\n/);
109
+ for (const line of lines) {
110
+ const m = /^(\t+|[ ]+)\S/.exec(line);
111
+ if (!m)
112
+ continue;
113
+ const ws = m[1] ?? '';
114
+ if (ws.startsWith('\t')) {
115
+ indent = 1; // tab-indented; JSON.stringify will use `\t` when we pass it directly
116
+ }
117
+ else {
118
+ indent = ws.length;
119
+ }
120
+ break;
121
+ }
122
+ // Clamp to a sensible range. 0 indent (single-line JSON) is preserved
123
+ // by the caller — we don't re-serialize when no change is needed.
124
+ if (indent < 1 || indent > 8)
125
+ indent = 2;
126
+ const trailingNewline = raw.endsWith('\n');
127
+ return { indent, eol, trailingNewline };
128
+ }
129
+ function isPlainObject(v) {
130
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
131
+ }
132
+ /**
133
+ * Strip a leading UTF-8 BOM (U+FEFF / EF BB BF) from a string, returning the
134
+ * rest. No-op when no BOM is present.
135
+ *
136
+ * Some Windows operators commit `package.json` with a leading BOM; `JSON.parse`
137
+ * rejects it (the spec says JSON.parse must error on a leading BOM). We
138
+ * silently strip it before parse — npm and pnpm both tolerate either form
139
+ * when writing back, so dropping the BOM on save is the simpler, more
140
+ * invariant choice. The alternative (detect-and-preserve) would need an
141
+ * extra field on `FileShape` plus a re-prepend in `serialize`, and the cost
142
+ * (one operator who deliberately wanted a BOM no longer has one) is much
143
+ * lower than the cost of an unrecoverable false-positive
144
+ * `skipped-malformed-package-json` on every BOM-bearing manifest.
145
+ *
146
+ * P3-1 (codex round 1): extracted into a shared helper so the same canonical
147
+ * BOM-strip applies to BOTH `readPackageJson` (the write path used by
148
+ * `selfPinRea`) AND `checkSelfPinDeclaredSync` (the doctor brick-state
149
+ * detector). Pre-extraction, only the write path stripped — doctor would
150
+ * report `fail-malformed` for a BOM-prefixed manifest that self-pin
151
+ * tolerated fine, which is the asymmetric-fix class we explicitly want
152
+ * to defend against.
153
+ */
154
+ export function stripUtf8Bom(input) {
155
+ if (input.length > 0 && input.charCodeAt(0) === 0xfeff) {
156
+ return input.slice(1);
157
+ }
158
+ return input;
159
+ }
160
+ function checkPackageJsonShapeSync(pkgPath) {
161
+ let lst;
162
+ try {
163
+ lst = fsSync.lstatSync(pkgPath);
164
+ }
165
+ catch (e) {
166
+ // ENOENT is fine — caller handles missing pkg.json with its own
167
+ // logic. Any other error: treat as 'lstat-error' so the caller
168
+ // can fall through to existing read paths (which will surface
169
+ // the same error in a context-appropriate way).
170
+ const code = e.code;
171
+ if (code === 'ENOENT')
172
+ return 'ok';
173
+ return 'lstat-error';
174
+ }
175
+ if (lst.isSymbolicLink())
176
+ return 'symlink';
177
+ return 'ok';
178
+ }
179
+ async function checkPackageJsonShape(pkgPath) {
180
+ let lst;
181
+ try {
182
+ lst = await fs.lstat(pkgPath);
183
+ }
184
+ catch (e) {
185
+ const code = e.code;
186
+ if (code === 'ENOENT')
187
+ return 'ok';
188
+ return 'lstat-error';
189
+ }
190
+ if (lst.isSymbolicLink())
191
+ return 'symlink';
192
+ return 'ok';
193
+ }
194
+ /**
195
+ * Build the operator-facing refusal message for a symlinked
196
+ * package.json. Pulled out as a helper so `selfPinRea` (throws),
197
+ * `checkUpgradeBlockingPin` (returns block), and
198
+ * `checkSelfPinDeclaredSync` (returns fail-symlink) all surface
199
+ * IDENTICAL wording. Drift between these three surfaces would
200
+ * confuse operators reading the same diagnostic at different layers.
201
+ */
202
+ function buildSymlinkRefusalMessage(pkgPath) {
203
+ return (`rea self-pin refusing: ${pkgPath} is a symlink. ` +
204
+ `Writing through it would mutate a file outside the requested ` +
205
+ `project tree (the symlink's target). ` +
206
+ `To reconcile, either replace the symlink with a regular ` +
207
+ `package.json or run rea init/upgrade in the target directory ` +
208
+ `the symlink points to.`);
209
+ }
210
+ async function readPackageJson(pkgPath) {
211
+ let raw;
212
+ try {
213
+ raw = await fs.readFile(pkgPath, 'utf8');
214
+ }
215
+ catch {
216
+ return null;
217
+ }
218
+ // P2-3 (codex round 1): strip leading UTF-8 BOM. The original bytes
219
+ // tracked in `raw` go forward without the BOM so `detectShape` does
220
+ // not have to special-case its leading-whitespace scans either.
221
+ raw = stripUtf8Bom(raw);
222
+ let parsed;
223
+ try {
224
+ parsed = JSON.parse(raw);
225
+ }
226
+ catch {
227
+ return null;
228
+ }
229
+ if (!isPlainObject(parsed))
230
+ return null;
231
+ return { raw, parsed, shape: detectShape(raw) };
232
+ }
233
+ /**
234
+ * Format a JS object back to a JSON string honoring the detected shape.
235
+ * Always uses spaces (JSON.stringify's `space` argument). Trailing
236
+ * newline is restored if the original carried one.
237
+ *
238
+ * Indent semantics: pass the indent count directly to JSON.stringify.
239
+ * When the source was tab-indented (`indent === 1` per detectShape's
240
+ * conversion above) we still emit spaces — JSON.stringify only supports
241
+ * a string or a positive integer for indent, and supporting tabs would
242
+ * require post-processing every indent run. The cost (one operator who
243
+ * tabs their package.json sees a one-time switch to spaces) is lower
244
+ * than the risk of indent-conversion bugs.
245
+ */
246
+ /**
247
+ * Managed-caret pin shape (P1-1 / codex round 2).
248
+ *
249
+ * Matches what `selfPinRea` itself writes — a caret prefix followed by
250
+ * a 2- or 3-segment semver, with optional `-prerelease` tail. Anything
251
+ * NOT matching this shape is treated as operator-authored and
252
+ * `mode: 'upgrade'` will hands-off rather than bump.
253
+ *
254
+ * Examples that MATCH (rea-managed):
255
+ * `^0.49.0`, `^0.49`, `^1.0.0`, `^1.2.3-beta.1`
256
+ *
257
+ * Examples that DO NOT MATCH (operator-authored, hands-off):
258
+ * `workspace:^`, `workspace:*`, `file:../rea`, `git+https://...`,
259
+ * `next`, `latest`, `0.49.0` (exact pin), `~0.49.0` (tilde),
260
+ * `>=0.49.0 <0.50.0` (range), `0.49.x` (x-range), `^0.49.0 || ^0.48.0`.
261
+ *
262
+ * R4-P2 (codex round 4): the per-shape "is this a managed pin?" gate
263
+ * is still our own regex (semver doesn't have a "did rea write this?"
264
+ * concept — operator-authored carets like `^1.2.3` would also satisfy
265
+ * any semver-only shape gate, and we explicitly don't want to bump
266
+ * those without an audit-traceable rea-managed marker). The
267
+ * VERSION-SATISFIES decision uses `semver.satisfies` so prerelease
268
+ * upgrades are handled correctly: a non-prerelease range like
269
+ * `^0.49.0` does NOT satisfy `0.49.1-beta.0` (npm spec excludes
270
+ * prereleases from non-prerelease ranges); the pre-fix predicate
271
+ * compared major/minor only and treated this as already-covered,
272
+ * leaving the pin pointing at the older CLI when newer hooks shipped.
273
+ */
274
+ const MANAGED_CARET_RE = /^\^(\d+)\.(\d+)(?:\.(\d+))?(?:-[A-Za-z0-9.+-]+)?$/;
275
+ /**
276
+ * Extract the major version number from a managed-caret range string,
277
+ * or null when the input is not a managed-caret shape (in which case
278
+ * the caller hands off — operator-authored).
279
+ */
280
+ function managedCaretMajor(range) {
281
+ const m = MANAGED_CARET_RE.exec(range);
282
+ if (m === null || m[1] === undefined)
283
+ return null;
284
+ return Number(m[1]);
285
+ }
286
+ /**
287
+ * Decide whether `rea upgrade` should auto-bump an existing pin to the
288
+ * newly-written caret. Returns `true` only when:
289
+ *
290
+ * 1. `existing` matches the managed-caret shape (we wrote it; it
291
+ * isn't a workspace/file/git/tag/exact pin).
292
+ * 2. `newRange` ALSO matches the managed-caret shape (sanity — the
293
+ * function should never be called with a non-caret target, but
294
+ * the predicate stays self-contained).
295
+ * 3. Both ranges share the same major version. Cross-major bumps
296
+ * are intentional operator decisions and we hands-off.
297
+ * 4. The existing caret does NOT already admit the version the new
298
+ * range would resolve to. We extract the floor version from
299
+ * `newRange` (strip the leading `^`) and ask
300
+ * `semver.satisfies(floor, existing)`:
301
+ *
302
+ * - `^0.49.0` + `^0.49.5` → 0.49.5 satisfies ^0.49.0 → NO bump
303
+ * - `^0.49.0` + `^0.50.0` → 0.50.0 does NOT satisfy → BUMP
304
+ * - `^0.49.0` + `^0.49.1-beta.0` → prerelease does NOT satisfy
305
+ * a non-prerelease range (npm spec) → BUMP (R4-P2)
306
+ * - `^1.0.0` + `^1.5.0` → 1.5.0 satisfies ^1.0.0 → NO bump
307
+ * - `^1.0.0` + `^1.1.0-beta.0` → prerelease does NOT satisfy
308
+ * → BUMP (R4-P2)
309
+ *
310
+ * The pre-fix predicate compared major/minor by hand, which
311
+ * mis-classified prerelease bumps as already-covered. Switching to
312
+ * `semver.satisfies` gives us npm-spec-correct semver behavior in
313
+ * one place.
314
+ */
315
+ export function shouldBumpManagedCaret(existing, newRange) {
316
+ const existingMajor = managedCaretMajor(existing);
317
+ if (existingMajor === null)
318
+ return false;
319
+ const newMajor = managedCaretMajor(newRange);
320
+ if (newMajor === null)
321
+ return false;
322
+ if (existingMajor !== newMajor)
323
+ return false;
324
+ // Extract the version floor from the new range. `^X.Y.Z` floor is
325
+ // exactly `X.Y.Z` (with optional prerelease tail). Strip the `^`;
326
+ // semver.satisfies handles the rest. We do not use `semver.minVersion`
327
+ // here because for prerelease shapes (`^0.49.1-beta.0`) its result
328
+ // is `0.49.1-beta.0` — same as a manual strip — but for our managed
329
+ // shape the strip is unambiguous and avoids the cross-version
330
+ // semver quirks.
331
+ const newFloor = newRange.startsWith('^') ? newRange.slice(1) : newRange;
332
+ // Coerce-via-parse to validate the floor is a real semver; if not,
333
+ // we cannot reason about it and hands-off.
334
+ if (semver.valid(newFloor) === null)
335
+ return false;
336
+ // R9-P1 (codex round 9 / 0.49.0): hands-off on downgrades. The
337
+ // managed-caret bump is an UPGRADE primitive — when the existing
338
+ // caret pins a HIGHER floor than the new CLI version, the operator
339
+ // explicitly pinned newer-than-us (deliberate forward pin) and we
340
+ // must not silently rewrite their pin DOWN to an older floor. The
341
+ // R9-P1 abort gate in `runUpgrade` handles this case separately:
342
+ // it sees `shouldBumpManagedCaret = false` AND `existing range
343
+ // does not admit new CLI`, then blocks the upgrade with a clear
344
+ // operator-actionable message rather than silently downgrading.
345
+ const existingFloor = existing.startsWith('^') ? existing.slice(1) : existing;
346
+ if (semver.valid(existingFloor) !== null && semver.lt(newFloor, existingFloor)) {
347
+ return false;
348
+ }
349
+ // `includePrerelease: false` is semver's default and what we want:
350
+ // a non-prerelease range like `^0.49.0` does NOT include
351
+ // `0.49.1-beta.0`, so the !satisfies branch correctly returns true
352
+ // and we bump.
353
+ return !semver.satisfies(newFloor, existing);
354
+ }
355
+ /**
356
+ * Determine whether the existing `@bookedsolid/rea` pin would block
357
+ * `rea init` / `rea upgrade` from writing 0.49 artifacts safely.
358
+ * See type doc.
359
+ *
360
+ * R11-P1 (codex round 11): the check applies to BOTH `rea init` and
361
+ * `rea upgrade`. The implementation is unchanged; only the
362
+ * operator-facing message prefix varies with `mode`.
363
+ */
364
+ export async function checkUpgradeBlockingPin(options) {
365
+ const newCliVersion = options.cliVersion;
366
+ const newPinnedRange = `^${newCliVersion}`;
367
+ const cmdName = options.mode === 'init' ? 'rea init' : 'rea upgrade';
368
+ const pkgPath = findPackageJson(options.cwd);
369
+ if (pkgPath === null)
370
+ return { kind: 'no-pkg-json' };
371
+ // R10-P2 (codex round 10): symlink check BEFORE `readPackageJson`
372
+ // so the parser never touches the symlink target. Even on the
373
+ // read-only preview path, surfacing this as a block is the right
374
+ // signal to the operator — `selfPinRea`'s write path would throw
375
+ // moments later anyway, and the upgrade preflight needs to refuse
376
+ // before any artifact-writing step.
377
+ if (checkPackageJsonShapeSync(pkgPath) === 'symlink') {
378
+ return {
379
+ kind: 'block-symlink',
380
+ packageJsonPath: pkgPath,
381
+ newCliVersion,
382
+ newPinnedRange,
383
+ reason: buildSymlinkRefusalMessage(pkgPath),
384
+ };
385
+ }
386
+ const pkg = await readPackageJson(pkgPath);
387
+ if (pkg === null)
388
+ return { kind: 'malformed-pkg-json', packageJsonPath: pkgPath };
389
+ if (pkg.parsed['name'] === REA_PACKAGE_NAME) {
390
+ return { kind: 'dogfood', packageJsonPath: pkgPath };
391
+ }
392
+ const deps = isPlainObject(pkg.parsed['dependencies']) ? pkg.parsed['dependencies'] : null;
393
+ const devDeps = isPlainObject(pkg.parsed['devDependencies'])
394
+ ? pkg.parsed['devDependencies']
395
+ : null;
396
+ const existingDep = deps !== null && typeof deps[REA_PACKAGE_NAME] === 'string'
397
+ ? deps[REA_PACKAGE_NAME]
398
+ : undefined;
399
+ const existingDevDep = devDeps !== null && typeof devDeps[REA_PACKAGE_NAME] === 'string'
400
+ ? devDeps[REA_PACKAGE_NAME]
401
+ : undefined;
402
+ // Authoritative pin (matches `selfPinRea`'s precedence).
403
+ const existing = existingDep ?? existingDevDep;
404
+ if (existing === undefined) {
405
+ return { kind: 'ok', packageJsonPath: pkgPath };
406
+ }
407
+ // Exact match — pin already at the new version. No skew possible.
408
+ if (existing === newPinnedRange) {
409
+ return { kind: 'ok', packageJsonPath: pkgPath, existingRange: existing };
410
+ }
411
+ // Managed-caret bumpable — `selfPinRea` will rewrite the pin in
412
+ // place; no skew once the upgrade completes.
413
+ if (shouldBumpManagedCaret(existing, newPinnedRange)) {
414
+ return { kind: 'ok', packageJsonPath: pkgPath, existingRange: existing };
415
+ }
416
+ // Does the existing pin admit the new CLI version?
417
+ // - Pin is a valid semver range AND semver.satisfies(newCliVersion, existing)
418
+ // → ok (e.g. `^0.49.0` admits `0.49.5`).
419
+ // - Pin is NOT a valid semver range (workspace:*, file:.., git URL,
420
+ // dist-tag like `next`) → semver.validRange returns null →
421
+ // we cannot statically determine admittance → block on uncertainty.
422
+ // The operator can re-run after editing the manifest.
423
+ let admits = false;
424
+ if (semver.validRange(existing) !== null) {
425
+ admits = semver.satisfies(newCliVersion, existing);
426
+ }
427
+ if (admits) {
428
+ return { kind: 'ok', packageJsonPath: pkgPath, existingRange: existing };
429
+ }
430
+ // Build the operator-facing reason. Customize the third bullet for
431
+ // workspace / file / git / tag shapes — those need a workspace-
432
+ // specific fix path.
433
+ const isWorkspacePin = existing.startsWith('workspace:');
434
+ const isFilePin = existing.startsWith('file:');
435
+ const isGitPin = existing.startsWith('git') ||
436
+ existing.startsWith('github:') ||
437
+ existing.startsWith('gitlab:') ||
438
+ existing.startsWith('bitbucket:') ||
439
+ /^https?:\/\//.test(existing);
440
+ const isLooksDistTag = semver.validRange(existing) === null && !isWorkspacePin && !isFilePin && !isGitPin;
441
+ const lines = [
442
+ `${cmdName} refusing: package.json pins ${REA_PACKAGE_NAME} to "${existing}"`,
443
+ `which does not admit the installed CLI version ${newCliVersion}.`,
444
+ '',
445
+ `Writing ${newCliVersion} hooks/policy artifacts now would create a hook/CLI skew:`,
446
+ `your shims would resolve the older CLI from node_modules, which cannot`,
447
+ `parse the new policy.yaml schema (strict).`,
448
+ '',
449
+ // R12-P2 (codex round 12): recommend the bare-spec form. The
450
+ // CLI-missing bootstrap allowlist (hooks/_lib/bootstrap-allowlist.sh)
451
+ // accepts ONLY `pnpm add -D @bookedsolid/rea` — bare. Version-
452
+ // pinned `@bookedsolid/rea@^X.Y.Z` forms are REFUSED at the bash
453
+ // gate (R6-P2 lock — security: prevents attacker version-pin
454
+ // downgrade in the CLI-missing state). If we recommended the
455
+ // version-pinned form here, agents running the diagnostic-
456
+ // suggested command would loop forever — the bash gate refuses
457
+ // the very recovery command we printed. The bare-spec form
458
+ // installs the latest version matching the consumer's existing
459
+ // constraint (or absolute latest if no constraint); a follow-up
460
+ // `${cmdName}` then runs the managed-caret bump (R2-P1-1) to
461
+ // set the canonical pin under audit.
462
+ 'To reconcile, choose one:',
463
+ ` 1. pnpm add -D ${REA_PACKAGE_NAME} (installs latest within range,`,
464
+ ` then re-run: ${cmdName})`,
465
+ ` 2. Edit package.json to pin a version that admits ${newCliVersion},`,
466
+ ' then: pnpm install',
467
+ ];
468
+ if (isWorkspacePin) {
469
+ lines.push(` 3. workspace:* points to a sibling package; ensure that package's`, ` version admits ${newCliVersion} before re-running ${cmdName}`);
470
+ }
471
+ else if (isFilePin) {
472
+ lines.push(` 3. file: pins to a local path; ensure the linked package's`, ` version admits ${newCliVersion} before re-running ${cmdName}`);
473
+ }
474
+ else if (isGitPin) {
475
+ lines.push(` 3. git URL pins to a remote ref; ensure the target ref's`, ` package version admits ${newCliVersion} before re-running ${cmdName}`);
476
+ }
477
+ else if (isLooksDistTag) {
478
+ lines.push(` 3. dist-tag "${existing}" resolves at install time; ensure the`, ` tag currently points at a version that admits ${newCliVersion}`);
479
+ }
480
+ else {
481
+ lines.push(` 3. If using workspace:* or file:.., ensure the workspace target`, ` resolves to >= ${newCliVersion} before re-running ${cmdName}`);
482
+ }
483
+ lines.push('', `Then re-run: ${cmdName}`);
484
+ return {
485
+ kind: 'block',
486
+ packageJsonPath: pkgPath,
487
+ existingRange: existing,
488
+ newCliVersion,
489
+ newPinnedRange,
490
+ reason: lines.join('\n'),
491
+ };
492
+ }
493
+ function serialize(obj, shape) {
494
+ let s = JSON.stringify(obj, null, shape.indent);
495
+ if (shape.eol === '\r\n') {
496
+ s = s.replace(/\n/g, '\r\n');
497
+ }
498
+ if (shape.trailingNewline) {
499
+ s += shape.eol;
500
+ }
501
+ return s;
502
+ }
503
+ /**
504
+ * Idempotent self-pin step. See module header for the full contract.
505
+ */
506
+ export async function selfPinRea(options) {
507
+ const pinnedRange = `^${options.cliVersion}`;
508
+ const pkgPath = findPackageJson(options.cwd);
509
+ if (pkgPath === null) {
510
+ return {
511
+ action: 'skipped-no-package-json',
512
+ packageJsonPath: null,
513
+ pinnedRange,
514
+ message: `self-pin skipped — no package.json in the target directory ${options.cwd}. ` +
515
+ `rea init refuses to walk up and mutate a parent the operator did not ` +
516
+ `explicitly target — re-invoke from the workspace root if that is the ` +
517
+ `intent.`,
518
+ };
519
+ }
520
+ // R10-P2 (codex round 10): refuse symlinked package.json BEFORE
521
+ // any read or write. Writing through a symlink mutates the target
522
+ // — typically outside the requested project tree — which violates
523
+ // the R2-P4 "don't mutate a parent the operator did not target"
524
+ // contract.
525
+ //
526
+ // Live mode THROWS — security refusals must surface, not silently
527
+ // no-op. Dry-run mode RETURNS a `skipped-symlink-package-json`
528
+ // skip-shape so `rea upgrade --dry-run` can complete a full preview
529
+ // even when the symlink would block the live run. The upgrade
530
+ // pre-flight (`checkUpgradeBlockingPin`) already surfaced a
531
+ // `block-symlink` to the operator before reaching this code path,
532
+ // so dry-run consumers see the diagnostic ONCE at the pre-flight
533
+ // and the downstream `selfPinRea({ dryRun: true })` call simply
534
+ // reports "skipped — symlinked" without re-throwing.
535
+ if ((await checkPackageJsonShape(pkgPath)) === 'symlink') {
536
+ const message = buildSymlinkRefusalMessage(pkgPath);
537
+ if (options.dryRun === true) {
538
+ return {
539
+ action: 'skipped-symlink-package-json',
540
+ packageJsonPath: pkgPath,
541
+ pinnedRange,
542
+ message,
543
+ };
544
+ }
545
+ throw new Error(message);
546
+ }
547
+ const pkg = await readPackageJson(pkgPath);
548
+ if (pkg === null) {
549
+ return {
550
+ action: 'skipped-malformed-package-json',
551
+ packageJsonPath: pkgPath,
552
+ pinnedRange,
553
+ message: `self-pin skipped — ${pkgPath} is missing or not a valid JSON object`,
554
+ };
555
+ }
556
+ // Dogfood short-circuit: pkg.name === '@bookedsolid/rea'.
557
+ if (pkg.parsed['name'] === REA_PACKAGE_NAME) {
558
+ return {
559
+ action: 'skipped-dogfood',
560
+ packageJsonPath: pkgPath,
561
+ pinnedRange,
562
+ message: `self-pin skipped — this IS @bookedsolid/rea (dogfood)`,
563
+ };
564
+ }
565
+ // Conflict check: where does `@bookedsolid/rea` currently live?
566
+ // We look in dependencies + devDependencies — those are the surfaces
567
+ // the bootstrap allowlist (Fix B) accepts. We do NOT look in
568
+ // peerDependencies / optionalDependencies / pnpm.overrides — those
569
+ // are NOT bootstrap declarations and inserting our pin there would
570
+ // silently shift the contract.
571
+ const deps = isPlainObject(pkg.parsed['dependencies']) ? pkg.parsed['dependencies'] : null;
572
+ const devDeps = isPlainObject(pkg.parsed['devDependencies'])
573
+ ? pkg.parsed['devDependencies']
574
+ : null;
575
+ const existingDep = deps !== null && typeof deps[REA_PACKAGE_NAME] === 'string'
576
+ ? deps[REA_PACKAGE_NAME]
577
+ : undefined;
578
+ const existingDevDep = devDeps !== null && typeof devDeps[REA_PACKAGE_NAME] === 'string'
579
+ ? devDeps[REA_PACKAGE_NAME]
580
+ : undefined;
581
+ // P1-1 (codex round 2): upgrade-mode bump predicate. Resolved here
582
+ // once so the dependencies + devDependencies branches share the
583
+ // same shape gate. `mode: 'upgrade'` opts the caller in to auto-
584
+ // bumping a managed-caret pin when the existing range does not
585
+ // admit the new CLI minor (pre-1.0 caret = tilde, so `^0.49.0`
586
+ // does NOT admit `0.50.0` and a 0.49 → 0.50 upgrade would otherwise
587
+ // ship newer hooks against the older CLI — exactly the brick state
588
+ // this whole feature exists to prevent). `mode: 'init'` (default)
589
+ // never bumps: respects whatever pin the operator already chose.
590
+ const mode = options.mode ?? 'init';
591
+ const allowBump = mode === 'upgrade';
592
+ // R3-P2: dry-run prefixes "would " on the mutation messages so the
593
+ // operator-visible string is unambiguous. The `action` discriminant
594
+ // itself is identical between dry-run and live run — callers that
595
+ // pattern-match on `action` see the same value either way; only the
596
+ // message and the absence of an on-disk write differ.
597
+ const dryRun = options.dryRun ?? false;
598
+ const wroteVerb = dryRun ? 'would add' : 'added';
599
+ const bumpedVerb = dryRun ? 'would bump' : 'bumped';
600
+ // A dep present in `dependencies` AND `devDependencies` is unusual
601
+ // but legal. We treat the `dependencies` value as authoritative for
602
+ // conflict detection (npm install resolves it that way) and refuse
603
+ // to mutate the more constrained surface. Operator owns it.
604
+ if (existingDep !== undefined) {
605
+ if (existingDep === pinnedRange) {
606
+ return {
607
+ action: 'skipped-same',
608
+ packageJsonPath: pkgPath,
609
+ pinnedRange,
610
+ existingRange: existingDep,
611
+ message: `self-pin: ${REA_PACKAGE_NAME} already pinned in dependencies as ${existingDep}`,
612
+ };
613
+ }
614
+ if (allowBump && shouldBumpManagedCaret(existingDep, pinnedRange)) {
615
+ await writePin(pkgPath, pkg, 'dependencies', pinnedRange, deps, devDeps, dryRun);
616
+ return {
617
+ action: 'bumped',
618
+ packageJsonPath: pkgPath,
619
+ pinnedRange,
620
+ existingRange: existingDep,
621
+ message: `self-pin: ${bumpedVerb} ${REA_PACKAGE_NAME} pin in dependencies from ` +
622
+ `${existingDep} to ${pinnedRange} (managed-caret upgrade)`,
623
+ };
624
+ }
625
+ return {
626
+ action: 'skipped-different',
627
+ packageJsonPath: pkgPath,
628
+ pinnedRange,
629
+ existingRange: existingDep,
630
+ message: `self-pin: ${REA_PACKAGE_NAME} already pinned in dependencies as ${existingDep} ` +
631
+ `(different from ${pinnedRange}) — leaving operator's pin intact`,
632
+ };
633
+ }
634
+ if (existingDevDep !== undefined) {
635
+ if (existingDevDep === pinnedRange) {
636
+ return {
637
+ action: 'skipped-same',
638
+ packageJsonPath: pkgPath,
639
+ pinnedRange,
640
+ existingRange: existingDevDep,
641
+ message: `self-pin: ${REA_PACKAGE_NAME} already pinned in devDependencies as ${existingDevDep}`,
642
+ };
643
+ }
644
+ if (allowBump && shouldBumpManagedCaret(existingDevDep, pinnedRange)) {
645
+ await writePin(pkgPath, pkg, 'devDependencies', pinnedRange, deps, devDeps, dryRun);
646
+ return {
647
+ action: 'bumped',
648
+ packageJsonPath: pkgPath,
649
+ pinnedRange,
650
+ existingRange: existingDevDep,
651
+ message: `self-pin: ${bumpedVerb} ${REA_PACKAGE_NAME} pin in devDependencies from ` +
652
+ `${existingDevDep} to ${pinnedRange} (managed-caret upgrade)`,
653
+ };
654
+ }
655
+ return {
656
+ action: 'skipped-different',
657
+ packageJsonPath: pkgPath,
658
+ pinnedRange,
659
+ existingRange: existingDevDep,
660
+ message: `self-pin: ${REA_PACKAGE_NAME} already pinned in devDependencies as ${existingDevDep} ` +
661
+ `(different from ${pinnedRange}) — leaving operator's pin intact`,
662
+ };
663
+ }
664
+ // No existing pin — write a new one into devDependencies.
665
+ const result = await writePin(pkgPath, pkg, 'devDependencies', pinnedRange, deps, devDeps, dryRun);
666
+ if (result.newRaw === pkg.raw) {
667
+ return {
668
+ action: 'skipped-same',
669
+ packageJsonPath: pkgPath,
670
+ pinnedRange,
671
+ existingRange: pinnedRange,
672
+ message: `self-pin: ${REA_PACKAGE_NAME} already pinned at ${pinnedRange} (byte-identical)`,
673
+ };
674
+ }
675
+ return {
676
+ action: 'wrote',
677
+ packageJsonPath: pkgPath,
678
+ pinnedRange,
679
+ message: `self-pin: ${wroteVerb} ${REA_PACKAGE_NAME}@${pinnedRange} to devDependencies in ${pkgPath}`,
680
+ };
681
+ }
682
+ /**
683
+ * Write the rea pin into either `dependencies` or `devDependencies`
684
+ * (caller picks). Sorts the target dep block alphabetically (matches
685
+ * the pre-refactor behavior for devDependencies and is the common
686
+ * tooling convention for both). Preserves top-level key order in the
687
+ * package.json object so the on-disk diff is minimal — JSON.stringify
688
+ * honors insertion order in V8.
689
+ *
690
+ * Returns `{ newRaw }` so the caller can compare against
691
+ * `pkg.raw` for the byte-fidelity guard.
692
+ *
693
+ * P1-1 (codex round 2): extracted as a shared helper so the new-write
694
+ * path and the upgrade-mode bump path use the same serializer. Pre-
695
+ * extraction the bump path would have needed duplicated logic.
696
+ */
697
+ async function writePin(pkgPath, pkg, target, pinnedRange, deps, devDeps, dryRun) {
698
+ const baseBlock = target === 'dependencies' ? deps : devDeps;
699
+ const newBlock = { ...(baseBlock ?? {}), [REA_PACKAGE_NAME]: pinnedRange };
700
+ const sortedKeys = Object.keys(newBlock).sort();
701
+ const sortedBlock = {};
702
+ for (const k of sortedKeys)
703
+ sortedBlock[k] = newBlock[k];
704
+ const out = { ...pkg.parsed, [target]: sortedBlock };
705
+ const newRaw = serialize(out, pkg.shape);
706
+ // R3-P2: skip the write when previewing. The caller still gets the
707
+ // `newRaw` value back so the byte-fidelity guard ("did this even
708
+ // change?") works identically in both modes.
709
+ if (!dryRun && newRaw !== pkg.raw) {
710
+ await fs.writeFile(pkgPath, newRaw, 'utf8');
711
+ }
712
+ return { newRaw };
713
+ }
714
+ export async function checkSelfPinDeclared(baseDir, cliVersion) {
715
+ return checkSelfPinDeclaredSync(baseDir, cliVersion);
716
+ }
717
+ /**
718
+ * Synchronous variant of {@link checkSelfPinDeclared}. `rea doctor`
719
+ * runs all checks sync; the read+parse cost here is microseconds so
720
+ * the sync form is acceptable.
721
+ *
722
+ * R11-P3 (codex round 11): when `cliVersion` is provided, this check
723
+ * also verifies that the declared range admits the running CLI
724
+ * version (semver.satisfies). Without `cliVersion` the check
725
+ * reverts to presence-only behavior (backwards-compat for callers
726
+ * that don't yet pass the version). The doctor wrapper
727
+ * (`checkSelfPinDeclaredCheck`) passes `getPkgVersion()` so the
728
+ * skew detection always runs in the doctor surface.
729
+ */
730
+ export function checkSelfPinDeclaredSync(baseDir, cliVersion) {
731
+ const hooksDir = path.join(baseDir, '.claude', 'hooks');
732
+ if (!fsSync.existsSync(hooksDir)) {
733
+ return { kind: 'pass-no-hooks' };
734
+ }
735
+ const pkgPath = findPackageJson(baseDir);
736
+ if (pkgPath === null) {
737
+ return { kind: 'pass-no-pkg', hooksDir };
738
+ }
739
+ // R10-P2 (codex round 10): mirror the write-path's symlink refusal
740
+ // here so doctor surfaces the same diagnostic. Operators get
741
+ // told about the misconfiguration BEFORE running `rea upgrade`,
742
+ // which would throw at the symlink check otherwise.
743
+ if (checkPackageJsonShapeSync(pkgPath) === 'symlink') {
744
+ return {
745
+ kind: 'fail-symlink',
746
+ packageJsonPath: pkgPath,
747
+ reason: buildSymlinkRefusalMessage(pkgPath),
748
+ };
749
+ }
750
+ let raw;
751
+ try {
752
+ raw = fsSync.readFileSync(pkgPath, 'utf8');
753
+ }
754
+ catch {
755
+ return { kind: 'fail-malformed', packageJsonPath: pkgPath };
756
+ }
757
+ // P3-1 (codex round 1): tolerate a leading UTF-8 BOM the same way
758
+ // the write path does — otherwise doctor reports `fail-malformed`
759
+ // on Windows-authored manifests that selfPinRea handles fine.
760
+ raw = stripUtf8Bom(raw);
761
+ let parsed;
762
+ try {
763
+ parsed = JSON.parse(raw);
764
+ }
765
+ catch {
766
+ return { kind: 'fail-malformed', packageJsonPath: pkgPath };
767
+ }
768
+ if (!isPlainObject(parsed)) {
769
+ return { kind: 'fail-malformed', packageJsonPath: pkgPath };
770
+ }
771
+ const pkg = { raw, parsed, shape: detectShape(raw) };
772
+ if (pkg.parsed['name'] === REA_PACKAGE_NAME) {
773
+ return { kind: 'pass-dogfood', packageJsonPath: pkgPath };
774
+ }
775
+ const deps = isPlainObject(pkg.parsed['dependencies']) ? pkg.parsed['dependencies'] : null;
776
+ const devDeps = isPlainObject(pkg.parsed['devDependencies'])
777
+ ? pkg.parsed['devDependencies']
778
+ : null;
779
+ // Resolve the authoritative pin + its location (deps wins over
780
+ // devDeps, matching `selfPinRea`'s precedence).
781
+ let declaredRange;
782
+ let declaredIn;
783
+ if (deps !== null && typeof deps[REA_PACKAGE_NAME] === 'string') {
784
+ declaredRange = deps[REA_PACKAGE_NAME];
785
+ declaredIn = 'dependencies';
786
+ }
787
+ else if (devDeps !== null && typeof devDeps[REA_PACKAGE_NAME] === 'string') {
788
+ declaredRange = devDeps[REA_PACKAGE_NAME];
789
+ declaredIn = 'devDependencies';
790
+ }
791
+ if (declaredRange === undefined || declaredIn === undefined) {
792
+ return { kind: 'fail', packageJsonPath: pkgPath, hooksDir };
793
+ }
794
+ // R11-P3 (codex round 11): pin-compatibility check. When the
795
+ // caller passed a `cliVersion`, run semver.satisfies against the
796
+ // declared range. Non-semver shapes (workspace:*, file:.., git,
797
+ // dist-tag) cannot be resolved statically — surface a dedicated
798
+ // `fail-non-semver` so the operator audits the resolution path
799
+ // rather than seeing a misleading `pass`.
800
+ if (cliVersion !== undefined) {
801
+ const validRange = semver.validRange(declaredRange);
802
+ if (validRange === null) {
803
+ return {
804
+ kind: 'fail-non-semver',
805
+ packageJsonPath: pkgPath,
806
+ declaredRange,
807
+ declaredIn,
808
+ reason: `Self-pin declared as a non-semver shape: ${REA_PACKAGE_NAME} pinned to ` +
809
+ `"${declaredRange}" in ${declaredIn}.\n\n` +
810
+ `rea doctor cannot statically determine whether the resolved version admits the\n` +
811
+ `installed CLI version ${cliVersion}. Workspace, file:, git URL, and dist-tag\n` +
812
+ `pins resolve at install time — if the resolved version does not admit ${cliVersion},\n` +
813
+ `your hook scripts will resolve the older CLI from node_modules and may fail to\n` +
814
+ `parse the current policy.yaml schema.\n\n` +
815
+ // R12-P2 (codex round 12): bare-spec form only. The
816
+ // CLI-missing bash gate refuses version-pinned adds.
817
+ `To reconcile, either:\n` +
818
+ ` 1. Replace the pin with pnpm add -D ${REA_PACKAGE_NAME}\n` +
819
+ ` (installs latest within range, then re-run: rea upgrade)\n` +
820
+ ` 2. Verify the resolved version admits ${cliVersion} (check node_modules/${REA_PACKAGE_NAME}/package.json)`,
821
+ };
822
+ }
823
+ // semver.satisfies with includePrerelease so a 0.49.0-beta.0
824
+ // running CLI passes against `^0.49.0` (otherwise a prerelease
825
+ // would fail-incompatible against its own non-prerelease range).
826
+ const admits = semver.satisfies(cliVersion, validRange, { includePrerelease: true });
827
+ if (!admits) {
828
+ return {
829
+ kind: 'fail-incompatible',
830
+ packageJsonPath: pkgPath,
831
+ declaredRange,
832
+ declaredIn,
833
+ currentCliVersion: cliVersion,
834
+ reason: `Self-pin declared but incompatible: package.json pins ${REA_PACKAGE_NAME} to ` +
835
+ `"${declaredRange}" in ${declaredIn} which does not admit the installed CLI ` +
836
+ `version ${cliVersion}.\n\n` +
837
+ `Your hook scripts will resolve the older CLI from node_modules and may fail to\n` +
838
+ `parse the current policy.yaml schema (strict).\n\n` +
839
+ // R12-P2 (codex round 12): bare-spec form only — see the
840
+ // R9-P1 reason builder above for the full rationale.
841
+ `To reconcile:\n` +
842
+ ` pnpm add -D ${REA_PACKAGE_NAME} (installs latest within range,\n` +
843
+ ` then re-run: rea upgrade)`,
844
+ };
845
+ }
846
+ }
847
+ return {
848
+ kind: 'pass',
849
+ packageJsonPath: pkgPath,
850
+ declaredRange,
851
+ declaredIn,
852
+ };
853
+ }