@bookedsolid/rea 0.1.0 → 0.2.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 (90) hide show
  1. package/.husky/commit-msg +130 -0
  2. package/.husky/pre-push +128 -0
  3. package/README.md +5 -5
  4. package/agents/codex-adversarial.md +23 -8
  5. package/commands/codex-review.md +2 -2
  6. package/dist/audit/append.d.ts +62 -0
  7. package/dist/audit/append.js +189 -0
  8. package/dist/audit/codex-event.d.ts +28 -0
  9. package/dist/audit/codex-event.js +15 -0
  10. package/dist/cli/doctor.d.ts +60 -1
  11. package/dist/cli/doctor.js +459 -20
  12. package/dist/cli/index.js +35 -5
  13. package/dist/cli/init.d.ts +13 -0
  14. package/dist/cli/init.js +278 -67
  15. package/dist/cli/install/canonical.d.ts +43 -0
  16. package/dist/cli/install/canonical.js +101 -0
  17. package/dist/cli/install/claude-md.d.ts +48 -0
  18. package/dist/cli/install/claude-md.js +93 -0
  19. package/dist/cli/install/commit-msg.d.ts +30 -0
  20. package/dist/cli/install/commit-msg.js +102 -0
  21. package/dist/cli/install/copy.d.ts +169 -0
  22. package/dist/cli/install/copy.js +455 -0
  23. package/dist/cli/install/fs-safe.d.ts +91 -0
  24. package/dist/cli/install/fs-safe.js +347 -0
  25. package/dist/cli/install/manifest-io.d.ts +12 -0
  26. package/dist/cli/install/manifest-io.js +44 -0
  27. package/dist/cli/install/manifest-schema.d.ts +83 -0
  28. package/dist/cli/install/manifest-schema.js +80 -0
  29. package/dist/cli/install/reagent.d.ts +59 -0
  30. package/dist/cli/install/reagent.js +160 -0
  31. package/dist/cli/install/settings-merge.d.ts +91 -0
  32. package/dist/cli/install/settings-merge.js +239 -0
  33. package/dist/cli/install/sha.d.ts +9 -0
  34. package/dist/cli/install/sha.js +21 -0
  35. package/dist/cli/serve.d.ts +11 -0
  36. package/dist/cli/serve.js +72 -6
  37. package/dist/cli/upgrade.d.ts +67 -0
  38. package/dist/cli/upgrade.js +509 -0
  39. package/dist/gateway/downstream-pool.d.ts +39 -0
  40. package/dist/gateway/downstream-pool.js +93 -0
  41. package/dist/gateway/downstream.d.ts +80 -0
  42. package/dist/gateway/downstream.js +196 -0
  43. package/dist/gateway/middleware/audit-types.d.ts +10 -0
  44. package/dist/gateway/middleware/audit.js +14 -0
  45. package/dist/gateway/middleware/injection.d.ts +59 -2
  46. package/dist/gateway/middleware/injection.js +91 -14
  47. package/dist/gateway/middleware/kill-switch.d.ts +20 -5
  48. package/dist/gateway/middleware/kill-switch.js +57 -35
  49. package/dist/gateway/middleware/redact.d.ts +83 -6
  50. package/dist/gateway/middleware/redact.js +133 -46
  51. package/dist/gateway/observability/codex-probe.d.ts +110 -0
  52. package/dist/gateway/observability/codex-probe.js +234 -0
  53. package/dist/gateway/observability/codex-telemetry.d.ts +93 -0
  54. package/dist/gateway/observability/codex-telemetry.js +221 -0
  55. package/dist/gateway/redact-safe/match-timeout.d.ts +83 -0
  56. package/dist/gateway/redact-safe/match-timeout.js +179 -0
  57. package/dist/gateway/reviewers/claude-self.d.ts +99 -0
  58. package/dist/gateway/reviewers/claude-self.js +316 -0
  59. package/dist/gateway/reviewers/codex.d.ts +64 -0
  60. package/dist/gateway/reviewers/codex.js +80 -0
  61. package/dist/gateway/reviewers/select.d.ts +64 -0
  62. package/dist/gateway/reviewers/select.js +102 -0
  63. package/dist/gateway/reviewers/types.d.ts +85 -0
  64. package/dist/gateway/reviewers/types.js +14 -0
  65. package/dist/gateway/server.d.ts +51 -0
  66. package/dist/gateway/server.js +258 -0
  67. package/dist/gateway/session.d.ts +9 -0
  68. package/dist/gateway/session.js +17 -0
  69. package/dist/policy/loader.d.ts +59 -0
  70. package/dist/policy/loader.js +65 -0
  71. package/dist/policy/profiles.d.ts +80 -0
  72. package/dist/policy/profiles.js +94 -0
  73. package/dist/policy/types.d.ts +38 -0
  74. package/dist/registry/loader.d.ts +98 -0
  75. package/dist/registry/loader.js +153 -0
  76. package/dist/registry/types.d.ts +44 -0
  77. package/dist/registry/types.js +6 -0
  78. package/dist/scripts/read-policy-field.d.ts +36 -0
  79. package/dist/scripts/read-policy-field.js +96 -0
  80. package/hooks/push-review-gate.sh +627 -17
  81. package/package.json +13 -2
  82. package/profiles/bst-internal-no-codex.yaml +40 -0
  83. package/profiles/bst-internal.yaml +23 -0
  84. package/profiles/client-engagement.yaml +23 -0
  85. package/profiles/lit-wc.yaml +17 -0
  86. package/profiles/minimal.yaml +11 -0
  87. package/profiles/open-source-no-codex.yaml +33 -0
  88. package/profiles/open-source.yaml +18 -0
  89. package/scripts/lint-safe-regex.mjs +78 -0
  90. package/scripts/postinstall.mjs +131 -0
