@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.
- package/.husky/commit-msg +130 -0
- package/.husky/pre-push +128 -0
- package/README.md +5 -5
- package/agents/codex-adversarial.md +23 -8
- package/commands/codex-review.md +2 -2
- package/dist/audit/append.d.ts +62 -0
- package/dist/audit/append.js +189 -0
- package/dist/audit/codex-event.d.ts +28 -0
- package/dist/audit/codex-event.js +15 -0
- package/dist/cli/doctor.d.ts +60 -1
- package/dist/cli/doctor.js +459 -20
- package/dist/cli/index.js +35 -5
- package/dist/cli/init.d.ts +13 -0
- package/dist/cli/init.js +278 -67
- package/dist/cli/install/canonical.d.ts +43 -0
- package/dist/cli/install/canonical.js +101 -0
- package/dist/cli/install/claude-md.d.ts +48 -0
- package/dist/cli/install/claude-md.js +93 -0
- package/dist/cli/install/commit-msg.d.ts +30 -0
- package/dist/cli/install/commit-msg.js +102 -0
- package/dist/cli/install/copy.d.ts +169 -0
- package/dist/cli/install/copy.js +455 -0
- package/dist/cli/install/fs-safe.d.ts +91 -0
- package/dist/cli/install/fs-safe.js +347 -0
- package/dist/cli/install/manifest-io.d.ts +12 -0
- package/dist/cli/install/manifest-io.js +44 -0
- package/dist/cli/install/manifest-schema.d.ts +83 -0
- package/dist/cli/install/manifest-schema.js +80 -0
- package/dist/cli/install/reagent.d.ts +59 -0
- package/dist/cli/install/reagent.js +160 -0
- package/dist/cli/install/settings-merge.d.ts +91 -0
- package/dist/cli/install/settings-merge.js +239 -0
- package/dist/cli/install/sha.d.ts +9 -0
- package/dist/cli/install/sha.js +21 -0
- package/dist/cli/serve.d.ts +11 -0
- package/dist/cli/serve.js +72 -6
- package/dist/cli/upgrade.d.ts +67 -0
- package/dist/cli/upgrade.js +509 -0
- package/dist/gateway/downstream-pool.d.ts +39 -0
- package/dist/gateway/downstream-pool.js +93 -0
- package/dist/gateway/downstream.d.ts +80 -0
- package/dist/gateway/downstream.js +196 -0
- package/dist/gateway/middleware/audit-types.d.ts +10 -0
- package/dist/gateway/middleware/audit.js +14 -0
- package/dist/gateway/middleware/injection.d.ts +59 -2
- package/dist/gateway/middleware/injection.js +91 -14
- package/dist/gateway/middleware/kill-switch.d.ts +20 -5
- package/dist/gateway/middleware/kill-switch.js +57 -35
- package/dist/gateway/middleware/redact.d.ts +83 -6
- package/dist/gateway/middleware/redact.js +133 -46
- package/dist/gateway/observability/codex-probe.d.ts +110 -0
- package/dist/gateway/observability/codex-probe.js +234 -0
- package/dist/gateway/observability/codex-telemetry.d.ts +93 -0
- package/dist/gateway/observability/codex-telemetry.js +221 -0
- package/dist/gateway/redact-safe/match-timeout.d.ts +83 -0
- package/dist/gateway/redact-safe/match-timeout.js +179 -0
- package/dist/gateway/reviewers/claude-self.d.ts +99 -0
- package/dist/gateway/reviewers/claude-self.js +316 -0
- package/dist/gateway/reviewers/codex.d.ts +64 -0
- package/dist/gateway/reviewers/codex.js +80 -0
- package/dist/gateway/reviewers/select.d.ts +64 -0
- package/dist/gateway/reviewers/select.js +102 -0
- package/dist/gateway/reviewers/types.d.ts +85 -0
- package/dist/gateway/reviewers/types.js +14 -0
- package/dist/gateway/server.d.ts +51 -0
- package/dist/gateway/server.js +258 -0
- package/dist/gateway/session.d.ts +9 -0
- package/dist/gateway/session.js +17 -0
- package/dist/policy/loader.d.ts +59 -0
- package/dist/policy/loader.js +65 -0
- package/dist/policy/profiles.d.ts +80 -0
- package/dist/policy/profiles.js +94 -0
- package/dist/policy/types.d.ts +38 -0
- package/dist/registry/loader.d.ts +98 -0
- package/dist/registry/loader.js +153 -0
- package/dist/registry/types.d.ts +44 -0
- package/dist/registry/types.js +6 -0
- package/dist/scripts/read-policy-field.d.ts +36 -0
- package/dist/scripts/read-policy-field.js +96 -0
- package/hooks/push-review-gate.sh +627 -17
- package/package.json +13 -2
- package/profiles/bst-internal-no-codex.yaml +40 -0
- package/profiles/bst-internal.yaml +23 -0
- package/profiles/client-engagement.yaml +23 -0
- package/profiles/lit-wc.yaml +17 -0
- package/profiles/minimal.yaml +11 -0
- package/profiles/open-source-no-codex.yaml +33 -0
- package/profiles/open-source.yaml +18 -0
- package/scripts/lint-safe-regex.mjs +78 -0
- 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 {
|
|
7
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
99
|
+
let profileName;
|
|
63
100
|
if (options.profile !== undefined) {
|
|
64
101
|
if (!isValidProfile(options.profile)) {
|
|
65
|
-
p.cancel(`Unknown profile: "${options.profile}". Valid: ${
|
|
102
|
+
p.cancel(`Unknown profile: "${options.profile}". Valid: ${PROFILE_NAMES.join(', ')}`);
|
|
66
103
|
process.exit(1);
|
|
67
104
|
}
|
|
68
|
-
|
|
69
|
-
p.log.info(`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
|
-
|
|
122
|
+
profileName = picked;
|
|
90
123
|
}
|
|
91
|
-
|
|
124
|
+
const autonomyDefault = layeredBase.autonomy_level ?? AutonomyLevel.L1;
|
|
92
125
|
const autonomyPick = await p.select({
|
|
93
126
|
message: 'Starting autonomy_level',
|
|
94
|
-
initialValue:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
` -
|
|
160
|
-
|
|
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 --
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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=${
|
|
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
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
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('
|
|
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';
|