@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,160 @@
1
+ /**
2
+ * Translate a `.reagent/policy.yaml` into a `.rea/policy.yaml`-shaped payload.
3
+ *
4
+ * ## Explicit contract
5
+ *
6
+ * Reagent had a broader policy schema than rea. Most top-level fields either
7
+ * transfer directly (same semantics) or are dropped because their governance
8
+ * model changed. Dropping a security-relevant field silently would downgrade a
9
+ * guarantee the user already expected — that is forbidden.
10
+ *
11
+ * Fields fall into one of three lists:
12
+ *
13
+ * - **copy list**: copy verbatim into the rea policy.
14
+ * - **drop list**: SECURITY-RELEVANT fields that were removed or restructured
15
+ * in rea. If any drop-list field is present in the input policy, this
16
+ * function refuses to translate unless `acceptDropped === true`.
17
+ * - **ignore list**: non-governance fields (metadata, project name, notes)
18
+ * that are simply not written to the rea policy. No warning emitted.
19
+ *
20
+ * ## Autonomy clamping
21
+ *
22
+ * If the reagent policy's `max_autonomy_level` exceeds the chosen profile's
23
+ * ceiling, we clamp down to the profile ceiling and record a notice. A reagent
24
+ * install that allowed L3 cannot silently survive a migration into an
25
+ * `open-source` profile capped at L2.
26
+ */
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { parse as parseYaml } from 'yaml';
30
+ import { AutonomyLevel } from '../../policy/types.js';
31
+ import { ProfileSchema } from '../../policy/profiles.js';
32
+ const LEVEL_RANK = {
33
+ [AutonomyLevel.L0]: 0,
34
+ [AutonomyLevel.L1]: 1,
35
+ [AutonomyLevel.L2]: 2,
36
+ [AutonomyLevel.L3]: 3,
37
+ };
38
+ /** Fields transferred directly from reagent. Both schemas agree on semantics. */
39
+ const COPY_FIELDS = [
40
+ 'autonomy_level',
41
+ 'max_autonomy_level',
42
+ 'promotion_requires_human_approval',
43
+ 'blocked_paths',
44
+ 'context_protection',
45
+ 'block_ai_attribution',
46
+ ];
47
+ /**
48
+ * SECURITY-RELEVANT fields that reagent supported but rea does not model the
49
+ * same way (yet). Presence of any of these triggers a refusal unless the
50
+ * caller explicitly opts in via `--accept-dropped-fields`. This list is
51
+ * intentionally broader than the strict minimum — err on the side of asking.
52
+ */
53
+ const DROP_FIELDS = [
54
+ 'push_review',
55
+ 'coverage',
56
+ 'security',
57
+ 'commit_review',
58
+ 'quality_gates',
59
+ ];
60
+ export class ReagentDroppedFieldsError extends Error {
61
+ dropped;
62
+ constructor(dropped) {
63
+ super(`Reagent policy contains fields that rea does not model identically:\n` +
64
+ dropped.map((f) => ` - ${f}`).join('\n') +
65
+ `\n\nPass --accept-dropped-fields to continue. The dropped fields are\n` +
66
+ `security-adjacent and will be silently removed — review the rea policy\n` +
67
+ `after migration and restore equivalent guarantees by other means.`);
68
+ this.name = 'ReagentDroppedFieldsError';
69
+ this.dropped = dropped;
70
+ }
71
+ }
72
+ function readReagentPolicy(reagentPath) {
73
+ const raw = fs.readFileSync(reagentPath, 'utf8');
74
+ let parsed;
75
+ try {
76
+ parsed = parseYaml(raw);
77
+ }
78
+ catch (err) {
79
+ throw new Error(`Failed to parse reagent policy at ${reagentPath}: ${err instanceof Error ? err.message : err}`);
80
+ }
81
+ if (parsed === null || parsed === undefined)
82
+ return {};
83
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
84
+ throw new Error(`Reagent policy at ${reagentPath} is not a YAML mapping`);
85
+ }
86
+ return parsed;
87
+ }
88
+ function detectDropped(policy) {
89
+ const found = [];
90
+ for (const f of DROP_FIELDS) {
91
+ if (f in policy)
92
+ found.push(f);
93
+ }
94
+ return found;
95
+ }
96
+ function extractCopyFields(policy) {
97
+ const out = {};
98
+ for (const f of COPY_FIELDS) {
99
+ if (f in policy)
100
+ out[f] = policy[f];
101
+ }
102
+ return out;
103
+ }
104
+ /**
105
+ * Translate the reagent policy at `reagentPath`, enforcing drop-list rules.
106
+ *
107
+ * @throws {ReagentDroppedFieldsError} if drop-list fields are present and
108
+ * `acceptDropped` is false.
109
+ */
110
+ export function translateReagentPolicy(reagentPath, options) {
111
+ if (!fs.existsSync(reagentPath)) {
112
+ throw new Error(`Reagent policy not found at ${reagentPath}`);
113
+ }
114
+ const raw = readReagentPolicy(reagentPath);
115
+ const dropped = detectDropped(raw);
116
+ if (dropped.length > 0 && !options.acceptDropped) {
117
+ throw new ReagentDroppedFieldsError(dropped);
118
+ }
119
+ const notices = [];
120
+ if (dropped.length > 0) {
121
+ for (const f of dropped) {
122
+ notices.push(`dropped reagent field "${f}" — governance surface no longer modeled in rea policy`);
123
+ }
124
+ }
125
+ const copied = extractCopyFields(raw);
126
+ // Validate the copied subset against the profile schema. This catches shape
127
+ // drift (e.g. a reagent file with a malformed context_protection block).
128
+ let validated;
129
+ try {
130
+ validated = ProfileSchema.parse(copied);
131
+ }
132
+ catch (err) {
133
+ throw new Error(`Reagent-translated fields failed schema validation: ${err instanceof Error ? err.message : err}`);
134
+ }
135
+ let clamped = false;
136
+ if (validated.max_autonomy_level !== undefined) {
137
+ const reagentCeilingRank = LEVEL_RANK[validated.max_autonomy_level];
138
+ const profileCeilingRank = LEVEL_RANK[options.profileCeiling];
139
+ if (reagentCeilingRank > profileCeilingRank) {
140
+ notices.push(`clamping max_autonomy_level ${validated.max_autonomy_level} → ${options.profileCeiling} (profile ceiling)`);
141
+ validated.max_autonomy_level = options.profileCeiling;
142
+ clamped = true;
143
+ }
144
+ // Also clamp autonomy_level if it now exceeds the ceiling.
145
+ if (validated.autonomy_level !== undefined &&
146
+ LEVEL_RANK[validated.autonomy_level] > LEVEL_RANK[validated.max_autonomy_level]) {
147
+ notices.push(`clamping autonomy_level ${validated.autonomy_level} → ${validated.max_autonomy_level} after ceiling clamp`);
148
+ validated.autonomy_level = validated.max_autonomy_level;
149
+ clamped = true;
150
+ }
151
+ }
152
+ return { translated: validated, notices, droppedFields: dropped, clampedAutonomy: clamped };
153
+ }
154
+ /**
155
+ * Resolve the default reagent policy path inside a target directory.
156
+ * Convenience for the CLI's `--from-reagent` flag.
157
+ */
158
+ export function defaultReagentPath(targetDir) {
159
+ return path.join(targetDir, '.reagent', 'policy.yaml');
160
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Merge rea's required hook registrations into a consumer's
3
+ * `.claude/settings.json` without ever silently overwriting consumer-authored
4
+ * entries.
5
+ *
6
+ * Rules per hook-group `(event, matcher, command)`:
7
+ *
8
+ * 1. `${matcher}::${command}` already present on the same event → no-op.
9
+ * 2. Same matcher, different command → append and warn (the consumer may
10
+ * need to chain the hooks manually if order matters).
11
+ * 3. Novel matcher → append as a new matcher group.
12
+ *
13
+ * Writes are atomic: serialize to `settings.json.tmp`, then rename. This keeps
14
+ * the file intact under crash or signal-interrupt.
15
+ *
16
+ * We deliberately do NOT validate the shape of existing entries beyond the
17
+ * minimum needed to merge. The harness is the source of truth for the schema;
18
+ * if a consumer has hand-authored unusual entries, we trust them and merge
19
+ * around their structure.
20
+ */
21
+ export type HookEvent = 'PreToolUse' | 'PostToolUse' | 'Notification' | 'Stop';
22
+ export interface DesiredHook {
23
+ type: 'command';
24
+ command: string;
25
+ timeout?: number;
26
+ statusMessage?: string;
27
+ }
28
+ export interface DesiredHookGroup {
29
+ event: HookEvent;
30
+ matcher: string;
31
+ hooks: DesiredHook[];
32
+ }
33
+ export interface MergeResult {
34
+ merged: Record<string, unknown>;
35
+ warnings: string[];
36
+ addedCount: number;
37
+ skippedCount: number;
38
+ }
39
+ /**
40
+ * Pure merge function — takes the existing settings object and the desired
41
+ * hooks and returns the merged settings plus a list of warnings. Does NOT
42
+ * touch disk.
43
+ */
44
+ export declare function mergeSettings(existing: Record<string, unknown>, desired: DesiredHookGroup[]): MergeResult;
45
+ /**
46
+ * Atomic write via tmp-file + rename.
47
+ *
48
+ * Portability note (finding #8): POSIX `rename(2)` atomically replaces the
49
+ * destination if it exists, but Windows `MoveFileEx` without
50
+ * `MOVEFILE_REPLACE_EXISTING` fails with `EEXIST`/`EPERM` on a non-empty
51
+ * destination — and Node's `fs.rename` does not pass that flag on Win32. So on
52
+ * Windows, a straight rename over an existing `settings.json` fails and
53
+ * `rea init` cannot update consumer settings.
54
+ *
55
+ * We handle this inline rather than taking a dependency: try `rename`; if it
56
+ * fails with `EEXIST` or `EPERM`, `unlink` the destination and retry. This
57
+ * opens a tiny window where the file is missing between unlink and rename, but
58
+ * a crash in that window leaves the `.tmp` file on disk as a recoverable
59
+ * artifact — strictly better than a corrupted merge. We prefer no dependency
60
+ * over `write-file-atomic`: every dep on a governance tool is a supply-chain
61
+ * surface, and this shim is small enough to audit here.
62
+ */
63
+ export declare function writeSettingsAtomic(settingsPath: string, settings: Record<string, unknown>): Promise<void>;
64
+ /**
65
+ * Read `${targetDir}/.claude/settings.json` if present; return an empty object
66
+ * otherwise. The wizard/`--yes` path will then populate env defaults
67
+ * elsewhere; this function only concerns itself with structural parsing.
68
+ */
69
+ export declare function readSettings(targetDir: string): {
70
+ settings: Record<string, unknown>;
71
+ settingsPath: string;
72
+ existed: boolean;
73
+ };
74
+ /**
75
+ * Stable SHA-256 of the rea-owned "desired hooks" subset. G12 uses this as
76
+ * the manifest entry for `.claude/settings.json` so drift detection only
77
+ * flags cases where a consumer deleted one of OUR entries — consumer-added
78
+ * entries remain invisible.
79
+ *
80
+ * Deterministic serialization: property order is fixed in-code (event,
81
+ * matcher, hooks → type, command, timeout, statusMessage), and the array
82
+ * order matches `defaultDesiredHooks()`. Any change to the desired set
83
+ * produces a new hash, which is the correct upgrade signal.
84
+ */
85
+ export declare function canonicalSettingsSubsetHash(groups: DesiredHookGroup[]): string;
86
+ /**
87
+ * Desired hook registrations that `rea init` installs on every run. Mirrors
88
+ * the shape of the `.claude/settings.json` that this repo dogfoods. Keep in
89
+ * lockstep with `hooks/` filenames.
90
+ */
91
+ export declare function defaultDesiredHooks(): DesiredHookGroup[];
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Merge rea's required hook registrations into a consumer's
3
+ * `.claude/settings.json` without ever silently overwriting consumer-authored
4
+ * entries.
5
+ *
6
+ * Rules per hook-group `(event, matcher, command)`:
7
+ *
8
+ * 1. `${matcher}::${command}` already present on the same event → no-op.
9
+ * 2. Same matcher, different command → append and warn (the consumer may
10
+ * need to chain the hooks manually if order matters).
11
+ * 3. Novel matcher → append as a new matcher group.
12
+ *
13
+ * Writes are atomic: serialize to `settings.json.tmp`, then rename. This keeps
14
+ * the file intact under crash or signal-interrupt.
15
+ *
16
+ * We deliberately do NOT validate the shape of existing entries beyond the
17
+ * minimum needed to merge. The harness is the source of truth for the schema;
18
+ * if a consumer has hand-authored unusual entries, we trust them and merge
19
+ * around their structure.
20
+ */
21
+ import { createHash } from 'node:crypto';
22
+ import fs from 'node:fs';
23
+ import fsPromises from 'node:fs/promises';
24
+ import path from 'node:path';
25
+ function deepClone(value) {
26
+ // structuredClone is available on Node 22+ (engines.node enforces this).
27
+ return structuredClone(value);
28
+ }
29
+ function ensureHooksShape(settings) {
30
+ const hooks = settings.hooks ?? {};
31
+ settings.hooks = hooks;
32
+ return hooks;
33
+ }
34
+ function keyFor(matcher, command) {
35
+ return `${matcher}::${command}`;
36
+ }
37
+ /**
38
+ * Pure merge function — takes the existing settings object and the desired
39
+ * hooks and returns the merged settings plus a list of warnings. Does NOT
40
+ * touch disk.
41
+ */
42
+ export function mergeSettings(existing, desired) {
43
+ const merged = deepClone(existing);
44
+ const hooks = ensureHooksShape(merged);
45
+ const warnings = [];
46
+ let addedCount = 0;
47
+ let skippedCount = 0;
48
+ for (const want of desired) {
49
+ const existingGroups = hooks[want.event] ?? [];
50
+ // Build a set of already-registered (matcher, command) pairs across all
51
+ // groups for this event. Most consumer files have one group per matcher,
52
+ // but we handle the multi-group case defensively.
53
+ const seen = new Set();
54
+ for (const g of existingGroups) {
55
+ if (typeof g.matcher !== 'string')
56
+ continue;
57
+ for (const h of g.hooks ?? []) {
58
+ if (typeof h.command === 'string')
59
+ seen.add(keyFor(g.matcher, h.command));
60
+ }
61
+ }
62
+ // Find or create a group with exactly this matcher. Track whether the
63
+ // group pre-existed so we only warn when rea chains onto consumer-authored
64
+ // hooks — not when multiple rea defaults share the same matcher group we
65
+ // just created this run.
66
+ const preExisting = existingGroups.find((g) => g.matcher === want.matcher);
67
+ let targetGroup = preExisting;
68
+ const wasPreExisting = preExisting !== undefined;
69
+ for (const wantHook of want.hooks) {
70
+ const k = keyFor(want.matcher, wantHook.command);
71
+ if (seen.has(k)) {
72
+ skippedCount += 1;
73
+ continue;
74
+ }
75
+ if (targetGroup === undefined) {
76
+ targetGroup = { matcher: want.matcher, hooks: [] };
77
+ existingGroups.push(targetGroup);
78
+ hooks[want.event] = existingGroups;
79
+ warnings.push(`added novel matcher "${want.matcher}" to event ${want.event}`);
80
+ }
81
+ else if (wasPreExisting) {
82
+ // Same matcher existed already in the consumer file; we're chaining
83
+ // new commands onto something the consumer owns. Warn so they review
84
+ // ordering semantics.
85
+ warnings.push(`chained new command onto existing matcher "${want.matcher}" for event ${want.event}: ${wantHook.command} — verify hook ordering is still correct`);
86
+ }
87
+ if (!Array.isArray(targetGroup.hooks))
88
+ targetGroup.hooks = [];
89
+ targetGroup.hooks.push({
90
+ type: wantHook.type,
91
+ command: wantHook.command,
92
+ ...(wantHook.timeout !== undefined ? { timeout: wantHook.timeout } : {}),
93
+ ...(wantHook.statusMessage !== undefined
94
+ ? { statusMessage: wantHook.statusMessage }
95
+ : {}),
96
+ });
97
+ seen.add(k);
98
+ addedCount += 1;
99
+ }
100
+ }
101
+ return { merged, warnings, addedCount, skippedCount };
102
+ }
103
+ /**
104
+ * Atomic write via tmp-file + rename.
105
+ *
106
+ * Portability note (finding #8): POSIX `rename(2)` atomically replaces the
107
+ * destination if it exists, but Windows `MoveFileEx` without
108
+ * `MOVEFILE_REPLACE_EXISTING` fails with `EEXIST`/`EPERM` on a non-empty
109
+ * destination — and Node's `fs.rename` does not pass that flag on Win32. So on
110
+ * Windows, a straight rename over an existing `settings.json` fails and
111
+ * `rea init` cannot update consumer settings.
112
+ *
113
+ * We handle this inline rather than taking a dependency: try `rename`; if it
114
+ * fails with `EEXIST` or `EPERM`, `unlink` the destination and retry. This
115
+ * opens a tiny window where the file is missing between unlink and rename, but
116
+ * a crash in that window leaves the `.tmp` file on disk as a recoverable
117
+ * artifact — strictly better than a corrupted merge. We prefer no dependency
118
+ * over `write-file-atomic`: every dep on a governance tool is a supply-chain
119
+ * surface, and this shim is small enough to audit here.
120
+ */
121
+ export async function writeSettingsAtomic(settingsPath, settings) {
122
+ const dir = path.dirname(settingsPath);
123
+ await fsPromises.mkdir(dir, { recursive: true });
124
+ const tmp = `${settingsPath}.tmp`;
125
+ const serialized = JSON.stringify(settings, null, 2) + '\n';
126
+ await fsPromises.writeFile(tmp, serialized, 'utf8');
127
+ try {
128
+ await fsPromises.rename(tmp, settingsPath);
129
+ }
130
+ catch (err) {
131
+ const code = err.code;
132
+ if (code !== 'EEXIST' && code !== 'EPERM') {
133
+ // Clean up the tmp file on unexpected failure so we don't leave litter.
134
+ await fsPromises.unlink(tmp).catch(() => {
135
+ /* best-effort; original error is the one that matters */
136
+ });
137
+ throw err;
138
+ }
139
+ // Windows: destination exists and rename refuses to replace it. Remove
140
+ // and retry. If the second rename also fails, propagate — something
141
+ // stranger is going on (permissions, read-only volume) that the operator
142
+ // needs to see.
143
+ await fsPromises.unlink(settingsPath);
144
+ try {
145
+ await fsPromises.rename(tmp, settingsPath);
146
+ }
147
+ catch (retryErr) {
148
+ await fsPromises.unlink(tmp).catch(() => {
149
+ /* best-effort cleanup */
150
+ });
151
+ throw retryErr;
152
+ }
153
+ }
154
+ }
155
+ /**
156
+ * Read `${targetDir}/.claude/settings.json` if present; return an empty object
157
+ * otherwise. The wizard/`--yes` path will then populate env defaults
158
+ * elsewhere; this function only concerns itself with structural parsing.
159
+ */
160
+ export function readSettings(targetDir) {
161
+ const settingsPath = path.join(targetDir, '.claude', 'settings.json');
162
+ if (!fs.existsSync(settingsPath)) {
163
+ return { settings: {}, settingsPath, existed: false };
164
+ }
165
+ const raw = fs.readFileSync(settingsPath, 'utf8');
166
+ try {
167
+ const parsed = JSON.parse(raw);
168
+ return { settings: parsed, settingsPath, existed: true };
169
+ }
170
+ catch (err) {
171
+ throw new Error(`Failed to parse ${settingsPath}: ${err instanceof Error ? err.message : err}`);
172
+ }
173
+ }
174
+ /**
175
+ * Stable SHA-256 of the rea-owned "desired hooks" subset. G12 uses this as
176
+ * the manifest entry for `.claude/settings.json` so drift detection only
177
+ * flags cases where a consumer deleted one of OUR entries — consumer-added
178
+ * entries remain invisible.
179
+ *
180
+ * Deterministic serialization: property order is fixed in-code (event,
181
+ * matcher, hooks → type, command, timeout, statusMessage), and the array
182
+ * order matches `defaultDesiredHooks()`. Any change to the desired set
183
+ * produces a new hash, which is the correct upgrade signal.
184
+ */
185
+ export function canonicalSettingsSubsetHash(groups) {
186
+ const canonical = groups.map((g) => ({
187
+ event: g.event,
188
+ matcher: g.matcher,
189
+ hooks: g.hooks.map((h) => {
190
+ const entry = { type: h.type, command: h.command };
191
+ if (h.timeout !== undefined)
192
+ entry.timeout = h.timeout;
193
+ if (h.statusMessage !== undefined)
194
+ entry.statusMessage = h.statusMessage;
195
+ return entry;
196
+ }),
197
+ }));
198
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
199
+ }
200
+ /**
201
+ * Desired hook registrations that `rea init` installs on every run. Mirrors
202
+ * the shape of the `.claude/settings.json` that this repo dogfoods. Keep in
203
+ * lockstep with `hooks/` filenames.
204
+ */
205
+ export function defaultDesiredHooks() {
206
+ const base = '"$CLAUDE_PROJECT_DIR"/.claude/hooks';
207
+ return [
208
+ {
209
+ event: 'PreToolUse',
210
+ matcher: 'Bash',
211
+ hooks: [
212
+ { type: 'command', command: `${base}/dangerous-bash-interceptor.sh`, timeout: 10000, statusMessage: 'Checking command safety...' },
213
+ { type: 'command', command: `${base}/env-file-protection.sh`, timeout: 5000, statusMessage: 'Checking for .env file reads...' },
214
+ { type: 'command', command: `${base}/dependency-audit-gate.sh`, timeout: 15000, statusMessage: 'Verifying package exists...' },
215
+ { type: 'command', command: `${base}/security-disclosure-gate.sh`, timeout: 5000, statusMessage: 'Checking disclosure policy...' },
216
+ { type: 'command', command: `${base}/pr-issue-link-gate.sh`, timeout: 5000, statusMessage: 'Checking PR for issue reference...' },
217
+ { type: 'command', command: `${base}/attribution-advisory.sh`, timeout: 5000, statusMessage: 'Checking for AI attribution...' },
218
+ { type: 'command', command: `${base}/push-review-gate.sh`, timeout: 30000, statusMessage: 'Running push review gate...' },
219
+ ],
220
+ },
221
+ {
222
+ event: 'PreToolUse',
223
+ matcher: 'Write|Edit',
224
+ hooks: [
225
+ { type: 'command', command: `${base}/secret-scanner.sh`, timeout: 15000, statusMessage: 'Scanning for credentials...' },
226
+ { type: 'command', command: `${base}/settings-protection.sh`, timeout: 5000, statusMessage: 'Checking settings protection...' },
227
+ { type: 'command', command: `${base}/blocked-paths-enforcer.sh`, timeout: 5000, statusMessage: 'Checking blocked paths...' },
228
+ { type: 'command', command: `${base}/changeset-security-gate.sh`, timeout: 5000, statusMessage: 'Checking changeset for security leaks...' },
229
+ ],
230
+ },
231
+ {
232
+ event: 'PostToolUse',
233
+ matcher: 'Write|Edit',
234
+ hooks: [
235
+ { type: 'command', command: `${base}/architecture-review-gate.sh`, timeout: 10000, statusMessage: 'Checking architecture impact...' },
236
+ ],
237
+ },
238
+ ];
239
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * SHA-256 helpers for the install manifest (G12). Lowercase hex, 64 chars.
3
+ *
4
+ * Reads source files via streaming so large files don't balloon memory —
5
+ * even though our canonical files are all small, the same helper is used
6
+ * for consumer-side drift detection and we don't want a surprise.
7
+ */
8
+ export declare function sha256OfBuffer(buf: Buffer | string): string;
9
+ export declare function sha256OfFile(filePath: string): Promise<string>;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * SHA-256 helpers for the install manifest (G12). Lowercase hex, 64 chars.
3
+ *
4
+ * Reads source files via streaming so large files don't balloon memory —
5
+ * even though our canonical files are all small, the same helper is used
6
+ * for consumer-side drift detection and we don't want a surprise.
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ import { createReadStream } from 'node:fs';
10
+ export function sha256OfBuffer(buf) {
11
+ return createHash('sha256').update(buf).digest('hex');
12
+ }
13
+ export function sha256OfFile(filePath) {
14
+ return new Promise((resolve, reject) => {
15
+ const hash = createHash('sha256');
16
+ const stream = createReadStream(filePath);
17
+ stream.on('data', (chunk) => hash.update(chunk));
18
+ stream.on('end', () => resolve(hash.digest('hex')));
19
+ stream.on('error', reject);
20
+ });
21
+ }
@@ -1 +1,12 @@
1
+ /**
2
+ * `rea serve` — start the MCP gateway.
3
+ *
4
+ * Loads `.rea/policy.yaml` and `.rea/registry.yaml`, builds the middleware
5
+ * chain, spawns downstream children from the registry, and connects an upstream
6
+ * stdio MCP server that clients (Claude Code, Helix, etc.) can talk to.
7
+ *
8
+ * Signals: SIGTERM and SIGINT both trigger a graceful shutdown. We do NOT exit
9
+ * on uncaughtException — that path is owned by `src/cli/index.ts`. If the
10
+ * gateway itself throws during startup we log and exit 1.
11
+ */
1
12
  export declare function runServe(): Promise<void>;
package/dist/cli/serve.js CHANGED
@@ -1,19 +1,85 @@
1
1
  import { loadPolicy } from '../policy/loader.js';
2
- import { POLICY_FILE, err, exitWithMissingPolicy, log, reaPath } from './utils.js';
2
+ import { loadRegistry } from '../registry/loader.js';
3
+ import { createGateway } from '../gateway/server.js';
4
+ import { CodexProbe } from '../gateway/observability/codex-probe.js';
5
+ import { POLICY_FILE, REGISTRY_FILE, err, exitWithMissingPolicy, log, reaPath, warn, } from './utils.js';
6
+ /**
7
+ * `rea serve` — start the MCP gateway.
8
+ *
9
+ * Loads `.rea/policy.yaml` and `.rea/registry.yaml`, builds the middleware
10
+ * chain, spawns downstream children from the registry, and connects an upstream
11
+ * stdio MCP server that clients (Claude Code, Helix, etc.) can talk to.
12
+ *
13
+ * Signals: SIGTERM and SIGINT both trigger a graceful shutdown. We do NOT exit
14
+ * on uncaughtException — that path is owned by `src/cli/index.ts`. If the
15
+ * gateway itself throws during startup we log and exit 1.
16
+ */
3
17
  export async function runServe() {
4
18
  const baseDir = process.cwd();
5
19
  const policyPath = reaPath(baseDir, POLICY_FILE);
20
+ const registryPath = reaPath(baseDir, REGISTRY_FILE);
21
+ let policy;
6
22
  try {
7
- const policy = loadPolicy(baseDir);
8
- log(`MCP gateway not yet implemented — install complete, policy loaded (profile=${policy.profile}, autonomy=${policy.autonomy_level}).`);
9
- process.exit(0);
23
+ policy = loadPolicy(baseDir);
10
24
  }
11
25
  catch (e) {
12
26
  const message = e instanceof Error ? e.message : String(e);
13
- if (message.includes('not found')) {
27
+ if (message.includes('not found'))
14
28
  exitWithMissingPolicy(policyPath);
15
- }
16
29
  err(`Failed to load policy: ${message}`);
17
30
  process.exit(1);
18
31
  }
32
+ let registry;
33
+ try {
34
+ registry = loadRegistry(baseDir);
35
+ }
36
+ catch (e) {
37
+ const message = e instanceof Error ? e.message : String(e);
38
+ if (message.includes('not found')) {
39
+ err(`Registry file not found: ${registryPath}`);
40
+ console.error('');
41
+ console.error(' Run `rea init` to create an empty registry, then edit it to declare downstream servers.');
42
+ console.error('');
43
+ process.exit(1);
44
+ }
45
+ err(`Failed to load registry: ${message}`);
46
+ process.exit(1);
47
+ }
48
+ const handle = createGateway({ baseDir, policy, registry });
49
+ // G11.3 — Codex availability probe. Observational only: a failed probe
50
+ // NEVER fail-closes the gateway at startup. When the policy explicitly
51
+ // opts out of Codex (`review.codex_required: false`), skip the probe
52
+ // entirely — there are no Codex calls to observe, so the probe would be
53
+ // noise on stderr.
54
+ const codexRequired = policy.review?.codex_required !== false;
55
+ let codexProbe;
56
+ if (codexRequired) {
57
+ codexProbe = new CodexProbe();
58
+ const initialState = await codexProbe.probe();
59
+ if (!initialState.cli_responsive) {
60
+ warn(`Codex probe failed — push-gate will use fallback reviewer path if triggered (${initialState.last_error ?? 'no error detail'})`);
61
+ }
62
+ codexProbe.start();
63
+ }
64
+ const shutdown = async (signal) => {
65
+ log(`rea serve: received ${signal} — draining and shutting down`);
66
+ codexProbe?.stop();
67
+ try {
68
+ await handle.stop();
69
+ }
70
+ catch (e) {
71
+ err(`shutdown error: ${e instanceof Error ? e.message : e}`);
72
+ }
73
+ process.exit(0);
74
+ };
75
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
76
+ process.on('SIGINT', () => void shutdown('SIGINT'));
77
+ log(`rea serve: policy profile=${policy.profile}, autonomy=${policy.autonomy_level}, downstream servers=${registry.servers.filter((s) => s.enabled).length}`);
78
+ try {
79
+ await handle.start();
80
+ }
81
+ catch (e) {
82
+ err(`gateway start failed: ${e instanceof Error ? e.message : e}`);
83
+ process.exit(1);
84
+ }
19
85
  }