@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
package/dist/cli/init.js CHANGED
@@ -3,14 +3,35 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import * as p from '@clack/prompts';
5
5
  import { AutonomyLevel } from '../policy/types.js';
6
- import { POLICY_FILE, REA_DIR, REGISTRY_FILE, err, getPkgVersion, log, } from './utils.js';
7
- const PROFILES = [
6
+ import { HARD_DEFAULTS, loadProfile, mergeProfiles } from '../policy/profiles.js';
7
+ import { copyArtifacts } from './install/copy.js';
8
+ import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
9
+ import { installCommitMsgHook } from './install/commit-msg.js';
10
+ import { buildFragment, writeClaudeMdFragment } from './install/claude-md.js';
11
+ import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
12
+ import { writeManifestAtomic } from './install/manifest-io.js';
13
+ import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
14
+ import { defaultReagentPath, ReagentDroppedFieldsError, translateReagentPolicy, } from './install/reagent.js';
15
+ import { POLICY_FILE, REA_DIR, REGISTRY_FILE, err, getPkgVersion, log, warn, } from './utils.js';
16
+ const PROFILE_NAMES = [
8
17
  'minimal',
9
18
  'client-engagement',
10
19
  'bst-internal',
20
+ 'bst-internal-no-codex',
11
21
  'lit-wc',
12
22
  'open-source',
23
+ 'open-source-no-codex',
13
24
  ];
25
+ /**
26
+ * Default value for the "Use Codex?" decision, derived from the profile
27
+ * name. Profiles whose name ends in `-no-codex` default to false;
28
+ * everything else defaults to true. This keeps the wizard aligned with
29
+ * whatever profile preset the operator picked without hard-coding a
30
+ * profile-to-bool map.
31
+ */
32
+ function profileDefaultCodexRequired(profileName) {
33
+ return !profileName.endsWith('-no-codex');
34
+ }
14
35
  const AUTONOMY_LEVELS = [
15
36
  AutonomyLevel.L0,
16
37
  AutonomyLevel.L1,
@@ -18,7 +39,7 @@ const AUTONOMY_LEVELS = [
18
39
  AutonomyLevel.L3,
19
40
  ];
20
41
  function detectReagentPolicy(targetDir) {
21
- const reagentPolicy = path.join(targetDir, '.reagent', 'policy.yaml');
42
+ const reagentPolicy = defaultReagentPath(targetDir);
22
43
  return fs.existsSync(reagentPolicy) ? reagentPolicy : null;
23
44
  }
24
45
  function detectProjectName(targetDir) {
@@ -39,13 +60,29 @@ function levelRank(level) {
39
60
  return { L0: 0, L1: 1, L2: 2, L3: 3 }[level];
40
61
  }
41
62
  function isValidProfile(value) {
42
- return PROFILES.includes(value);
63
+ return PROFILE_NAMES.includes(value);
43
64
  }
44
65
  function cancel(message) {
45
66
  p.cancel(message);
46
67
  process.exit(0);
47
68
  }
48
- async function runWizard(options, targetDir, reagentPolicyPath) {
69
+ /**
70
+ * Build the final layered profile in the documented merge order:
71
+ * hardDefaults ← profile ← reagentTranslation ← wizardAnswers
72
+ */
73
+ function resolveLayered(profileName, reagentTranslated) {
74
+ const profile = loadProfile(profileName);
75
+ if (profile === null) {
76
+ warn(`profile "${profileName}" not found on disk — using hard defaults only`);
77
+ return { ...HARD_DEFAULTS };
78
+ }
79
+ let layered = mergeProfiles(HARD_DEFAULTS, profile);
80
+ if (reagentTranslated !== null) {
81
+ layered = mergeProfiles(layered, reagentTranslated);
82
+ }
83
+ return layered;
84
+ }
85
+ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase) {
49
86
  const projectName = detectProjectName(targetDir);
50
87
  p.intro(`rea init — ${projectName}`);
51
88
  let fromReagent = options.fromReagent === true;
@@ -59,14 +96,14 @@ async function runWizard(options, targetDir, reagentPolicyPath) {
59
96
  fromReagent = migrate === true;
60
97
  }
61
98
  // Profile selection
62
- let profile;
99
+ let profileName;
63
100
  if (options.profile !== undefined) {
64
101
  if (!isValidProfile(options.profile)) {
65
- p.cancel(`Unknown profile: "${options.profile}". Valid: ${PROFILES.join(', ')}`);
102
+ p.cancel(`Unknown profile: "${options.profile}". Valid: ${PROFILE_NAMES.join(', ')}`);
66
103
  process.exit(1);
67
104
  }
68
- profile = options.profile;
69
- p.log.info(`Profile: ${profile} (from --profile)`);
105
+ profileName = options.profile;
106
+ p.log.info(`Profile: ${profileName} (from --profile)`);
70
107
  }
71
108
  else {
72
109
  const picked = await p.select({
@@ -74,11 +111,7 @@ async function runWizard(options, targetDir, reagentPolicyPath) {
74
111
  initialValue: 'minimal',
75
112
  options: [
76
113
  { value: 'minimal', label: 'minimal', hint: 'bare policy, no extras (default)' },
77
- {
78
- value: 'client-engagement',
79
- label: 'client-engagement',
80
- hint: 'zero-trust client project',
81
- },
114
+ { value: 'client-engagement', label: 'client-engagement', hint: 'zero-trust client project' },
82
115
  { value: 'bst-internal', label: 'bst-internal', hint: 'internal BST projects' },
83
116
  { value: 'lit-wc', label: 'lit-wc', hint: 'Lit / web component libraries' },
84
117
  { value: 'open-source', label: 'open-source', hint: 'public OSS repos' },
@@ -86,12 +119,12 @@ async function runWizard(options, targetDir, reagentPolicyPath) {
86
119
  });
87
120
  if (p.isCancel(picked))
88
121
  cancel('Init cancelled.');
89
- profile = picked;
122
+ profileName = picked;
90
123
  }
91
- // Autonomy level
124
+ const autonomyDefault = layeredBase.autonomy_level ?? AutonomyLevel.L1;
92
125
  const autonomyPick = await p.select({
93
126
  message: 'Starting autonomy_level',
94
- initialValue: AutonomyLevel.L1,
127
+ initialValue: autonomyDefault,
95
128
  options: [
96
129
  { value: AutonomyLevel.L0, label: 'L0', hint: 'read-only; every write needs approval' },
97
130
  { value: AutonomyLevel.L1, label: 'L1', hint: 'default — writes allowed, destructive gated' },
@@ -102,69 +135,111 @@ async function runWizard(options, targetDir, reagentPolicyPath) {
102
135
  if (p.isCancel(autonomyPick))
103
136
  cancel('Init cancelled.');
104
137
  const autonomyLevel = autonomyPick;
105
- // Max autonomy ceiling — constrain to levels >= autonomy
106
138
  const maxCandidates = AUTONOMY_LEVELS.filter((lvl) => levelRank(lvl) >= levelRank(autonomyLevel));
107
- const defaultMax = maxCandidates.find((l) => l === AutonomyLevel.L2) ?? autonomyLevel;
139
+ const defaultMax = (layeredBase.max_autonomy_level !== undefined &&
140
+ maxCandidates.includes(layeredBase.max_autonomy_level) &&
141
+ layeredBase.max_autonomy_level) ||
142
+ maxCandidates.find((l) => l === AutonomyLevel.L2) ||
143
+ autonomyLevel;
108
144
  const maxOptions = maxCandidates.map((lvl) => {
109
- if (lvl === autonomyLevel) {
145
+ if (lvl === autonomyLevel)
110
146
  return { value: lvl, label: lvl, hint: 'same as starting level' };
111
- }
112
147
  return { value: lvl, label: lvl };
113
148
  });
114
149
  const maxPick = await p.select({
115
150
  message: 'max_autonomy_level (ceiling — cannot be exceeded at runtime)',
116
151
  initialValue: defaultMax,
117
- // Cast: clack's Option type is a discriminated union over the literal values,
118
- // but here we build it dynamically from the AutonomyLevel enum.
119
152
  options: maxOptions,
120
153
  });
121
154
  if (p.isCancel(maxPick))
122
155
  cancel('Init cancelled.');
123
156
  const maxAutonomyLevel = maxPick;
124
- // block_ai_attribution
125
157
  const attribPick = await p.confirm({
126
158
  message: 'Enforce block_ai_attribution (reject AI-authored commit trailers)?',
127
- initialValue: true,
159
+ initialValue: layeredBase.block_ai_attribution ?? true,
128
160
  });
129
161
  if (p.isCancel(attribPick))
130
162
  cancel('Init cancelled.');
131
163
  const blockAiAttribution = attribPick === true;
132
- p.outro('Config collectedwriting files.');
164
+ // G11.4: "Use Codex adversarial review?" the default follows the
165
+ // chosen profile (any `*-no-codex` profile defaults to No). An explicit
166
+ // flag on the command line overrides that default for the initial value.
167
+ const codexInitial = options.codex !== undefined
168
+ ? options.codex
169
+ : profileDefaultCodexRequired(profileName);
170
+ const codexPick = await p.confirm({
171
+ message: 'Use Codex adversarial review? (requires an OpenAI account — can be added later)',
172
+ initialValue: codexInitial,
173
+ });
174
+ if (p.isCancel(codexPick))
175
+ cancel('Init cancelled.');
176
+ const codexRequired = codexPick === true;
177
+ p.outro('Config collected — installing files.');
133
178
  return {
134
- profile,
179
+ profile: profileName,
135
180
  autonomyLevel,
136
181
  maxAutonomyLevel,
137
182
  blockAiAttribution,
183
+ blockedPaths: layeredBase.blocked_paths ?? ['.env', '.env.*'],
184
+ notificationChannel: layeredBase.notification_channel ?? '',
185
+ codexRequired,
138
186
  fromReagent,
139
187
  reagentPolicyPath,
188
+ reagentNotices: [],
140
189
  };
141
190
  }
142
- function writePolicyYaml(targetDir, config) {
191
+ function writePolicyYaml(targetDir, config, layered) {
143
192
  const policyPath = path.join(targetDir, REA_DIR, POLICY_FILE);
144
193
  const installedBy = process.env.USER ?? os.userInfo().username ?? 'unknown';
145
194
  const installedAt = new Date().toISOString();
146
- const lines = [
147
- `# .rea/policy.yaml — managed by rea v${getPkgVersion()}`,
148
- `# Edit carefully: tightening takes effect on next load; loosening requires human approval.`,
149
- `version: "1"`,
150
- `profile: ${JSON.stringify(config.profile)}`,
151
- `installed_by: ${JSON.stringify(installedBy)}`,
152
- `installed_at: ${JSON.stringify(installedAt)}`,
153
- `autonomy_level: ${config.autonomyLevel}`,
154
- `max_autonomy_level: ${config.maxAutonomyLevel}`,
155
- `promotion_requires_human_approval: true`,
156
- `block_ai_attribution: ${config.blockAiAttribution ? 'true' : 'false'}`,
157
- `blocked_paths:`,
158
- ` - ".env"`,
159
- ` - ".env.*"`,
160
- `notification_channel: ""`,
161
- ``,
162
- ];
195
+ const lines = [];
196
+ lines.push(`# .rea/policy.yaml — managed by rea v${getPkgVersion()}`);
197
+ lines.push(`# Edit carefully: tightening takes effect on next load; loosening requires human approval.`);
198
+ lines.push(`version: "1"`);
199
+ lines.push(`profile: ${JSON.stringify(config.profile)}`);
200
+ lines.push(`installed_by: ${JSON.stringify(installedBy)}`);
201
+ lines.push(`installed_at: ${JSON.stringify(installedAt)}`);
202
+ lines.push(`autonomy_level: ${config.autonomyLevel}`);
203
+ lines.push(`max_autonomy_level: ${config.maxAutonomyLevel}`);
204
+ lines.push(`promotion_requires_human_approval: true`);
205
+ lines.push(`block_ai_attribution: ${config.blockAiAttribution ? 'true' : 'false'}`);
206
+ lines.push(`blocked_paths:`);
207
+ for (const bp of config.blockedPaths) {
208
+ lines.push(` - ${JSON.stringify(bp)}`);
209
+ }
210
+ lines.push(`notification_channel: ${JSON.stringify(config.notificationChannel)}`);
211
+ // Preserve injection_detection and context_protection if the layered profile
212
+ // carried them (e.g. bst-internal). These are pass-through fields.
213
+ if (layered.injection_detection !== undefined) {
214
+ lines.push(`injection_detection: ${layered.injection_detection}`);
215
+ }
216
+ if (layered.context_protection !== undefined) {
217
+ lines.push(`context_protection:`);
218
+ const cp = layered.context_protection;
219
+ if (cp.delegate_to_subagent !== undefined) {
220
+ lines.push(` delegate_to_subagent:`);
221
+ for (const cmd of cp.delegate_to_subagent) {
222
+ lines.push(` - ${JSON.stringify(cmd)}`);
223
+ }
224
+ }
225
+ if (cp.max_bash_output_lines !== undefined) {
226
+ lines.push(` max_bash_output_lines: ${cp.max_bash_output_lines}`);
227
+ }
228
+ }
229
+ // G11.4: always emit the review block explicitly. Making the value
230
+ // visible in the generated file helps the operator notice what was
231
+ // chosen at init time and simplifies switching modes later (edit a
232
+ // single line, no need to understand the default semantics).
233
+ lines.push(`review:`);
234
+ lines.push(` codex_required: ${config.codexRequired ? 'true' : 'false'}`);
235
+ lines.push(``);
163
236
  fs.writeFileSync(policyPath, lines.join('\n'), 'utf8');
164
237
  return policyPath;
165
238
  }
166
239
  function writeRegistryYaml(targetDir) {
167
240
  const registryPath = path.join(targetDir, REA_DIR, REGISTRY_FILE);
241
+ if (fs.existsSync(registryPath))
242
+ return registryPath;
168
243
  const content = [
169
244
  `# .rea/registry.yaml — downstream MCP servers proxied through rea serve.`,
170
245
  `# Every entry below is subject to the same middleware chain as native tool calls.`,
@@ -175,49 +250,187 @@ function writeRegistryYaml(targetDir) {
175
250
  fs.writeFileSync(registryPath, content, 'utf8');
176
251
  return registryPath;
177
252
  }
253
+ /**
254
+ * G12 — write `.rea/install-manifest.json` after `runInit` has copied all
255
+ * artifacts. SHAs are computed from the files on disk (so the manifest
256
+ * reflects actual state, not canonical-source state) with two synthetic
257
+ * entries for the rea-owned settings subset and the managed CLAUDE.md
258
+ * fragment.
259
+ */
260
+ async function writeInstallManifest(targetDir, profile, fragmentInput) {
261
+ const canonical = await enumerateCanonicalFiles();
262
+ const entries = [];
263
+ for (const c of canonical) {
264
+ const dst = path.join(targetDir, c.destRelPath);
265
+ // A file that was skipped during copy (e.g. --yes over existing) may not
266
+ // exist if the destination layout diverged — hash whatever is on disk.
267
+ // If it doesn't exist at all, fall back to hashing the source so the
268
+ // manifest still has a baseline.
269
+ const absPath = fs.existsSync(dst) ? dst : c.sourceAbsPath;
270
+ const sha = await sha256OfFile(absPath);
271
+ entries.push({ path: c.destRelPath, sha256: sha, source: c.source });
272
+ }
273
+ // Synthetic entries.
274
+ entries.push({
275
+ path: SETTINGS_MANIFEST_PATH,
276
+ sha256: canonicalSettingsSubsetHash(defaultDesiredHooks()),
277
+ source: 'settings',
278
+ });
279
+ entries.push({
280
+ path: CLAUDE_MD_MANIFEST_PATH,
281
+ sha256: sha256OfBuffer(buildFragment(fragmentInput)),
282
+ source: 'claude-md',
283
+ });
284
+ const manifest = {
285
+ version: getPkgVersion(),
286
+ profile,
287
+ installed_at: new Date().toISOString(),
288
+ files: entries,
289
+ };
290
+ return writeManifestAtomic(targetDir, manifest);
291
+ }
178
292
  export async function runInit(options) {
179
293
  const targetDir = process.cwd();
180
294
  const reagentPolicyPath = detectReagentPolicy(targetDir);
181
295
  const reaDir = path.join(targetDir, REA_DIR);
182
296
  const policyPath = path.join(reaDir, POLICY_FILE);
183
- if (fs.existsSync(policyPath) && options.yes !== true) {
297
+ if (fs.existsSync(policyPath) && options.yes !== true && options.force !== true) {
184
298
  err(`.rea/policy.yaml already exists at ${policyPath}`);
185
299
  console.error('');
186
- console.error(' Refusing to overwrite. Pass --yes to force, or remove the file first.');
300
+ console.error(' Refusing to overwrite. Pass --force to replace, or --yes to accept current settings.');
187
301
  console.error('');
188
302
  process.exit(1);
189
303
  }
304
+ // Select the profile name up front so we can load it for the layered base.
305
+ let profileName;
306
+ if (options.profile !== undefined && isValidProfile(options.profile)) {
307
+ profileName = options.profile;
308
+ }
309
+ else if (options.profile !== undefined) {
310
+ err(`Unknown profile: "${options.profile}". Valid: ${PROFILE_NAMES.join(', ')}`);
311
+ process.exit(1);
312
+ }
313
+ else {
314
+ profileName = 'minimal';
315
+ }
316
+ // Reagent translation, applied to the layered base before the wizard so
317
+ // defaults reflect the reagent values when the user confirms.
318
+ let reagentTranslated = null;
319
+ const reagentNotices = [];
320
+ const fromReagent = options.fromReagent === true;
321
+ if (fromReagent) {
322
+ if (reagentPolicyPath === null) {
323
+ err('--from-reagent passed but no .reagent/policy.yaml found');
324
+ process.exit(1);
325
+ }
326
+ const baseProfile = loadProfile(profileName);
327
+ const profileCeiling = baseProfile?.max_autonomy_level ??
328
+ HARD_DEFAULTS.max_autonomy_level ??
329
+ AutonomyLevel.L2;
330
+ try {
331
+ const t = translateReagentPolicy(reagentPolicyPath, {
332
+ profileCeiling,
333
+ acceptDropped: options.acceptDroppedFields === true,
334
+ });
335
+ reagentTranslated = t.translated;
336
+ reagentNotices.push(...t.notices);
337
+ }
338
+ catch (e) {
339
+ if (e instanceof ReagentDroppedFieldsError) {
340
+ err(e.message);
341
+ process.exit(1);
342
+ }
343
+ err(e instanceof Error ? e.message : String(e));
344
+ process.exit(1);
345
+ }
346
+ }
347
+ const layeredBase = resolveLayered(profileName, reagentTranslated);
190
348
  let config;
191
349
  if (options.yes === true) {
192
- const profile = options.profile !== undefined && isValidProfile(options.profile)
193
- ? options.profile
194
- : 'minimal';
350
+ // G11.4 non-interactive codex resolution:
351
+ // 1. Explicit --codex / --no-codex flag wins.
352
+ // 2. Otherwise derive from the profile name (`*-no-codex` → false).
353
+ const codexRequired = options.codex !== undefined
354
+ ? options.codex
355
+ : profileDefaultCodexRequired(profileName);
195
356
  config = {
196
- profile,
197
- autonomyLevel: AutonomyLevel.L1,
198
- maxAutonomyLevel: AutonomyLevel.L2,
199
- blockAiAttribution: true,
200
- fromReagent: options.fromReagent === true,
357
+ profile: profileName,
358
+ autonomyLevel: layeredBase.autonomy_level ?? AutonomyLevel.L1,
359
+ maxAutonomyLevel: layeredBase.max_autonomy_level ?? AutonomyLevel.L2,
360
+ blockAiAttribution: layeredBase.block_ai_attribution ?? true,
361
+ blockedPaths: layeredBase.blocked_paths ?? ['.env', '.env.*'],
362
+ notificationChannel: layeredBase.notification_channel ?? '',
363
+ codexRequired,
364
+ fromReagent,
201
365
  reagentPolicyPath,
366
+ reagentNotices,
202
367
  };
203
- log(`Non-interactive init: profile=${profile}, autonomy=L1, max=L2, attribution-block=true`);
368
+ log(`Non-interactive init: profile=${profileName}, autonomy=${config.autonomyLevel}, max=${config.maxAutonomyLevel}, attribution-block=${config.blockAiAttribution}, codex_required=${config.codexRequired}`);
204
369
  }
205
370
  else {
206
- config = await runWizard(options, targetDir, reagentPolicyPath);
371
+ config = await runWizard(options, targetDir, reagentPolicyPath, layeredBase);
372
+ config.reagentNotices = reagentNotices;
207
373
  }
208
- if (!fs.existsSync(reaDir)) {
374
+ if (!fs.existsSync(reaDir))
209
375
  fs.mkdirSync(reaDir, { recursive: true });
210
- }
211
376
  const written = [];
212
- written.push(writePolicyYaml(targetDir, config));
377
+ written.push(writePolicyYaml(targetDir, config, layeredBase));
213
378
  written.push(writeRegistryYaml(targetDir));
214
- // TODO: copy hooks/commands/agents once templates directory ships
215
- // TODO: merge .claude/settings.json once hook registration is defined
216
- // TODO: install .husky/commit-msg + .git/hooks/commit-msg for block_ai_attribution
379
+ // Artifact copies + settings merge + commit-msg + CLAUDE.md fragment.
380
+ const copyOptions = {
381
+ force: options.force === true,
382
+ yes: options.yes === true || options.force === true,
383
+ };
384
+ const copyResult = await copyArtifacts(targetDir, copyOptions);
385
+ const { settings, settingsPath } = readSettings(targetDir);
386
+ const desired = defaultDesiredHooks();
387
+ const mergeResult = mergeSettings(settings, desired);
388
+ await writeSettingsAtomic(settingsPath, mergeResult.merged);
389
+ const commitMsgResult = await installCommitMsgHook(targetDir);
390
+ const fragmentInput = {
391
+ policyPath: `.${path.sep}rea${path.sep}policy.yaml`.replace(/\\/g, '/'),
392
+ profile: config.profile,
393
+ autonomyLevel: config.autonomyLevel,
394
+ maxAutonomyLevel: config.maxAutonomyLevel,
395
+ blockedPathsCount: config.blockedPaths.length,
396
+ blockAiAttribution: config.blockAiAttribution,
397
+ };
398
+ const mdResult = await writeClaudeMdFragment(targetDir, fragmentInput);
399
+ // G12 — record the install manifest. SHAs are of the files actually on disk
400
+ // after the copy pass, so drift detection compares against real state (not
401
+ // canonical, which may differ if the consumer's copy was aborted mid-run).
402
+ const manifestPath = await writeInstallManifest(targetDir, config.profile, fragmentInput);
217
403
  console.log('');
218
404
  log('init complete');
219
- for (const file of written) {
405
+ for (const file of written)
220
406
  console.log(` + ${path.relative(targetDir, file)}`);
407
+ console.log(` + .claude/ (${copyResult.copied.length} copied, ${copyResult.overwritten.length} overwritten, ${copyResult.skipped.length} skipped)`);
408
+ console.log(` + .claude/settings.json (${mergeResult.addedCount} hook entries added, ${mergeResult.skippedCount} already present)`);
409
+ if (commitMsgResult.gitHook)
410
+ console.log(` + ${path.relative(targetDir, commitMsgResult.gitHook)}`);
411
+ if (commitMsgResult.huskyHook)
412
+ console.log(` + ${path.relative(targetDir, commitMsgResult.huskyHook)}`);
413
+ console.log(` ${mdResult.replaced ? '~' : '+'} ${path.relative(targetDir, mdResult.path)} (fragment ${mdResult.replaced ? 'replaced' : 'written'})`);
414
+ console.log(` + ${path.relative(targetDir, manifestPath)}`);
415
+ if (mergeResult.warnings.length > 0) {
416
+ console.log('');
417
+ for (const w of mergeResult.warnings)
418
+ warn(w);
419
+ }
420
+ for (const w of commitMsgResult.warnings)
421
+ warn(w);
422
+ for (const n of config.reagentNotices)
423
+ warn(n);
424
+ // G11.4: when Codex review is disabled, print a durable notice. Mentions
425
+ // the exact edit path so the operator can flip back later without having
426
+ // to re-run init. (Coupling note: a future G6-style "Codex install
427
+ // assist" prompt belongs here too, and should short-circuit when
428
+ // codex_required is false — do not invoke install-assist in no-codex
429
+ // mode.)
430
+ if (!config.codexRequired) {
431
+ console.log('');
432
+ console.log('Codex review disabled. ClaudeSelfReviewer will be used.');
433
+ console.log(' Set review.codex_required: true in .rea/policy.yaml to re-enable.');
221
434
  }
222
435
  console.log('');
223
436
  console.log('Next steps:');
@@ -228,10 +441,8 @@ export async function runInit(options) {
228
441
  console.log('');
229
442
  console.log('Reagent migration:');
230
443
  console.log(` Source: ${config.reagentPolicyPath ?? '(none)'}`);
231
- console.log(' Field-for-field translation is not yet automated review both files manually.');
444
+ console.log(' Copied fields were applied per the translator rules.');
232
445
  console.log(' Once satisfied, you can remove the .reagent/ directory.');
233
446
  }
234
447
  console.log('');
235
- console.log(' Hooks, slash commands, and agents are not installed yet — coming in a follow-up.');
236
- console.log('');
237
448
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * G12 — Canonical file enumeration.
3
+ *
4
+ * Single source of truth for "what ships in this rea version". Used by:
5
+ * - `rea init` — to record SHAs of what was installed
6
+ * - `rea upgrade` — to classify consumer files (new/unmodified/drifted/removed)
7
+ * - `rea doctor --drift` — to report drift without mutating
8
+ *
9
+ * The function walks `PKG_ROOT/{hooks,agents,commands,.husky}` at runtime.
10
+ * Mirrors the layout in `copy.ts` but is pure enumeration — no writes, no
11
+ * symlink guards (those live on the write side).
12
+ */
13
+ import type { SourceKind } from './manifest-schema.js';
14
+ export interface CanonicalFile {
15
+ /** Absolute path to the source file inside PKG_ROOT. */
16
+ sourceAbsPath: string;
17
+ /** POSIX-normalized consumer-relative destination path (what goes in the manifest `path`). */
18
+ destRelPath: string;
19
+ source: SourceKind;
20
+ /** Desired mode at install; hooks get 0o755, everything else 0o644. */
21
+ mode: number;
22
+ }
23
+ /**
24
+ * Enumerate every file this rea version installs. Ordering is stable (sorted
25
+ * by absolute source path) so manifests are deterministic across runs.
26
+ *
27
+ * The `.husky/` mapping only emits files actually present in the packaged
28
+ * `.husky/` directory — so when a new hook ships (e.g. `pre-push`) it
29
+ * automatically becomes part of the upgrade surface without code changes.
30
+ */
31
+ export declare function enumerateCanonicalFiles(pkgRoot?: string): Promise<CanonicalFile[]>;
32
+ /**
33
+ * Synthetic canonical entry for the managed CLAUDE.md fragment. Hashed
34
+ * separately via `sha256OfBuffer(fragment)` because the SHA tracks fragment
35
+ * content, not the full CLAUDE.md (consumer owns the rest of the file).
36
+ */
37
+ export declare const CLAUDE_MD_MANIFEST_PATH = "CLAUDE.md#rea:managed:v1";
38
+ /**
39
+ * Synthetic canonical entry for the rea-owned subset of `.claude/settings.json`.
40
+ * The SHA tracks what we own (the desired hooks) — never the full file, so a
41
+ * consumer adding their own hook entries does not register as drift.
42
+ */
43
+ export declare const SETTINGS_MANIFEST_PATH = ".claude/settings.json#rea:desired";
@@ -0,0 +1,101 @@
1
+ /**
2
+ * G12 — Canonical file enumeration.
3
+ *
4
+ * Single source of truth for "what ships in this rea version". Used by:
5
+ * - `rea init` — to record SHAs of what was installed
6
+ * - `rea upgrade` — to classify consumer files (new/unmodified/drifted/removed)
7
+ * - `rea doctor --drift` — to report drift without mutating
8
+ *
9
+ * The function walks `PKG_ROOT/{hooks,agents,commands,.husky}` at runtime.
10
+ * Mirrors the layout in `copy.ts` but is pure enumeration — no writes, no
11
+ * symlink guards (those live on the write side).
12
+ */
13
+ import fsPromises from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { PKG_ROOT } from '../utils.js';
16
+ function toPosix(p) {
17
+ return p.split(path.sep).join('/');
18
+ }
19
+ async function walkFiles(srcDir) {
20
+ const out = [];
21
+ async function walk(dir) {
22
+ let entries;
23
+ try {
24
+ entries = await fsPromises.readdir(dir, { withFileTypes: true });
25
+ }
26
+ catch (err) {
27
+ if (err.code === 'ENOENT')
28
+ return;
29
+ throw err;
30
+ }
31
+ for (const entry of entries) {
32
+ const abs = path.join(dir, entry.name);
33
+ // `Dirent.isFile()` / `isDirectory()` return false for symlinks when
34
+ // `readdir` is called with `withFileTypes: true` — the link would be
35
+ // silently dropped from enumeration. That is exactly the primitive a
36
+ // supply-chain attacker would exploit: planting a symlink at
37
+ // `.husky/pre-push` in the published tarball causes the canonical set
38
+ // to lose the hook, which in turn causes `rea upgrade` to classify the
39
+ // consumer's on-disk copy as `removed-upstream` and prompt to delete
40
+ // it. Refuse loudly instead.
41
+ if (entry.isSymbolicLink()) {
42
+ throw new Error(`canonical source contains symlink at ${abs} — refusing to enumerate; ` +
43
+ `audit the package tree before shipping.`);
44
+ }
45
+ if (entry.isDirectory()) {
46
+ await walk(abs);
47
+ }
48
+ else if (entry.isFile()) {
49
+ out.push(abs);
50
+ }
51
+ // sockets, FIFOs, devices silently ignored — they cannot originate
52
+ // from a tarball so their presence is an operational anomaly, not a
53
+ // security event.
54
+ }
55
+ }
56
+ await walk(srcDir);
57
+ out.sort();
58
+ return out;
59
+ }
60
+ /**
61
+ * Enumerate every file this rea version installs. Ordering is stable (sorted
62
+ * by absolute source path) so manifests are deterministic across runs.
63
+ *
64
+ * The `.husky/` mapping only emits files actually present in the packaged
65
+ * `.husky/` directory — so when a new hook ships (e.g. `pre-push`) it
66
+ * automatically becomes part of the upgrade surface without code changes.
67
+ */
68
+ export async function enumerateCanonicalFiles(pkgRoot = PKG_ROOT) {
69
+ const mappings = [
70
+ { srcDir: path.join(pkgRoot, 'hooks'), dstPrefix: '.claude/hooks', source: 'hook', mode: 0o755 },
71
+ { srcDir: path.join(pkgRoot, 'agents'), dstPrefix: '.claude/agents', source: 'agent', mode: 0o644 },
72
+ { srcDir: path.join(pkgRoot, 'commands'), dstPrefix: '.claude/commands', source: 'command', mode: 0o644 },
73
+ { srcDir: path.join(pkgRoot, '.husky'), dstPrefix: '.husky', source: 'husky', mode: 0o755 },
74
+ ];
75
+ const out = [];
76
+ for (const m of mappings) {
77
+ const files = await walkFiles(m.srcDir);
78
+ for (const abs of files) {
79
+ const rel = path.relative(m.srcDir, abs);
80
+ out.push({
81
+ sourceAbsPath: abs,
82
+ destRelPath: toPosix(path.join(m.dstPrefix, rel)),
83
+ source: m.source,
84
+ mode: m.mode,
85
+ });
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+ /**
91
+ * Synthetic canonical entry for the managed CLAUDE.md fragment. Hashed
92
+ * separately via `sha256OfBuffer(fragment)` because the SHA tracks fragment
93
+ * content, not the full CLAUDE.md (consumer owns the rest of the file).
94
+ */
95
+ export const CLAUDE_MD_MANIFEST_PATH = 'CLAUDE.md#rea:managed:v1';
96
+ /**
97
+ * Synthetic canonical entry for the rea-owned subset of `.claude/settings.json`.
98
+ * The SHA tracks what we own (the desired hooks) — never the full file, so a
99
+ * consumer adding their own hook entries does not register as drift.
100
+ */
101
+ export const SETTINGS_MANIFEST_PATH = '.claude/settings.json#rea:desired';