claude-dev-env 7.6.1 → 7.7.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/.agents/skills/e-code-review/reference/fix.md +4 -2
- package/.agents/skills/e-code-review/reference/loop.md +4 -2
- package/AGENTS.md +48 -71
- package/_shared/advisor/advisor-protocol.md +2 -2
- package/_shared/advisor/reference/advisor-block.md +1 -1
- package/_shared/advisor/reference/astra-rung.md +2 -2
- package/_shared/advisor/scripts/codex_astra_advisor.py +122 -23
- package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +76 -9
- package/_shared/advisor/scripts/tests/test_advisor_route_constants.py +51 -0
- package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +52 -2
- package/bin/install-constants.mjs +3 -0
- package/bin/install-transaction.mjs +41 -44
- package/bin/install.cursor-rules.test.mjs +311 -3
- package/bin/install.mjs +156 -95
- package/bin/install.transaction.test.mjs +61 -1
- package/bin/pstack-model-preferences.codex.json +1 -0
- package/bin/pstack.mjs +28 -6
- package/bin/pstack.test.mjs +48 -0
- package/bin/select-pstack-models.test.mjs +424 -26
- package/bin/subagent-model-policy.test.mjs +436 -0
- package/docs/codex-compatibility.md +19 -5
- package/docs/subagent-model-routing.md +69 -0
- package/hooks/hooks.json +10 -0
- package/hooks/routing/subagent_model_routing.mjs +81 -0
- package/hooks/routing/subagent_model_routing.test.mjs +255 -0
- package/package.json +1 -1
- package/rules/pstack-models.md +22 -0
- package/rules/subagent-model-policy.json +85 -0
- package/scripts/resolve_advisor_model_route.mjs +52 -0
- package/scripts/select_pstack_models.mjs +203 -38
- package/scripts/subagent_model_policy.mjs +620 -0
- package/scripts/tests/resolve_advisor_model_route.test.mjs +51 -0
- package/scripts/tests/test_codex_compat_materializer.py +15 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""Behavioral tests for the executable Codex Astra advisor path."""
|
|
2
|
-
import io
|
|
3
2
|
import importlib.util
|
|
3
|
+
import io
|
|
4
4
|
import json
|
|
5
5
|
import subprocess
|
|
6
6
|
import sys
|
|
7
7
|
from collections.abc import Callable
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from types import ModuleType
|
|
10
|
+
|
|
10
11
|
import pytest
|
|
11
12
|
|
|
12
13
|
_ProcessRunner = Callable[..., subprocess.CompletedProcess[str]]
|
|
@@ -28,6 +29,7 @@ def _load_astra_module() -> ModuleType:
|
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
astra_advisor = _load_astra_module()
|
|
32
|
+
|
|
31
33
|
SCRIPTS_ROOT = Path(__file__).parent.parent
|
|
32
34
|
USAGE_PROBE_PATH = (
|
|
33
35
|
SCRIPTS_ROOT.parents[1] / "pr-loop" / "scripts" / "codex_usage_probe.py"
|
|
@@ -87,7 +89,37 @@ def test_astra_flag_rejects_legacy_sol_setting() -> None:
|
|
|
87
89
|
|
|
88
90
|
def test_resolve_advisor_effort_uses_shared_setting_and_default() -> None:
|
|
89
91
|
assert astra_advisor.resolve_advisor_effort({"ADVISOR_EFFORT": "HIGH"}) == "high"
|
|
90
|
-
|
|
92
|
+
with pytest.raises(RuntimeError, match="effort is unknown"):
|
|
93
|
+
astra_advisor.resolve_advisor_effort({"ADVISOR_EFFORT": "unknown"})
|
|
94
|
+
assert astra_advisor.resolve_advisor_effort({"ADVISOR_EFFORT": "Light"}) == "low"
|
|
95
|
+
assert astra_advisor.resolve_advisor_effort({"ADVISOR_EFFORT": "XHIGH"}) == "medium"
|
|
96
|
+
assert astra_advisor.resolve_advisor_effort({"ADVISOR_EFFORT": "MAX"}) == "medium"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_advisor_route_uses_edited_policy_model_and_effort(tmp_path: Path) -> None:
|
|
100
|
+
policy = json.loads(
|
|
101
|
+
(SCRIPTS_ROOT.parents[2] / "rules" / "subagent-model-policy.json").read_text(
|
|
102
|
+
encoding="utf-8"
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
for each_entry in policy["roleReplacements"]:
|
|
106
|
+
if (
|
|
107
|
+
each_entry["role"] == "advisor"
|
|
108
|
+
and each_entry["requested"] == {"model": "astra", "effort": "xhigh"}
|
|
109
|
+
):
|
|
110
|
+
each_entry["selected"] = {"model": "luna", "effort": "high"}
|
|
111
|
+
policy_path = tmp_path / "subagent-model-policy.json"
|
|
112
|
+
policy_path.write_text(json.dumps(policy), encoding="utf-8")
|
|
113
|
+
assert astra_advisor.resolve_advisor_pair(
|
|
114
|
+
{"ADVISOR_EFFORT": "xhigh"}, policy_path
|
|
115
|
+
) == ("gpt-5.6-luna", "high")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_advisor_route_blocks_malformed_policy(tmp_path: Path) -> None:
|
|
119
|
+
policy_path = tmp_path / "subagent-model-policy.json"
|
|
120
|
+
policy_path.write_text('{"schemaVersion": 1}', encoding="utf-8")
|
|
121
|
+
with pytest.raises(RuntimeError, match="models must be an object"):
|
|
122
|
+
astra_advisor.resolve_advisor_pair({}, policy_path)
|
|
91
123
|
|
|
92
124
|
|
|
93
125
|
def test_resolve_codex_executable_prefers_override_and_falls_back_to_path(
|
|
@@ -118,6 +150,7 @@ def test_argument_parser_accepts_astra_and_rejects_sol() -> None:
|
|
|
118
150
|
parser = astra_advisor.build_argument_parser()
|
|
119
151
|
parsed = parser.parse_args(["--bind", "--cwd", ".", "--enable-astra"])
|
|
120
152
|
assert parsed.is_astra_requested
|
|
153
|
+
assert parser.parse_args(["--bind", "--cwd", ".", "--effort", "light"]).astra_effort == "light"
|
|
121
154
|
with pytest.raises(SystemExit):
|
|
122
155
|
parser.parse_args(["--bind", "--cwd", ".", "--enable-sol"])
|
|
123
156
|
|
|
@@ -190,6 +223,23 @@ def test_bind_runs_probe_then_codex() -> None:
|
|
|
190
223
|
assert calls[1] == astra_advisor.build_codex_arguments("codex")
|
|
191
224
|
|
|
192
225
|
|
|
226
|
+
def test_bind_routes_astra_xhigh_to_the_policy_effort() -> None:
|
|
227
|
+
calls: list[list[str]] = []
|
|
228
|
+
settings = {**ENABLED_SETTINGS, astra_advisor.ADVISOR_EFFORT_ENV_VAR: "xhigh"}
|
|
229
|
+
reply = astra_advisor.run_codex_astra_advisor(
|
|
230
|
+
"consult",
|
|
231
|
+
Path("."),
|
|
232
|
+
None,
|
|
233
|
+
USAGE_PROBE_PATH,
|
|
234
|
+
settings,
|
|
235
|
+
None,
|
|
236
|
+
_two_step_runner(calls),
|
|
237
|
+
)
|
|
238
|
+
assert reply.successful
|
|
239
|
+
config_index = calls[1].index("--config")
|
|
240
|
+
assert calls[1][config_index + 1] == 'model_reasoning_effort="medium"'
|
|
241
|
+
|
|
242
|
+
|
|
193
243
|
def test_disabled_flag_returns_declined_fallback() -> None:
|
|
194
244
|
reply = astra_advisor.run_codex_astra_advisor(
|
|
195
245
|
"consult",
|
|
@@ -56,6 +56,7 @@ export const RUN_BACKUP_DIRECTORY_NAME_PATTERN =
|
|
|
56
56
|
export const PACKAGE_AGENTS_HOME_DIRECTORY_NAME = '.agents';
|
|
57
57
|
|
|
58
58
|
export const PSTACK_MODEL_RULE_FILE_NAME = 'pstack-models.mdc';
|
|
59
|
+
export const SUBAGENT_MODEL_POLICY_FILE_NAME = 'subagent-model-policy.json';
|
|
59
60
|
export const PSTACK_SEEDED_MODEL_PREFERENCES_FILE_NAMES = Object.freeze([
|
|
60
61
|
'pstack-model-preferences.claude.json',
|
|
61
62
|
'pstack-model-preferences.codex.json',
|
|
@@ -181,3 +182,5 @@ export const WINDOWS_PYTHON_LAUNCHER_COMMAND = 'py -3';
|
|
|
181
182
|
* Maximum time each Python interpreter probe may run during detection.
|
|
182
183
|
*/
|
|
183
184
|
export const PYTHON_PROBE_TIMEOUT_MILLISECONDS = 10_000;
|
|
185
|
+
export const ADDITIONAL_SETTINGS_BLOB_PREFIX = 'settings-';
|
|
186
|
+
export const CODEX_NATIVE_ROUTING_MATCHER = 'multi_agent_v1__spawn_agent';
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from 'node:fs';
|
|
31
31
|
import { dirname, join } from 'node:path';
|
|
32
32
|
import { execFileSync } from 'node:child_process';
|
|
33
|
+
import { ADDITIONAL_SETTINGS_BLOB_PREFIX } from './install-constants.mjs';
|
|
33
34
|
|
|
34
35
|
/** Env var that forces a throw after a named mutation phase (tests only). */
|
|
35
36
|
export const INSTALL_FAULT_ENV = 'CLAUDE_DEV_ENV_INSTALL_FAULT';
|
|
@@ -180,19 +181,6 @@ function classifyPath(absolutePath, io) {
|
|
|
180
181
|
return { kind: ENTRY_KIND_MISSING };
|
|
181
182
|
}
|
|
182
183
|
|
|
183
|
-
/**
|
|
184
|
-
* Capture prior settings, manifest, hooksPath, and package-owned files.
|
|
185
|
-
*
|
|
186
|
-
* @param {{
|
|
187
|
-
* managedRoot: string,
|
|
188
|
-
* manifestFilePath: string,
|
|
189
|
-
* settingsPath: string,
|
|
190
|
-
* priorManifestFiles?: string[]|null,
|
|
191
|
-
* journalParentDirectory?: string,
|
|
192
|
-
* io?: object,
|
|
193
|
-
* }} input
|
|
194
|
-
* @returns {object}
|
|
195
|
-
*/
|
|
196
184
|
export function capturePriorInstallSnapshot(input) {
|
|
197
185
|
const io = input.io || {};
|
|
198
186
|
const exists = io.existsSync || existsSync;
|
|
@@ -208,10 +196,23 @@ export function capturePriorInstallSnapshot(input) {
|
|
|
208
196
|
const filesDirectory = join(journalRoot, FILES_DIRECTORY_NAME);
|
|
209
197
|
mkdir(filesDirectory, { recursive: true });
|
|
210
198
|
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
199
|
+
const allSettingsPaths = [
|
|
200
|
+
input.settingsPath,
|
|
201
|
+
...(Array.isArray(input.additionalSettingsPaths) ? input.additionalSettingsPaths : []),
|
|
202
|
+
].filter((eachPath, index, allPaths) => (
|
|
203
|
+
typeof eachPath === 'string'
|
|
204
|
+
&& eachPath !== ''
|
|
205
|
+
&& allPaths.indexOf(eachPath) === index
|
|
206
|
+
));
|
|
207
|
+
const settingsSnapshots = allSettingsPaths.map((settingsPath, index) => {
|
|
208
|
+
const settingsExisted = exists(settingsPath);
|
|
209
|
+
const blobName = index === 0
|
|
210
|
+
? SETTINGS_BLOB_NAME
|
|
211
|
+
: `${ADDITIONAL_SETTINGS_BLOB_PREFIX}${index}.json`;
|
|
212
|
+
if (settingsExisted) copyFile(settingsPath, join(journalRoot, blobName));
|
|
213
|
+
return { path: settingsPath, existed: settingsExisted, blobName };
|
|
214
|
+
});
|
|
215
|
+
const primarySettingsSnapshot = settingsSnapshots[0];
|
|
215
216
|
|
|
216
217
|
const manifestExisted = exists(input.manifestFilePath);
|
|
217
218
|
if (manifestExisted) {
|
|
@@ -255,7 +256,8 @@ export function capturePriorInstallSnapshot(input) {
|
|
|
255
256
|
managedRoot: input.managedRoot,
|
|
256
257
|
settingsPath: input.settingsPath,
|
|
257
258
|
manifestFilePath: input.manifestFilePath,
|
|
258
|
-
|
|
259
|
+
settingsSnapshots,
|
|
260
|
+
settingsExisted: primarySettingsSnapshot?.existed ?? false,
|
|
259
261
|
manifestExisted,
|
|
260
262
|
priorHooksPath,
|
|
261
263
|
allFileEntries,
|
|
@@ -267,23 +269,14 @@ export function capturePriorInstallSnapshot(input) {
|
|
|
267
269
|
managedRoot: input.managedRoot,
|
|
268
270
|
settingsPath: input.settingsPath,
|
|
269
271
|
manifestFilePath: input.manifestFilePath,
|
|
270
|
-
|
|
272
|
+
settingsSnapshots: Object.freeze(settingsSnapshots.map(eachSnapshot => Object.freeze(eachSnapshot))),
|
|
273
|
+
settingsExisted: primarySettingsSnapshot?.existed ?? false,
|
|
271
274
|
manifestExisted,
|
|
272
275
|
priorHooksPath,
|
|
273
276
|
allFileEntries: Object.freeze(allFileEntries.map((eachEntry) => Object.freeze({ ...eachEntry }))),
|
|
274
277
|
});
|
|
275
278
|
}
|
|
276
279
|
|
|
277
|
-
/**
|
|
278
|
-
* Restore the captured prior installation and drop extra write-set paths.
|
|
279
|
-
*
|
|
280
|
-
* @param {ReturnType<typeof capturePriorInstallSnapshot>} snapshot
|
|
281
|
-
* @param {{
|
|
282
|
-
* allWrittenPaths?: string[],
|
|
283
|
-
* io?: object,
|
|
284
|
-
* }} [options]
|
|
285
|
-
* @returns {void}
|
|
286
|
-
*/
|
|
287
280
|
export function restorePriorInstallSnapshot(snapshot, options = {}) {
|
|
288
281
|
const io = options.io || {};
|
|
289
282
|
const exists = io.existsSync || existsSync;
|
|
@@ -294,6 +287,13 @@ export function restorePriorInstallSnapshot(snapshot, options = {}) {
|
|
|
294
287
|
const lstat = io.lstatSync || lstatSync;
|
|
295
288
|
|
|
296
289
|
const filesDirectory = join(snapshot.journalRoot, FILES_DIRECTORY_NAME);
|
|
290
|
+
const settingsSnapshots = snapshot.settingsSnapshots?.length > 0
|
|
291
|
+
? snapshot.settingsSnapshots
|
|
292
|
+
: [{
|
|
293
|
+
path: snapshot.settingsPath,
|
|
294
|
+
existed: snapshot.settingsExisted,
|
|
295
|
+
blobName: SETTINGS_BLOB_NAME,
|
|
296
|
+
}];
|
|
297
297
|
for (const eachEntry of snapshot.allFileEntries) {
|
|
298
298
|
try {
|
|
299
299
|
if (exists(eachEntry.absolutePath)) {
|
|
@@ -302,9 +302,7 @@ export function restorePriorInstallSnapshot(snapshot, options = {}) {
|
|
|
302
302
|
unlink(eachEntry.absolutePath);
|
|
303
303
|
}
|
|
304
304
|
}
|
|
305
|
-
} catch {
|
|
306
|
-
// keep restoring remaining entries
|
|
307
|
-
}
|
|
305
|
+
} catch {}
|
|
308
306
|
if (eachEntry.kind === ENTRY_KIND_FILE && eachEntry.blobName) {
|
|
309
307
|
mkdir(dirname(eachEntry.absolutePath), { recursive: true });
|
|
310
308
|
copyFile(join(filesDirectory, eachEntry.blobName), eachEntry.absolutePath);
|
|
@@ -324,7 +322,8 @@ export function restorePriorInstallSnapshot(snapshot, options = {}) {
|
|
|
324
322
|
if (priorPathSet.has(eachWrittenPath)) {
|
|
325
323
|
continue;
|
|
326
324
|
}
|
|
327
|
-
if (
|
|
325
|
+
if (settingsSnapshots.some(eachSnapshot => eachSnapshot.path === eachWrittenPath)
|
|
326
|
+
|| eachWrittenPath === snapshot.manifestFilePath) {
|
|
328
327
|
continue;
|
|
329
328
|
}
|
|
330
329
|
try {
|
|
@@ -339,14 +338,14 @@ export function restorePriorInstallSnapshot(snapshot, options = {}) {
|
|
|
339
338
|
}
|
|
340
339
|
}
|
|
341
340
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
341
|
+
for (const eachSnapshot of settingsSnapshots) {
|
|
342
|
+
if (eachSnapshot.existed) {
|
|
343
|
+
mkdir(dirname(eachSnapshot.path), { recursive: true });
|
|
344
|
+
copyFile(join(snapshot.journalRoot, eachSnapshot.blobName), eachSnapshot.path);
|
|
345
|
+
} else if (exists(eachSnapshot.path)) {
|
|
346
|
+
try {
|
|
347
|
+
unlink(eachSnapshot.path);
|
|
348
|
+
} catch {}
|
|
350
349
|
}
|
|
351
350
|
}
|
|
352
351
|
|
|
@@ -356,9 +355,7 @@ export function restorePriorInstallSnapshot(snapshot, options = {}) {
|
|
|
356
355
|
} else if (exists(snapshot.manifestFilePath)) {
|
|
357
356
|
try {
|
|
358
357
|
unlink(snapshot.manifestFilePath);
|
|
359
|
-
} catch {
|
|
360
|
-
// leave in place when unlink fails
|
|
361
|
-
}
|
|
358
|
+
} catch {}
|
|
362
359
|
}
|
|
363
360
|
|
|
364
361
|
writeGlobalCoreHooksPath(snapshot.priorHooksPath, io);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
writeFileSync,
|
|
7
7
|
readFileSync,
|
|
8
8
|
existsSync,
|
|
9
|
+
copyFileSync,
|
|
9
10
|
rmSync,
|
|
10
11
|
} from 'node:fs';
|
|
11
12
|
import { tmpdir } from 'node:os';
|
|
@@ -150,6 +151,11 @@ for (const targetName of [null, 'profile']) {
|
|
|
150
151
|
'rules',
|
|
151
152
|
'pstack-model-preferences.codex.json',
|
|
152
153
|
);
|
|
154
|
+
const policyPath = join(
|
|
155
|
+
resolution.agentsHome,
|
|
156
|
+
'rules',
|
|
157
|
+
'subagent-model-policy.json',
|
|
158
|
+
);
|
|
153
159
|
const selectorPath = join(
|
|
154
160
|
resolution.agentsHome,
|
|
155
161
|
'scripts',
|
|
@@ -172,10 +178,12 @@ for (const targetName of [null, 'profile']) {
|
|
|
172
178
|
assert.equal(readFileSync(cursorPath, 'utf8'), installedText);
|
|
173
179
|
assert.equal(isAllowedInstallDestination(sharedPath, resolution), true);
|
|
174
180
|
assert.equal(existsSync(selectorPath), true);
|
|
181
|
+
assert.equal(existsSync(policyPath), true);
|
|
175
182
|
|
|
176
183
|
const savedPreferences = JSON.parse(readFileSync(preferencesPath, 'utf8'));
|
|
177
184
|
assert.deepEqual(savedPreferences, {
|
|
178
185
|
host: 'codex',
|
|
186
|
+
defaultEffort: 'medium',
|
|
179
187
|
modelsByRole: {
|
|
180
188
|
'feature, refactoring': ['gpt-5.6-sol'],
|
|
181
189
|
'bug-fix': ['gpt-5.6-sol'],
|
|
@@ -216,9 +224,9 @@ for (const targetName of [null, 'profile']) {
|
|
|
216
224
|
hasMaterialCapabilityLoss: false,
|
|
217
225
|
},
|
|
218
226
|
});
|
|
219
|
-
assert.
|
|
220
|
-
|
|
221
|
-
});
|
|
227
|
+
assert.equal(codexSelection.canDelegate, false);
|
|
228
|
+
assert.equal(codexSelection.failure, 'no-supported-model');
|
|
229
|
+
assert.deepEqual(codexSelection.nativeSpawnArguments, {});
|
|
222
230
|
|
|
223
231
|
const claudePreferencesPath = join(
|
|
224
232
|
resolution.agentsHome,
|
|
@@ -344,6 +352,306 @@ for (const targetName of [null, 'profile']) {
|
|
|
344
352
|
});
|
|
345
353
|
}
|
|
346
354
|
|
|
355
|
+
test('seeds one editable policy and one native Codex routing hook', () => {
|
|
356
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-routing-install-'));
|
|
357
|
+
try {
|
|
358
|
+
const resolution = resolveInstallRoot({
|
|
359
|
+
homeDirectory,
|
|
360
|
+
environment: {},
|
|
361
|
+
explicitTarget: null,
|
|
362
|
+
});
|
|
363
|
+
const policyPath = join(resolution.agentsHome, 'rules', 'subagent-model-policy.json');
|
|
364
|
+
const installedRulesPolicyPath = join(
|
|
365
|
+
resolution.managedRoot,
|
|
366
|
+
'rules',
|
|
367
|
+
'subagent-model-policy.json',
|
|
368
|
+
);
|
|
369
|
+
const codexHooksPath = join(homeDirectory, '.codex', 'hooks.json');
|
|
370
|
+
|
|
371
|
+
runInstaller(homeDirectory, ['--only', 'core']);
|
|
372
|
+
|
|
373
|
+
assert.equal(existsSync(policyPath), true);
|
|
374
|
+
assert.equal(existsSync(installedRulesPolicyPath), false);
|
|
375
|
+
const firstCodexHooks = JSON.parse(readFileSync(codexHooksPath, 'utf8'));
|
|
376
|
+
const firstRoutingGroups = firstCodexHooks.hooks.PreToolUse.filter(
|
|
377
|
+
group => group.matcher === 'multi_agent_v1__spawn_agent',
|
|
378
|
+
);
|
|
379
|
+
assert.equal(firstRoutingGroups.length, 1);
|
|
380
|
+
assert.equal(firstRoutingGroups[0].hooks.length, 1);
|
|
381
|
+
assert.match(firstRoutingGroups[0].hooks[0].command, /subagent_model_routing\.mjs/);
|
|
382
|
+
assert.equal(
|
|
383
|
+
firstCodexHooks.hooks.PreToolUse.some(group => group.matcher === 'Write|Edit|MultiEdit|apply_patch'),
|
|
384
|
+
false,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const editedPolicy = '{"schemaVersion":1,"edited":true}\n';
|
|
388
|
+
writeFileSync(policyPath, editedPolicy);
|
|
389
|
+
firstCodexHooks.hooks.PreToolUse.push({
|
|
390
|
+
matcher: 'custom',
|
|
391
|
+
hooks: [{ type: 'command', command: 'python user-hook.py' }],
|
|
392
|
+
});
|
|
393
|
+
writeFileSync(codexHooksPath, JSON.stringify(firstCodexHooks, null, 2) + '\n');
|
|
394
|
+
|
|
395
|
+
runInstaller(homeDirectory, ['--only', 'core']);
|
|
396
|
+
|
|
397
|
+
assert.equal(readFileSync(policyPath, 'utf8'), editedPolicy);
|
|
398
|
+
const secondCodexHooks = JSON.parse(readFileSync(codexHooksPath, 'utf8'));
|
|
399
|
+
assert.equal(
|
|
400
|
+
secondCodexHooks.hooks.PreToolUse.filter(
|
|
401
|
+
group => group.matcher === 'multi_agent_v1__spawn_agent',
|
|
402
|
+
).length,
|
|
403
|
+
1,
|
|
404
|
+
);
|
|
405
|
+
assert.equal(
|
|
406
|
+
secondCodexHooks.hooks.PreToolUse.some(
|
|
407
|
+
group => group.matcher === 'custom'
|
|
408
|
+
&& group.hooks.some(hook => hook.command === 'python user-hook.py'),
|
|
409
|
+
),
|
|
410
|
+
true,
|
|
411
|
+
);
|
|
412
|
+
} finally {
|
|
413
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('reinstall moves the old routing hook and keeps user hooks', () => {
|
|
418
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-routing-hook-upgrade-'));
|
|
419
|
+
try {
|
|
420
|
+
const resolution = resolveInstallRoot({
|
|
421
|
+
homeDirectory,
|
|
422
|
+
environment: {},
|
|
423
|
+
explicitTarget: null,
|
|
424
|
+
});
|
|
425
|
+
const codexHooksPath = join(homeDirectory, '.codex', 'hooks.json');
|
|
426
|
+
const manifestPath = resolution.manifestFilePath;
|
|
427
|
+
const routingHookPath = join(
|
|
428
|
+
homeDirectory,
|
|
429
|
+
'.codex',
|
|
430
|
+
'hooks',
|
|
431
|
+
'routing',
|
|
432
|
+
'subagent_model_routing.mjs',
|
|
433
|
+
);
|
|
434
|
+
const oldRoutingHookPath = join(
|
|
435
|
+
homeDirectory,
|
|
436
|
+
'.codex',
|
|
437
|
+
'hooks',
|
|
438
|
+
'blocking',
|
|
439
|
+
'subagent_model_routing.mjs',
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
runInstaller(homeDirectory, []);
|
|
443
|
+
const firstCodexHooks = JSON.parse(readFileSync(codexHooksPath, 'utf8'));
|
|
444
|
+
const firstRoutingGroup = firstCodexHooks.hooks.PreToolUse.find(
|
|
445
|
+
group => group.matcher === 'multi_agent_v1__spawn_agent',
|
|
446
|
+
);
|
|
447
|
+
firstRoutingGroup.hooks[0].command = firstRoutingGroup.hooks[0].command.replace(
|
|
448
|
+
/routing([\\/])subagent_model_routing\.mjs$/,
|
|
449
|
+
'blocking$1subagent_model_routing.mjs',
|
|
450
|
+
);
|
|
451
|
+
firstCodexHooks.hooks.PreToolUse.push({
|
|
452
|
+
matcher: 'custom',
|
|
453
|
+
hooks: [{ type: 'command', command: 'node user-hook.mjs' }],
|
|
454
|
+
});
|
|
455
|
+
writeFileSync(codexHooksPath, JSON.stringify(firstCodexHooks, null, 2) + '\n');
|
|
456
|
+
copyFileSync(routingHookPath, oldRoutingHookPath);
|
|
457
|
+
rmSync(routingHookPath);
|
|
458
|
+
|
|
459
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
460
|
+
manifest.files = manifest.files.map(file => file.replace(
|
|
461
|
+
/([\\/])routing([\\/]subagent_model_routing\.mjs)$/,
|
|
462
|
+
'$1blocking$2',
|
|
463
|
+
));
|
|
464
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
465
|
+
|
|
466
|
+
runInstaller(homeDirectory, []);
|
|
467
|
+
|
|
468
|
+
const secondCodexHooks = JSON.parse(readFileSync(codexHooksPath, 'utf8'));
|
|
469
|
+
const routingGroups = secondCodexHooks.hooks.PreToolUse.filter(
|
|
470
|
+
group => group.matcher === 'multi_agent_v1__spawn_agent',
|
|
471
|
+
);
|
|
472
|
+
assert.equal(routingGroups.length, 1);
|
|
473
|
+
assert.match(routingGroups[0].hooks[0].command, /[\\/]hooks[\\/]routing[\\/]subagent_model_routing\.mjs/);
|
|
474
|
+
assert.doesNotMatch(
|
|
475
|
+
JSON.stringify(secondCodexHooks),
|
|
476
|
+
/[\\/]hooks[\\/]blocking[\\/]subagent_model_routing\.mjs/,
|
|
477
|
+
);
|
|
478
|
+
assert.equal(existsSync(routingHookPath), true);
|
|
479
|
+
assert.equal(existsSync(oldRoutingHookPath), false);
|
|
480
|
+
assert.equal(
|
|
481
|
+
secondCodexHooks.hooks.PreToolUse.some(
|
|
482
|
+
group => group.matcher === 'custom'
|
|
483
|
+
&& group.hooks.some(hook => hook.command === 'node user-hook.mjs'),
|
|
484
|
+
),
|
|
485
|
+
true,
|
|
486
|
+
);
|
|
487
|
+
} finally {
|
|
488
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test('uninstall removes the routing hook and keeps a Codex user hook', () => {
|
|
493
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-routing-uninstall-'));
|
|
494
|
+
try {
|
|
495
|
+
const profileRoot = join(homeDirectory, 'named-profile');
|
|
496
|
+
const codexHooksPath = join(profileRoot, '.codex', 'hooks.json');
|
|
497
|
+
const targetArguments = ['--target', profileRoot];
|
|
498
|
+
runInstaller(homeDirectory, [...targetArguments, '--only', 'core']);
|
|
499
|
+
const codexHooks = JSON.parse(readFileSync(codexHooksPath, 'utf8'));
|
|
500
|
+
codexHooks.hooks.PreToolUse.push({
|
|
501
|
+
matcher: 'custom',
|
|
502
|
+
hooks: [{ type: 'command', command: 'python user-hook.py' }],
|
|
503
|
+
});
|
|
504
|
+
writeFileSync(codexHooksPath, JSON.stringify(codexHooks, null, 2) + '\n');
|
|
505
|
+
|
|
506
|
+
runInstaller(homeDirectory, [...targetArguments, '--uninstall']);
|
|
507
|
+
|
|
508
|
+
const remainingHooks = JSON.parse(readFileSync(codexHooksPath, 'utf8'));
|
|
509
|
+
assert.equal(
|
|
510
|
+
remainingHooks.hooks.PreToolUse.some(group => group.matcher === 'multi_agent_v1__spawn_agent'),
|
|
511
|
+
false,
|
|
512
|
+
);
|
|
513
|
+
assert.equal(
|
|
514
|
+
remainingHooks.hooks.PreToolUse.some(
|
|
515
|
+
group => group.matcher === 'custom'
|
|
516
|
+
&& group.hooks.some(hook => hook.command === 'python user-hook.py'),
|
|
517
|
+
),
|
|
518
|
+
true,
|
|
519
|
+
);
|
|
520
|
+
} finally {
|
|
521
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test('malformed Codex hooks restore the staged install', () => {
|
|
526
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-routing-malformed-'));
|
|
527
|
+
try {
|
|
528
|
+
const codexHooksPath = join(homeDirectory, '.codex', 'hooks.json');
|
|
529
|
+
const claudeSettingsPath = join(homeDirectory, '.claude', 'settings.json');
|
|
530
|
+
const originalCodexHooks = '{ malformed\n';
|
|
531
|
+
const originalClaudeSettings = '{"hooks":{"PreToolUse":[{"matcher":"user","hooks":[{"type":"command","command":"user-hook"}]}]}}\n';
|
|
532
|
+
mkdirSync(dirname(codexHooksPath), { recursive: true });
|
|
533
|
+
mkdirSync(dirname(claudeSettingsPath), { recursive: true });
|
|
534
|
+
writeFileSync(codexHooksPath, originalCodexHooks);
|
|
535
|
+
writeFileSync(claudeSettingsPath, originalClaudeSettings);
|
|
536
|
+
|
|
537
|
+
assert.throws(
|
|
538
|
+
() => runInstaller(homeDirectory, ['--only', 'core']),
|
|
539
|
+
error => error.status === 1,
|
|
540
|
+
);
|
|
541
|
+
assert.equal(readFileSync(codexHooksPath, 'utf8'), originalCodexHooks);
|
|
542
|
+
assert.equal(readFileSync(claudeSettingsPath, 'utf8'), originalClaudeSettings);
|
|
543
|
+
assert.equal(existsSync(join(homeDirectory, '.claude', 'hooks')), false);
|
|
544
|
+
} finally {
|
|
545
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test('malformed Codex hooks restore an uninstall', () => {
|
|
550
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-routing-uninstall-malformed-'));
|
|
551
|
+
try {
|
|
552
|
+
runInstaller(homeDirectory, ['--only', 'core']);
|
|
553
|
+
const codexHooksPath = join(homeDirectory, '.codex', 'hooks.json');
|
|
554
|
+
const manifestPath = join(homeDirectory, '.claude', '.claude-dev-env-manifest.json');
|
|
555
|
+
const managedHookPath = join(
|
|
556
|
+
homeDirectory,
|
|
557
|
+
'.claude',
|
|
558
|
+
'hooks',
|
|
559
|
+
'routing',
|
|
560
|
+
'subagent_model_routing.mjs',
|
|
561
|
+
);
|
|
562
|
+
writeFileSync(codexHooksPath, '{ malformed\n');
|
|
563
|
+
|
|
564
|
+
assert.throws(
|
|
565
|
+
() => runInstaller(homeDirectory, ['--uninstall']),
|
|
566
|
+
error => error.status === 1,
|
|
567
|
+
);
|
|
568
|
+
assert.equal(existsSync(manifestPath), true);
|
|
569
|
+
assert.equal(existsSync(managedHookPath), true);
|
|
570
|
+
assert.equal(readFileSync(codexHooksPath, 'utf8'), '{ malformed\n');
|
|
571
|
+
} finally {
|
|
572
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test('two profiles keep separate Codex routing hooks', () => {
|
|
577
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-routing-profiles-'));
|
|
578
|
+
try {
|
|
579
|
+
const profileRoot = join(homeDirectory, 'named-profile');
|
|
580
|
+
const mainCodexHooksPath = join(homeDirectory, '.codex', 'hooks.json');
|
|
581
|
+
const profileCodexHooksPath = join(profileRoot, '.codex', 'hooks.json');
|
|
582
|
+
runInstaller(homeDirectory, ['--only', 'core']);
|
|
583
|
+
runInstaller(homeDirectory, ['--target', profileRoot, '--only', 'core']);
|
|
584
|
+
|
|
585
|
+
const mainCodexHooks = JSON.parse(readFileSync(mainCodexHooksPath, 'utf8'));
|
|
586
|
+
const profileCodexHooks = JSON.parse(readFileSync(profileCodexHooksPath, 'utf8'));
|
|
587
|
+
const mainRoutingGroups = (mainCodexHooks.hooks?.PreToolUse ?? []).filter(
|
|
588
|
+
group => group.matcher === 'multi_agent_v1__spawn_agent',
|
|
589
|
+
);
|
|
590
|
+
const profileRoutingGroups = profileCodexHooks.hooks.PreToolUse.filter(
|
|
591
|
+
group => group.matcher === 'multi_agent_v1__spawn_agent',
|
|
592
|
+
);
|
|
593
|
+
assert.equal(mainRoutingGroups.length, 1);
|
|
594
|
+
assert.equal(profileRoutingGroups.length, 1);
|
|
595
|
+
assert.match(mainRoutingGroups[0].hooks[0].command, /[\\/]\.codex[\\/]hooks[\\/]routing[\\/]subagent_model_routing\.mjs/);
|
|
596
|
+
assert.match(profileRoutingGroups[0].hooks[0].command, /named-profile[\\/]\.codex[\\/]hooks[\\/]routing[\\/]subagent_model_routing\.mjs/);
|
|
597
|
+
assert.notEqual(mainRoutingGroups[0].hooks[0].command, profileRoutingGroups[0].hooks[0].command);
|
|
598
|
+
|
|
599
|
+
const profileHookPath = join(
|
|
600
|
+
profileRoot,
|
|
601
|
+
'.codex',
|
|
602
|
+
'hooks',
|
|
603
|
+
'routing',
|
|
604
|
+
'subagent_model_routing.mjs',
|
|
605
|
+
);
|
|
606
|
+
const profileHookResponse = JSON.parse(execFileSync(
|
|
607
|
+
'node',
|
|
608
|
+
[profileHookPath],
|
|
609
|
+
{
|
|
610
|
+
encoding: 'utf8',
|
|
611
|
+
input: JSON.stringify({
|
|
612
|
+
tool_name: 'multi_agent_v1__spawn_agent',
|
|
613
|
+
tool_input: { model: 'Terra', reasoning_effort: 'medium' },
|
|
614
|
+
}),
|
|
615
|
+
},
|
|
616
|
+
));
|
|
617
|
+
assert.equal(
|
|
618
|
+
profileHookResponse.hookSpecificOutput.updatedInput.model,
|
|
619
|
+
'gpt-5.6-luna',
|
|
620
|
+
);
|
|
621
|
+
assert.equal(
|
|
622
|
+
profileHookResponse.hookSpecificOutput.updatedInput.reasoning_effort,
|
|
623
|
+
'xhigh',
|
|
624
|
+
);
|
|
625
|
+
} finally {
|
|
626
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test('uninstalling one profile keeps routing for another installed profile', () => {
|
|
631
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-routing-profile-uninstall-'));
|
|
632
|
+
try {
|
|
633
|
+
const profileRoot = join(homeDirectory, 'named-profile');
|
|
634
|
+
const mainCodexHooksPath = join(homeDirectory, '.codex', 'hooks.json');
|
|
635
|
+
const profileCodexHooksPath = join(profileRoot, '.codex', 'hooks.json');
|
|
636
|
+
runInstaller(homeDirectory, ['--only', 'core']);
|
|
637
|
+
runInstaller(homeDirectory, ['--target', profileRoot, '--only', 'core']);
|
|
638
|
+
runInstaller(homeDirectory, ['--uninstall']);
|
|
639
|
+
|
|
640
|
+
const mainCodexHooks = JSON.parse(readFileSync(mainCodexHooksPath, 'utf8'));
|
|
641
|
+
const profileCodexHooks = JSON.parse(readFileSync(profileCodexHooksPath, 'utf8'));
|
|
642
|
+
const mainRoutingGroups = (mainCodexHooks.hooks?.PreToolUse ?? []).filter(
|
|
643
|
+
group => group.matcher === 'multi_agent_v1__spawn_agent',
|
|
644
|
+
);
|
|
645
|
+
const profileRoutingGroups = profileCodexHooks.hooks.PreToolUse.filter(
|
|
646
|
+
group => group.matcher === 'multi_agent_v1__spawn_agent',
|
|
647
|
+
);
|
|
648
|
+
assert.equal(mainRoutingGroups.length, 0);
|
|
649
|
+
assert.equal(profileRoutingGroups.length, 1);
|
|
650
|
+
} finally {
|
|
651
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
|
|
347
655
|
test('a blocked shared rule destination rolls back generated Cursor files', () => {
|
|
348
656
|
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-pstack-rollback-'));
|
|
349
657
|
try {
|