@@ -0,0 +1,67 @@
1
+ /**
2
+ * G12 — `rea upgrade`.
3
+ *
4
+ * Classify every canonical shipped file against the consumer's installed copy
5
+ * and the last manifest entry, then act:
6
+ *
7
+ * - NEW (not in manifest, not on disk) → install the canonical version.
8
+ * - UNMODIFIED (on-disk SHA matches manifest SHA) → silently overwrite with
9
+ * canonical version. The consumer never changed it; they get updates for
10
+ * free.
11
+ * - DRIFTED (on-disk SHA ≠ manifest SHA) → interactive prompt:
12
+ * keep | overwrite | diff (show unified diff, then re-prompt)
13
+ * Non-interactive (`--yes`) defaults to KEEP (safe). `--force` defaults
14
+ * to OVERWRITE and skips the prompt.
15
+ * - REMOVED-UPSTREAM (in manifest, no longer canonical) → prompt to delete.
16
+ * Non-interactive defaults to SKIP; `--force` deletes.
17
+ *
18
+ * After processing, the manifest is rewritten with fresh SHAs + `upgraded_at`.
19
+ *
20
+ * Bootstrap path: if no manifest is found, we record current on-disk SHAs
21
+ * as the baseline and mark `bootstrap: true`. This gives pre-G12 installs a
22
+ * manifest without pretending we know what was originally shipped. The NEXT
23
+ * `rea upgrade` compares against canonical normally.
24
+ *
25
+ * Dogfood caveat: running `rea upgrade` on this repo via a Claude Code
26
+ * session will be blocked by `settings-protection.sh` (`.claude/hooks/*`,
27
+ * `.claude/settings.json`, `.husky/*` all protected from Write/Edit). Invoke
28
+ * `rea upgrade` directly from a terminal outside the Claude Code session.
29
+ * The `rea upgrade` code itself performs writes via node `fs` calls which
30
+ * are not hook-gated — but a Claude Code-hosted Bash invocation is. This is
31
+ * intentional: upgrade is an authorized-human action by design.
32
+ *
33
+ * Security note: every on-disk mutation flows through `safeInstallFile` or
34
+ * `safeDeleteFile` in `install/fs-safe.ts`. Path values that originate from
35
+ * `.rea/install-manifest.json` (attacker-controllable) are validated at
36
+ * schema-load time AND re-validated at each filesystem call. See
37
+ * `install/fs-safe.ts` header for the full TOCTOU argument.
38
+ */
39
+ import { type CanonicalFile } from './install/canonical.js';
40
+ import { type ManifestEntry } from './install/manifest-schema.js';
41
+ export interface UpgradeOptions {
42
+ dryRun?: boolean | undefined;
43
+ yes?: boolean | undefined;
44
+ force?: boolean | undefined;
45
+ }
46
+ type Classification = {
47
+ kind: 'new';
48
+ canonical: CanonicalFile;
49
+ canonicalSha: string;
50
+ } | {
51
+ kind: 'unmodified';
52
+ canonical: CanonicalFile;
53
+ canonicalSha: string;
54
+ localSha: string;
55
+ entry: ManifestEntry;
56
+ } | {
57
+ kind: 'drifted';
58
+ canonical: CanonicalFile;
59
+ canonicalSha: string;
60
+ localSha: string;
61
+ entry: ManifestEntry;
62
+ } | {
63
+ kind: 'removed-upstream';
64
+ entry: ManifestEntry;
65
+ };
66
+ export declare function runUpgrade(options?: UpgradeOptions): Promise<void>;
67
+ export type { Classification };
@@ -0,0 +1,509 @@
1
+ /**
2
+ * G12 — `rea upgrade`.
3
+ *
4
+ * Classify every canonical shipped file against the consumer's installed copy
5
+ * and the last manifest entry, then act:
6
+ *
7
+ * - NEW (not in manifest, not on disk) → install the canonical version.
8
+ * - UNMODIFIED (on-disk SHA matches manifest SHA) → silently overwrite with
9
+ * canonical version. The consumer never changed it; they get updates for
10
+ * free.
11
+ * - DRIFTED (on-disk SHA ≠ manifest SHA) → interactive prompt:
12
+ * keep | overwrite | diff (show unified diff, then re-prompt)
13
+ * Non-interactive (`--yes`) defaults to KEEP (safe). `--force` defaults
14
+ * to OVERWRITE and skips the prompt.
15
+ * - REMOVED-UPSTREAM (in manifest, no longer canonical) → prompt to delete.
16
+ * Non-interactive defaults to SKIP; `--force` deletes.
17
+ *
18
+ * After processing, the manifest is rewritten with fresh SHAs + `upgraded_at`.
19
+ *
20
+ * Bootstrap path: if no manifest is found, we record current on-disk SHAs
21
+ * as the baseline and mark `bootstrap: true`. This gives pre-G12 installs a
22
+ * manifest without pretending we know what was originally shipped. The NEXT
23
+ * `rea upgrade` compares against canonical normally.
24
+ *
25
+ * Dogfood caveat: running `rea upgrade` on this repo via a Claude Code
26
+ * session will be blocked by `settings-protection.sh` (`.claude/hooks/*`,
27
+ * `.claude/settings.json`, `.husky/*` all protected from Write/Edit). Invoke
28
+ * `rea upgrade` directly from a terminal outside the Claude Code session.
29
+ * The `rea upgrade` code itself performs writes via node `fs` calls which
30
+ * are not hook-gated — but a Claude Code-hosted Bash invocation is. This is
31
+ * intentional: upgrade is an authorized-human action by design.
32
+ *
33
+ * Security note: every on-disk mutation flows through `safeInstallFile` or
34
+ * `safeDeleteFile` in `install/fs-safe.ts`. Path values that originate from
35
+ * `.rea/install-manifest.json` (attacker-controllable) are validated at
36
+ * schema-load time AND re-validated at each filesystem call. See
37
+ * `install/fs-safe.ts` header for the full TOCTOU argument.
38
+ */
39
+ import fs from 'node:fs';
40
+ import fsPromises from 'node:fs/promises';
41
+ import path from 'node:path';
42
+ import * as p from '@clack/prompts';
43
+ import { loadPolicy } from '../policy/loader.js';
44
+ import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
45
+ import { buildFragment, extractFragment, } from './install/claude-md.js';
46
+ import { atomicReplaceFile, safeDeleteFile, safeInstallFile, safeReadFile, } from './install/fs-safe.js';
47
+ import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
48
+ import { manifestExists, readManifest, writeManifestAtomic, } from './install/manifest-io.js';
49
+ import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
50
+ import { err, getPkgVersion, log, warn } from './utils.js';
51
+ /**
52
+ * Hard cap for `showDiff` reads. Canonical files are all tiny (<64KB) but a
53
+ * consumer could have replaced a hook with a 500MB log; refuse to slurp the
54
+ * whole thing into memory. Above this threshold we emit a truncation notice
55
+ * and decline to produce a diff.
56
+ */
57
+ const DIFF_SIZE_CAP_BYTES = 256 * 1024;
58
+ /**
59
+ * Read a consumer-side file's SHA-256 *through* the fs-safe containment
60
+ * check. Returns `null` when the file is absent. The path here comes from
61
+ * canonical.destRelPath (trusted, enumerated from PKG_ROOT), but we still
62
+ * run it through `safeReadFile` so every filesystem read in upgrade is
63
+ * uniformly symlink- and containment-guarded.
64
+ */
65
+ async function readLocalSha(resolvedRoot, relPath) {
66
+ const buf = await safeReadFile(resolvedRoot, relPath);
67
+ if (buf === null)
68
+ return null;
69
+ return sha256OfBuffer(buf);
70
+ }
71
+ function showDiff(resolvedRoot, canonical) {
72
+ const dst = path.join(resolvedRoot, canonical.destRelPath);
73
+ let localStat;
74
+ try {
75
+ localStat = fs.statSync(dst);
76
+ }
77
+ catch {
78
+ console.log('');
79
+ console.log(` (diff unavailable — ${canonical.destRelPath} disappeared)`);
80
+ console.log('');
81
+ return;
82
+ }
83
+ const canonicalStat = fs.statSync(canonical.sourceAbsPath);
84
+ if (localStat.size > DIFF_SIZE_CAP_BYTES || canonicalStat.size > DIFF_SIZE_CAP_BYTES) {
85
+ console.log('');
86
+ console.log(` (diff suppressed — ${canonical.destRelPath} exceeds ${DIFF_SIZE_CAP_BYTES} bytes; compare manually)`);
87
+ console.log('');
88
+ return;
89
+ }
90
+ const localBytes = fs.readFileSync(dst, 'utf8');
91
+ const canonicalBytes = fs.readFileSync(canonical.sourceAbsPath, 'utf8');
92
+ const localLines = localBytes.split('\n');
93
+ const canonicalLines = canonicalBytes.split('\n');
94
+ console.log('');
95
+ console.log(`--- local: ${canonical.destRelPath}`);
96
+ console.log(`+++ canonical (rea v${getPkgVersion()})`);
97
+ console.log('');
98
+ // Minimal unified-diff-ish output: line-by-line replace. Full diff requires
99
+ // an LCS implementation; for our purposes, showing both halves and a simple
100
+ // line-counter is enough to let a human decide.
101
+ const max = Math.max(localLines.length, canonicalLines.length);
102
+ let changes = 0;
103
+ for (let i = 0; i < max && changes < 80; i++) {
104
+ const a = localLines[i];
105
+ const b = canonicalLines[i];
106
+ if (a !== b) {
107
+ if (a !== undefined)
108
+ console.log(`- ${a}`);
109
+ if (b !== undefined)
110
+ console.log(`+ ${b}`);
111
+ changes += 1;
112
+ }
113
+ }
114
+ if (changes >= 80)
115
+ console.log('... (diff truncated at 80 changed lines)');
116
+ console.log('');
117
+ }
118
+ async function promptDriftDecision(resolvedRoot, canonical, opts) {
119
+ if (opts.force === true)
120
+ return 'overwrite';
121
+ if (opts.yes === true)
122
+ return 'keep';
123
+ while (true) {
124
+ const choice = await p.select({
125
+ message: `${canonical.destRelPath} — locally modified`,
126
+ initialValue: 'keep',
127
+ options: [
128
+ { value: 'keep', label: 'keep', hint: 'leave your version untouched (default)' },
129
+ { value: 'overwrite', label: 'overwrite', hint: `replace with canonical (rea v${getPkgVersion()})` },
130
+ { value: 'diff', label: 'diff', hint: 'show diff, then re-prompt' },
131
+ ],
132
+ });
133
+ if (p.isCancel(choice))
134
+ return 'keep';
135
+ if (choice === 'diff') {
136
+ showDiff(resolvedRoot, canonical);
137
+ continue;
138
+ }
139
+ return choice;
140
+ }
141
+ }
142
+ async function promptRemovedDecision(relPath, opts) {
143
+ if (opts.force === true)
144
+ return 'delete';
145
+ if (opts.yes === true)
146
+ return 'skip';
147
+ const answer = await p.select({
148
+ message: `${relPath} — no longer shipped by rea`,
149
+ initialValue: 'skip',
150
+ options: [
151
+ { value: 'skip', label: 'skip', hint: 'keep the file (default)' },
152
+ { value: 'delete', label: 'delete', hint: 'remove it' },
153
+ ],
154
+ });
155
+ if (p.isCancel(answer))
156
+ return 'skip';
157
+ return answer;
158
+ }
159
+ async function classifyFiles(resolvedRoot, canonicalFiles, manifest) {
160
+ const manifestByPath = new Map();
161
+ if (manifest !== null) {
162
+ for (const e of manifest.files)
163
+ manifestByPath.set(e.path, e);
164
+ }
165
+ const canonicalByPath = new Map();
166
+ for (const c of canonicalFiles)
167
+ canonicalByPath.set(c.destRelPath, c);
168
+ const classifications = [];
169
+ const shaByPath = new Map();
170
+ for (const canonical of canonicalFiles) {
171
+ const canonicalSha = await sha256OfFile(canonical.sourceAbsPath);
172
+ shaByPath.set(canonical.destRelPath, canonicalSha);
173
+ const localSha = await readLocalSha(resolvedRoot, canonical.destRelPath);
174
+ const entry = manifestByPath.get(canonical.destRelPath);
175
+ if (localSha === null) {
176
+ classifications.push({ kind: 'new', canonical, canonicalSha });
177
+ continue;
178
+ }
179
+ if (entry === undefined) {
180
+ // File exists locally but not in manifest — treat as drift against
181
+ // canonical (bootstrap-equivalent for this single file).
182
+ if (localSha === canonicalSha) {
183
+ classifications.push({
184
+ kind: 'unmodified',
185
+ canonical,
186
+ canonicalSha,
187
+ localSha,
188
+ entry: { path: canonical.destRelPath, sha256: canonicalSha, source: canonical.source },
189
+ });
190
+ }
191
+ else {
192
+ classifications.push({
193
+ kind: 'drifted',
194
+ canonical,
195
+ canonicalSha,
196
+ localSha,
197
+ entry: { path: canonical.destRelPath, sha256: localSha, source: canonical.source },
198
+ });
199
+ }
200
+ continue;
201
+ }
202
+ if (localSha === entry.sha256) {
203
+ classifications.push({ kind: 'unmodified', canonical, canonicalSha, localSha, entry });
204
+ }
205
+ else {
206
+ classifications.push({ kind: 'drifted', canonical, canonicalSha, localSha, entry });
207
+ }
208
+ }
209
+ // Removed-upstream: in manifest but not in canonical set.
210
+ if (manifest !== null) {
211
+ for (const entry of manifest.files) {
212
+ if (entry.path === CLAUDE_MD_MANIFEST_PATH ||
213
+ entry.path === SETTINGS_MANIFEST_PATH) {
214
+ continue; // synthetic entries handled separately
215
+ }
216
+ if (!canonicalByPath.has(entry.path)) {
217
+ classifications.push({ kind: 'removed-upstream', entry });
218
+ }
219
+ }
220
+ }
221
+ return { classifications, shaByPath };
222
+ }
223
+ function summarize(classifications) {
224
+ const counts = { new_: 0, unmodified: 0, drifted: 0, removedUpstream: 0 };
225
+ for (const c of classifications) {
226
+ if (c.kind === 'new')
227
+ counts.new_ += 1;
228
+ else if (c.kind === 'unmodified')
229
+ counts.unmodified += 1;
230
+ else if (c.kind === 'drifted')
231
+ counts.drifted += 1;
232
+ else if (c.kind === 'removed-upstream')
233
+ counts.removedUpstream += 1;
234
+ }
235
+ return counts;
236
+ }
237
+ function readPolicyForFragment(baseDir) {
238
+ try {
239
+ const policy = loadPolicy(baseDir);
240
+ return {
241
+ policyPath: '.rea/policy.yaml',
242
+ profile: policy.profile,
243
+ autonomyLevel: policy.autonomy_level,
244
+ maxAutonomyLevel: policy.max_autonomy_level,
245
+ blockedPathsCount: policy.blocked_paths.length,
246
+ blockAiAttribution: policy.block_ai_attribution,
247
+ };
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ }
253
+ async function upgradeClaudeMdFragment(resolvedRoot, opts) {
254
+ const fragmentInput = readPolicyForFragment(resolvedRoot);
255
+ if (fragmentInput === null)
256
+ return { sha: null, action: 'skipped' };
257
+ const newFragment = buildFragment(fragmentInput);
258
+ const newSha = sha256OfBuffer(newFragment);
259
+ const claudeMdPath = path.join(resolvedRoot, 'CLAUDE.md');
260
+ if (!fs.existsSync(claudeMdPath)) {
261
+ if (opts.dryRun === true)
262
+ return { sha: newSha, action: 'written' };
263
+ await atomicReplaceFile(claudeMdPath, `# CLAUDE.md\n\n${newFragment}\n`);
264
+ return { sha: newSha, action: 'written' };
265
+ }
266
+ const existing = await fsPromises.readFile(claudeMdPath, 'utf8');
267
+ const currentFragment = extractFragment(existing);
268
+ if (currentFragment === newFragment)
269
+ return { sha: newSha, action: 'unchanged' };
270
+ if (opts.dryRun === true)
271
+ return { sha: newSha, action: 'written' };
272
+ let next;
273
+ if (currentFragment !== null) {
274
+ next = existing.replace(currentFragment, newFragment);
275
+ }
276
+ else {
277
+ // No markers present — append fragment, preserving existing file content.
278
+ const trailer = existing.endsWith('\n') ? '' : '\n';
279
+ next = `${existing}${trailer}\n${newFragment}\n`;
280
+ }
281
+ await atomicReplaceFile(claudeMdPath, next);
282
+ return { sha: newSha, action: 'written' };
283
+ }
284
+ async function upgradeSettings(baseDir, opts) {
285
+ const desired = defaultDesiredHooks();
286
+ const sha = canonicalSettingsSubsetHash(desired);
287
+ const { settings, settingsPath } = readSettings(baseDir);
288
+ const mergeResult = mergeSettings(settings, desired);
289
+ if (opts.dryRun !== true) {
290
+ await writeSettingsAtomic(settingsPath, mergeResult.merged);
291
+ }
292
+ return {
293
+ sha,
294
+ addedCount: mergeResult.addedCount,
295
+ skippedCount: mergeResult.skippedCount,
296
+ warnings: mergeResult.warnings,
297
+ };
298
+ }
299
+ /** Re-hash a file we just wrote. Source and on-disk bytes should match, but
300
+ * we record the *installed* SHA so a disk-level corruption would be visible
301
+ * on the next run rather than papered over by the source SHA. */
302
+ async function hashInstalled(resolvedRoot, relPath) {
303
+ const buf = await safeReadFile(resolvedRoot, relPath);
304
+ if (buf === null) {
305
+ throw new Error(`post-install verification failed: ${relPath} not readable after write`);
306
+ }
307
+ return sha256OfBuffer(buf);
308
+ }
309
+ export async function runUpgrade(options = {}) {
310
+ const baseDir = process.cwd();
311
+ const dryRun = options.dryRun === true;
312
+ if (!fs.existsSync(path.join(baseDir, '.rea'))) {
313
+ err('no .rea/ directory — run `rea init` first.');
314
+ process.exit(1);
315
+ }
316
+ // Resolve the install root once so every filesystem op below uses a single
317
+ // trusted anchor. `safeInstallFile` / `safeDeleteFile` / `safeReadFile` all
318
+ // require this to enforce containment.
319
+ const resolvedRoot = await fsPromises.realpath(baseDir);
320
+ if (options.force === true && !dryRun) {
321
+ warn('--force: overwriting locally-modified files and deleting removed-upstream entries without prompt.');
322
+ }
323
+ const canonicalFiles = await enumerateCanonicalFiles();
324
+ if (canonicalFiles.length === 0) {
325
+ err('no canonical files found in package — is the build complete?');
326
+ process.exit(1);
327
+ }
328
+ const existingManifest = manifestExists(resolvedRoot) ? await readManifest(resolvedRoot) : null;
329
+ const isBootstrap = existingManifest === null;
330
+ log(`Upgrade — target ${resolvedRoot}${dryRun ? ' (dry run)' : ''}${isBootstrap ? ' — bootstrap mode' : ''}`);
331
+ console.log('');
332
+ const { classifications } = await classifyFiles(resolvedRoot, canonicalFiles, existingManifest);
333
+ const counts = summarize(classifications);
334
+ console.log(` ${counts.new_} new, ${counts.unmodified} auto-update, ${counts.drifted} drifted, ${counts.removedUpstream} removed-upstream`);
335
+ console.log('');
336
+ const applied = [];
337
+ const skipped = [];
338
+ const errors = [];
339
+ const finalFileEntries = [];
340
+ for (const c of classifications) {
341
+ if (c.kind === 'new') {
342
+ console.log(` + ${c.canonical.destRelPath}`);
343
+ if (!dryRun) {
344
+ await safeInstallFile({
345
+ srcAbsPath: c.canonical.sourceAbsPath,
346
+ resolvedRoot,
347
+ destRelPath: c.canonical.destRelPath,
348
+ mode: c.canonical.mode,
349
+ });
350
+ }
351
+ const installedSha = dryRun
352
+ ? c.canonicalSha
353
+ : await hashInstalled(resolvedRoot, c.canonical.destRelPath);
354
+ applied.push(c);
355
+ finalFileEntries.push({
356
+ path: c.canonical.destRelPath,
357
+ sha256: installedSha,
358
+ source: c.canonical.source,
359
+ });
360
+ }
361
+ else if (c.kind === 'unmodified') {
362
+ if (c.canonicalSha === c.localSha) {
363
+ // Already identical to canonical. Record the verified local SHA —
364
+ // no write performed.
365
+ finalFileEntries.push({
366
+ path: c.canonical.destRelPath,
367
+ sha256: c.localSha,
368
+ source: c.canonical.source,
369
+ });
370
+ continue;
371
+ }
372
+ // Consumer file matches the OLD manifest (untouched since last install) —
373
+ // safe to auto-update to the new canonical version.
374
+ console.log(` ~ ${c.canonical.destRelPath} (auto-update)`);
375
+ if (!dryRun) {
376
+ await safeInstallFile({
377
+ srcAbsPath: c.canonical.sourceAbsPath,
378
+ resolvedRoot,
379
+ destRelPath: c.canonical.destRelPath,
380
+ mode: c.canonical.mode,
381
+ });
382
+ }
383
+ const installedSha = dryRun
384
+ ? c.canonicalSha
385
+ : await hashInstalled(resolvedRoot, c.canonical.destRelPath);
386
+ applied.push(c);
387
+ finalFileEntries.push({
388
+ path: c.canonical.destRelPath,
389
+ sha256: installedSha,
390
+ source: c.canonical.source,
391
+ });
392
+ }
393
+ else if (c.kind === 'drifted') {
394
+ const decision = dryRun
395
+ ? 'keep'
396
+ : await promptDriftDecision(resolvedRoot, c.canonical, options);
397
+ if (decision === 'overwrite') {
398
+ console.log(` ~ ${c.canonical.destRelPath} (overwrite)`);
399
+ if (!dryRun) {
400
+ await safeInstallFile({
401
+ srcAbsPath: c.canonical.sourceAbsPath,
402
+ resolvedRoot,
403
+ destRelPath: c.canonical.destRelPath,
404
+ mode: c.canonical.mode,
405
+ });
406
+ }
407
+ const installedSha = dryRun
408
+ ? c.canonicalSha
409
+ : await hashInstalled(resolvedRoot, c.canonical.destRelPath);
410
+ applied.push(c);
411
+ finalFileEntries.push({
412
+ path: c.canonical.destRelPath,
413
+ sha256: installedSha,
414
+ source: c.canonical.source,
415
+ });
416
+ }
417
+ else {
418
+ console.log(` · ${c.canonical.destRelPath} (kept; local modifications preserved)`);
419
+ warn(`DRIFT: ${c.canonical.destRelPath} differs from canonical — local version kept`);
420
+ skipped.push(c);
421
+ finalFileEntries.push({
422
+ path: c.canonical.destRelPath,
423
+ sha256: c.localSha,
424
+ source: c.canonical.source,
425
+ });
426
+ }
427
+ }
428
+ else if (c.kind === 'removed-upstream') {
429
+ const decision = dryRun
430
+ ? 'skip'
431
+ : await promptRemovedDecision(c.entry.path, options);
432
+ if (decision === 'delete') {
433
+ console.log(` - ${c.entry.path} (deleted)`);
434
+ if (!dryRun) {
435
+ // Path originates from the manifest (attacker-controllable). The
436
+ // ManifestPath zod refinement already rejected `..`, absolute
437
+ // paths, and control chars at parse time; `safeDeleteFile` adds
438
+ // symlink refusal + containment re-check for defence in depth.
439
+ await safeDeleteFile(resolvedRoot, c.entry.path);
440
+ }
441
+ applied.push(c);
442
+ // Drop from manifest.
443
+ }
444
+ else {
445
+ console.log(` · ${c.entry.path} (kept; no longer shipped)`);
446
+ skipped.push(c);
447
+ finalFileEntries.push(c.entry);
448
+ }
449
+ }
450
+ }
451
+ // Synthetic entries: settings + claude-md fragment.
452
+ const settingsResult = await upgradeSettings(resolvedRoot, options);
453
+ if (settingsResult.addedCount > 0) {
454
+ console.log(` ~ .claude/settings.json (${settingsResult.addedCount} hook entries added, ${settingsResult.skippedCount} already present)`);
455
+ }
456
+ else {
457
+ console.log(` · .claude/settings.json (${settingsResult.skippedCount} rea entries already present)`);
458
+ }
459
+ for (const w of settingsResult.warnings)
460
+ warn(w);
461
+ finalFileEntries.push({
462
+ path: SETTINGS_MANIFEST_PATH,
463
+ sha256: settingsResult.sha,
464
+ source: 'settings',
465
+ });
466
+ const mdResult = await upgradeClaudeMdFragment(resolvedRoot, options);
467
+ if (mdResult.sha !== null) {
468
+ if (mdResult.action === 'written')
469
+ console.log(` ~ CLAUDE.md (managed fragment updated)`);
470
+ else if (mdResult.action === 'unchanged')
471
+ console.log(` · CLAUDE.md (fragment unchanged)`);
472
+ finalFileEntries.push({
473
+ path: CLAUDE_MD_MANIFEST_PATH,
474
+ sha256: mdResult.sha,
475
+ source: 'claude-md',
476
+ });
477
+ }
478
+ if (dryRun) {
479
+ console.log('');
480
+ log('dry run — no changes written.');
481
+ const planned = counts.new_ + counts.drifted + counts.removedUpstream +
482
+ (classifications.some((c) => c.kind === 'unmodified' && c.canonicalSha !== c.localSha)
483
+ ? classifications.filter((c) => c.kind === 'unmodified' && c.canonicalSha !== c.localSha).length
484
+ : 0);
485
+ console.log(` ${planned} file action(s) planned.`);
486
+ return;
487
+ }
488
+ const now = new Date().toISOString();
489
+ const installedAt = existingManifest?.installed_at ?? now;
490
+ const profile = existingManifest?.profile ?? 'unknown';
491
+ const freshManifest = {
492
+ version: getPkgVersion(),
493
+ profile,
494
+ installed_at: installedAt,
495
+ upgraded_at: now,
496
+ ...(isBootstrap ? { bootstrap: true } : {}),
497
+ files: finalFileEntries,
498
+ };
499
+ const manifestPath = await writeManifestAtomic(resolvedRoot, freshManifest);
500
+ console.log('');
501
+ log(`upgrade complete — ${applied.length} applied, ${skipped.length} skipped, ${errors.length} errors`);
502
+ console.log(` manifest: ${path.relative(resolvedRoot, manifestPath)} (v${freshManifest.version})`);
503
+ if (isBootstrap) {
504
+ console.log('');
505
+ console.log('Bootstrap mode: existing files were recorded as-is. The next `rea upgrade`');
506
+ console.log('will compare against the canonical set and surface any legitimate drift.');
507
+ }
508
+ console.log('');
509
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Pool of downstream MCP connections. Owns lookup + tool-name prefixing.
3
+ *
4
+ * Tool names exposed to the upstream MCP client are `<serverName>__<toolName>`.
5
+ * The gateway splits on the FIRST `__` — downstream tools that themselves
6
+ * contain `__` in their name continue to work because the split is one-shot.
7
+ */
8
+ import { DownstreamConnection, type DownstreamToolInfo } from './downstream.js';
9
+ import type { Registry } from '../registry/types.js';
10
+ export interface PrefixedTool extends DownstreamToolInfo {
11
+ /** Server name, not prefixed. */
12
+ server: string;
13
+ /** Full prefixed name, as exposed to the upstream client. */
14
+ name: string;
15
+ }
16
+ export declare class DownstreamPool {
17
+ private readonly connections;
18
+ constructor(registry: Registry);
19
+ get size(): number;
20
+ connectAll(): Promise<void>;
21
+ /**
22
+ * Aggregate tools from every healthy downstream with prefixed names.
23
+ * Unhealthy or unconnected connections are skipped — the upstream client
24
+ * will see a smaller catalog rather than a crash.
25
+ */
26
+ listAllTools(): Promise<PrefixedTool[]>;
27
+ /**
28
+ * Split a prefixed tool name and dispatch. Returns the raw result from the
29
+ * downstream (the gateway response handler shapes it for the upstream reply).
30
+ */
31
+ callTool(prefixedName: string, args: Record<string, unknown>): Promise<unknown>;
32
+ close(): Promise<void>;
33
+ /** Visible for tests: get a connection by server name. */
34
+ getConnection(serverName: string): DownstreamConnection | undefined;
35
+ }
36
+ export declare function splitPrefixed(prefixedName: string): {
37
+ server: string;
38
+ tool: string;
39
+ };