claude-dev-env 7.6.1 → 7.7.0

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 (31) hide show
  1. package/AGENTS.md +48 -71
  2. package/_shared/advisor/advisor-protocol.md +2 -2
  3. package/_shared/advisor/reference/advisor-block.md +1 -1
  4. package/_shared/advisor/reference/astra-rung.md +2 -2
  5. package/_shared/advisor/scripts/codex_astra_advisor.py +122 -23
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +76 -9
  7. package/_shared/advisor/scripts/tests/test_advisor_route_constants.py +51 -0
  8. package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +52 -2
  9. package/bin/install-constants.mjs +3 -0
  10. package/bin/install-transaction.mjs +41 -44
  11. package/bin/install.cursor-rules.test.mjs +311 -3
  12. package/bin/install.mjs +156 -95
  13. package/bin/install.transaction.test.mjs +61 -1
  14. package/bin/pstack-model-preferences.codex.json +1 -0
  15. package/bin/pstack.mjs +28 -6
  16. package/bin/pstack.test.mjs +48 -0
  17. package/bin/select-pstack-models.test.mjs +375 -26
  18. package/bin/subagent-model-policy.test.mjs +399 -0
  19. package/docs/codex-compatibility.md +19 -5
  20. package/docs/subagent-model-routing.md +50 -0
  21. package/hooks/hooks.json +10 -0
  22. package/hooks/routing/subagent_model_routing.mjs +81 -0
  23. package/hooks/routing/subagent_model_routing.test.mjs +218 -0
  24. package/package.json +1 -1
  25. package/rules/pstack-models.md +22 -0
  26. package/rules/subagent-model-policy.json +85 -0
  27. package/scripts/resolve_advisor_model_route.mjs +52 -0
  28. package/scripts/select_pstack_models.mjs +203 -38
  29. package/scripts/subagent_model_policy.mjs +620 -0
  30. package/scripts/tests/resolve_advisor_model_route.test.mjs +51 -0
  31. 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
- assert astra_advisor.resolve_advisor_effort({"ADVISOR_EFFORT": "unknown"}) == "low"
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 settingsExisted = exists(input.settingsPath);
212
- if (settingsExisted) {
213
- copyFile(input.settingsPath, join(journalRoot, SETTINGS_BLOB_NAME));
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
- settingsExisted,
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
- settingsExisted,
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 (eachWrittenPath === snapshot.settingsPath || eachWrittenPath === snapshot.manifestFilePath) {
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
- if (snapshot.settingsExisted) {
343
- mkdir(dirname(snapshot.settingsPath), { recursive: true });
344
- copyFile(join(snapshot.journalRoot, SETTINGS_BLOB_NAME), snapshot.settingsPath);
345
- } else if (exists(snapshot.settingsPath)) {
346
- try {
347
- unlink(snapshot.settingsPath);
348
- } catch {
349
- // leave in place when unlink fails
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.deepEqual(codexSelection.nativeSpawnArguments, {
220
- model: 'gpt-5.6-sol',
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 {