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
package/bin/install.mjs CHANGED
@@ -30,13 +30,17 @@ import {
30
30
  MANAGED_HOOKS_DIRECTORY_NAME,
31
31
  MANAGED_SCRIPTS_DIRECTORY_NAME,
32
32
  SETTINGS_FILE_NAME,
33
+ CODEX_HOME_ENVIRONMENT_VARIABLE,
34
+ DEFAULT_CODEX_DIRECTORY_NAME,
33
35
  CODEX_RULES_PACKAGE_DIRECTORY_NAME,
34
36
  CURSOR_SYNC_SCRIPT_FILE_NAME,
35
37
  CURSOR_RULES_DIRECTORY_NAME,
36
38
  PSTACK_MODEL_RULE_FILE_NAME,
39
+ SUBAGENT_MODEL_POLICY_FILE_NAME,
37
40
  PSTACK_SEEDED_MODEL_PREFERENCES_FILE_NAMES,
38
41
  WINDOWS_PYTHON_LAUNCHER_COMMAND,
39
42
  PYTHON_PROBE_TIMEOUT_MILLISECONDS,
43
+ CODEX_NATIVE_ROUTING_MATCHER,
40
44
  } from './install-constants.mjs';
41
45
  import {
42
46
  PSTACK_PLUGIN_DIRECTORY_NAME,
@@ -514,6 +518,20 @@ function seedPstackPreferences(preferenceFileName) {
514
518
  }
515
519
  }
516
520
 
521
+ function seedSubagentModelPolicy() {
522
+ const policyDirectory = join(AGENTS_HOME, CURSOR_RULES_DIRECTORY_NAME);
523
+ const policyPath = join(policyDirectory, SUBAGENT_MODEL_POLICY_FILE_NAME);
524
+ const policySource = join(PACKAGE_ROOT, 'rules', SUBAGENT_MODEL_POLICY_FILE_NAME);
525
+ mkdirSync(policyDirectory, { recursive: true });
526
+ try {
527
+ copyFileSync(policySource, policyPath, filesystemConstants.COPYFILE_EXCL);
528
+ return policyPath;
529
+ } catch (copyError) {
530
+ if (copyError.code === 'EEXIST') return null;
531
+ throw copyError;
532
+ }
533
+ }
534
+
517
535
  /**
518
536
  * Registers the session-continuity companion in each host configuration this
519
537
  * install root resolves. Without this step the companion ships to the agents
@@ -1215,22 +1233,11 @@ function renameCaseOnlyMatchToShippedName(destinationFilePath, entryNamesByDirec
1215
1233
  existingEntryNames[existingEntryNames.indexOf(caseOnlyMatchName)] = shippedFileName;
1216
1234
  }
1217
1235
 
1218
- /**
1219
- * Copy every file under a source directory into a destination directory,
1220
- * reporting what the run created and what it updated.
1221
- *
1222
- * A destination entry whose name differs from the shipped name only in letter
1223
- * case is renamed to the shipped name before the copy, so the tree carries the
1224
- * spelling the package ships. The directory listing behind that decision is read
1225
- * once per destination directory and reused for every file the run copies there.
1226
- *
1227
- * @param {string} sourceBase The absolute source directory to copy from.
1228
- * @param {string} destBase The absolute destination directory to copy into.
1229
- * @param {{isCaseInsensitive?: boolean}} options Whether name comparison folds letter case; defaults to this host's filesystem.
1230
- * @returns {{created: number, updated: number, paths: string[]}} The counts and the destination paths written.
1231
- */
1232
1236
  export function copyTree(sourceBase, destBase, options = {}) {
1233
- const files = collectFiles(sourceBase);
1237
+ const excludedFileNames = new Set(options.excludeFileNames ?? []);
1238
+ const files = collectFiles(sourceBase).filter(
1239
+ sourceFile => !excludedFileNames.has(basename(sourceFile)),
1240
+ );
1234
1241
  const stats = { created: 0, updated: 0, paths: [] };
1235
1242
  const entryNamesByDirectory = new Map();
1236
1243
  for (const sourceFile of files) {
@@ -1274,15 +1281,6 @@ function backupHubBeforeOverwrite(destPath, incomingPath, backupName) {
1274
1281
  return backupPath;
1275
1282
  }
1276
1283
 
1277
- /**
1278
- * Hook script paths the installer manages even though hooks.json
1279
- * carries no standalone entry for them. Most were folded into the PreToolUse
1280
- * dispatcher in Stage 1; retired hooks have no current registration.
1281
- * Each path stays in this set so a reinstall from an older settings shape
1282
- * prunes its standalone entry — a folded hook would otherwise double-run
1283
- * alongside the dispatcher, and a retired hook's entry would continue to run
1284
- * from the user's settings.json.
1285
- */
1286
1284
  export const FOLDED_HOOK_RELATIVE_PATHS = new Set([
1287
1285
  'blocking/write_existing_file_blocker.py',
1288
1286
  'blocking/sensitive_file_protector.py',
@@ -1358,25 +1356,18 @@ export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
1358
1356
  'blocking/session_handoff_blocker.py',
1359
1357
  ]);
1360
1358
 
1361
- /**
1362
- * Builds the set of hook script paths this installer manages, each relative to
1363
- * the hooks directory (e.g. 'blocking/code_rules_enforcer.py'), parsed from the
1364
- * `${CLAUDE_PLUGIN_ROOT}/hooks/<path>` references in hooks.json. Inline
1365
- * `python3 -c` commands reference the hooks directory without a script tail and
1366
- * contribute nothing. Also includes every path from FOLDED_HOOK_RELATIVE_PATHS
1367
- * and the retained path sets so a reinstall from an older settings shape prunes
1368
- * folded and retired entries.
1369
- *
1370
- * @param {{hooks: object}} hooksConfig Parsed hooks.json.
1371
- * @returns {Set<string>} Forward-slash relative script paths under hooks/.
1372
- */
1359
+ export const MOVED_HOOK_RELATIVE_PATHS = new Set([
1360
+ 'blocking/subagent_model_routing.mjs',
1361
+ ]);
1362
+
1373
1363
  export function managedHookScriptRelativePaths(hooksConfig) {
1374
1364
  const relativePaths = new Set([
1375
1365
  ...FOLDED_HOOK_RELATIVE_PATHS,
1376
1366
  ...POST_FOLDED_HOOK_RELATIVE_PATHS,
1377
1367
  ...RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS,
1368
+ ...MOVED_HOOK_RELATIVE_PATHS,
1378
1369
  ]);
1379
- const scriptReferencePattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.py)/g;
1370
+ const scriptReferencePattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.(?:py|mjs))/g;
1380
1371
  for (const matcherGroups of Object.values(hooksConfig.hooks)) {
1381
1372
  for (const sourceGroup of matcherGroups) {
1382
1373
  for (const hook of sourceGroup.hooks) {
@@ -1457,19 +1448,16 @@ export function commandReferencesManagedHook(commandString, managedHookRelativeP
1457
1448
  return false;
1458
1449
  }
1459
1450
 
1460
- /**
1461
- * Reports whether a command contains the `/.claude/hooks/<relative>` tail ending
1462
- * at a path boundary: end of string, or an argument separator (whitespace, quote,
1463
- * or semicolon). Anchoring the tail keeps a user hook whose path is the managed
1464
- * tail plus a suffix (`code_rules_enforcer.py.bak`, `a.py/extra/thing.py`) outside
1465
- * the managed set, so it is never pruned.
1466
- *
1467
- * @param {string} normalizedCommand Forward-slash-normalized hook command.
1468
- * @param {string} relativePath Managed script path under hooks/.
1469
- * @returns {boolean} True when the managed tail ends at a path boundary.
1470
- */
1471
1451
  function commandTailEndsAtManagedHook(normalizedCommand, relativePath) {
1472
- return commandHoldsPathAtBoundary(normalizedCommand, `/.claude/hooks/${relativePath}`);
1452
+ if (commandHoldsPathAtBoundary(normalizedCommand, `/.claude/hooks/${relativePath}`)) {
1453
+ return true;
1454
+ }
1455
+ return MANAGED_HOOK_ROOT_PATHS.some(eachHooksRoot => (
1456
+ commandHoldsPathAtBoundary(
1457
+ normalizedCommand,
1458
+ `${eachHooksRoot.replace(/\\/g, '/')}/${relativePath}`,
1459
+ )
1460
+ ));
1473
1461
  }
1474
1462
 
1475
1463
  /**
@@ -2160,16 +2148,7 @@ function reportRetiredHookStandIns(allHookRootPaths, standInPaths) {
2160
2148
  }
2161
2149
 
2162
2150
 
2163
- /**
2164
- * Load ~/.claude/settings.json for an in-place merge, or `{}` when absent/empty.
2165
- *
2166
- * Malformed JSON or a non-object root ends the process so install never writes
2167
- * onto a shape the harness cannot read.
2168
- *
2169
- * @param {string} settingsPath Absolute path to settings.json.
2170
- * @returns {object}
2171
- */
2172
- function loadClaudeSettingsObjectOrExit(settingsPath) {
2151
+ function loadClaudeSettingsObject(settingsPath) {
2173
2152
  if (!existsSync(settingsPath)) {
2174
2153
  return {};
2175
2154
  }
@@ -2181,42 +2160,67 @@ function loadClaudeSettingsObjectOrExit(settingsPath) {
2181
2160
  try {
2182
2161
  settings = JSON.parse(raw);
2183
2162
  } catch {
2184
- console.error(' ERROR: settings.json is malformed JSON. Fix it and rerun.');
2185
- process.exit(1);
2163
+ throw new Error('settings.json is malformed JSON. Fix it and rerun.');
2186
2164
  }
2187
2165
  if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
2188
- console.error(' ERROR: settings.json holds a value other than a JSON object. Fix it and rerun.');
2189
- process.exit(1);
2166
+ throw new Error('settings.json holds a value other than a JSON object. Fix it and rerun.');
2190
2167
  }
2191
2168
  return settings;
2192
2169
  }
2193
2170
 
2194
- /**
2195
- * Merge one package source root's hook groups into ~/.claude/settings.json.
2196
- *
2197
- * A settings file holding anything other than a JSON object ends the install with
2198
- * a message naming the file, so the run stops before it writes hook entries onto
2199
- * a shape the harness cannot read.
2200
- *
2201
- * @param {string} hooksSourceRoot The package root whose hooks/hooks.json is merged.
2202
- * @param {string} pythonCommand Interpreter command that replaces python3.
2203
- * @returns {number} Count of matcher groups merged.
2204
- */
2205
- function mergeHooks(hooksSourceRoot, pythonCommand) {
2171
+ function mergeHooksAtPath(
2172
+ hooksSourceRoot,
2173
+ settingsPath,
2174
+ indentation,
2175
+ pythonCommand,
2176
+ selectHooksConfig = hooksConfig => hooksConfig,
2177
+ pluginRootDir = CLAUDE_HOME,
2178
+ ) {
2206
2179
  const hooksSource = resolvePackageManagedDirectory(
2207
2180
  hooksSourceRoot,
2208
2181
  MANAGED_HOOKS_DIRECTORY_NAME,
2209
2182
  );
2210
2183
  const hooksJsonPath = join(hooksSource, 'hooks.json');
2211
2184
  if (!existsSync(hooksJsonPath)) return 0;
2212
- const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
2213
- const settingsPath = join(CLAUDE_HOME, SETTINGS_FILE_NAME);
2214
- const settings = loadClaudeSettingsObjectOrExit(settingsPath);
2215
- const groupCount = mergeHooksIntoSettings(settings, hooksConfig, CLAUDE_HOME, pythonCommand);
2216
- writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
2185
+ const hooksConfig = selectHooksConfig(JSON.parse(readFileSync(hooksJsonPath, 'utf8')));
2186
+ const settings = loadClaudeSettingsObject(settingsPath);
2187
+ const groupCount = mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pythonCommand);
2188
+ writeFileSync(settingsPath, JSON.stringify(settings, null, indentation) + '\n');
2217
2189
  return groupCount;
2218
2190
  }
2219
2191
 
2192
+ function selectCodexNativeHooksConfig(hooksConfig) {
2193
+ const preToolUseGroups = hooksConfig.hooks?.PreToolUse;
2194
+ return {
2195
+ ...hooksConfig,
2196
+ hooks: {
2197
+ PreToolUse: Array.isArray(preToolUseGroups)
2198
+ ? preToolUseGroups.filter(group => group?.matcher === CODEX_NATIVE_ROUTING_MATCHER)
2199
+ : [],
2200
+ },
2201
+ };
2202
+ }
2203
+
2204
+ function mergeHooks(hooksSourceRoot, pythonCommand) {
2205
+ return mergeHooksAtPath(
2206
+ hooksSourceRoot,
2207
+ join(CLAUDE_HOME, SETTINGS_FILE_NAME),
2208
+ 4,
2209
+ pythonCommand,
2210
+ );
2211
+ }
2212
+
2213
+ function mergeCodexHooks(hooksSourceRoot, pythonCommand) {
2214
+ return mergeHooksAtPath(
2215
+ hooksSourceRoot,
2216
+ CODEX_HOOKS_CONFIGURATION_PATH,
2217
+ 2,
2218
+ pythonCommand,
2219
+ selectCodexNativeHooksConfig,
2220
+ dirname(CODEX_HOOKS_CONFIGURATION_PATH),
2221
+ );
2222
+ }
2223
+
2220
2224
  function writeManifest(installedFiles, skillNames, managedPermissions = null) {
2221
2225
  const manifest = buildTargetManifestRecord({
2222
2226
  packageName: PACKAGE_NAME,
@@ -2296,7 +2300,7 @@ function mergeManagedPermissions() {
2296
2300
  }
2297
2301
  const settingsPath = join(CLAUDE_HOME, SETTINGS_FILE_NAME);
2298
2302
  const settingsExisted = existsSync(settingsPath);
2299
- const settings = loadClaudeSettingsObjectOrExit(settingsPath);
2303
+ const settings = loadClaudeSettingsObject(settingsPath);
2300
2304
  const pruneOutcome = pruneManagedPermissionsFromSettings(settings, allRetiredManagedDenyEntries);
2301
2305
  const mergeOutcome = hasCurrentManagedDenyEntries
2302
2306
  ? mergeManagedPermissionsIntoSettings(settings, allCurrentManagedDenyEntries)
@@ -2598,6 +2602,7 @@ function executeInstallPlan(plan) {
2598
2602
  managedRoot: CLAUDE_HOME,
2599
2603
  manifestFilePath: MANIFEST_FILE,
2600
2604
  settingsPath: plan.settingsPath,
2605
+ additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
2601
2606
  priorManifestFiles: plan.priorManifest.files,
2602
2607
  journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
2603
2608
  });
@@ -2706,7 +2711,9 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2706
2711
  if (!existsSync(sourceDir)) continue;
2707
2712
  const destDir = join(CLAUDE_HOME, directory);
2708
2713
  if (hasFullAccess) {
2709
- const stats = copyTree(sourceDir, destDir);
2714
+ const stats = copyTree(sourceDir, destDir, directory === 'rules'
2715
+ ? { excludeFileNames: [SUBAGENT_MODEL_POLICY_FILE_NAME] }
2716
+ : {});
2710
2717
  if (!summary[directory]) {
2711
2718
  summary[directory] = stats;
2712
2719
  } else {
@@ -2720,7 +2727,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2720
2727
  let rulesUpdated = 0;
2721
2728
  for (const ruleFile of allowedRules) {
2722
2729
  const sourcePath = join(sourceDir, ruleFile);
2723
- if (!existsSync(sourcePath)) continue;
2730
+ if (!existsSync(sourcePath) || ruleFile === SUBAGENT_MODEL_POLICY_FILE_NAME) continue;
2724
2731
  const destPath = join(destDir, ruleFile);
2725
2732
  mkdirSync(dirname(destPath), { recursive: true });
2726
2733
  const existed = existsSync(destPath);
@@ -2818,6 +2825,11 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2818
2825
  allUserOwnedPreferencePaths.add(seededPreferencePath);
2819
2826
  }
2820
2827
  }
2828
+ const seededPolicyPath = seedSubagentModelPolicy();
2829
+ if (seededPolicyPath) {
2830
+ allInstalledFiles.push(seededPolicyPath);
2831
+ allUserOwnedPreferencePaths.add(seededPolicyPath);
2832
+ }
2821
2833
  syncWrittenPaths(allInstalledFiles);
2822
2834
  }
2823
2835
  let skillsCreated = 0;
@@ -2866,6 +2878,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2866
2878
  let totalHooksCreated = 0;
2867
2879
  let totalHooksUpdated = 0;
2868
2880
  let totalHookGroups = 0;
2881
+ let totalCodexHookGroups = 0;
2869
2882
  for (const sourceRoot of allSourceRoots) {
2870
2883
  const hooksSource = resolvePackageManagedDirectory(
2871
2884
  sourceRoot,
@@ -2891,11 +2904,13 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2891
2904
  }
2892
2905
  const groupCount = mergeHooks(sourceRoot, pythonCommand);
2893
2906
  totalHookGroups += groupCount;
2907
+ totalCodexHookGroups += mergeCodexHooks(sourceRoot, pythonCommand);
2894
2908
  }
2895
2909
  summary.hookFiles = { created: totalHooksCreated, updated: totalHooksUpdated };
2896
2910
  console.log(` Hook files: ${totalHooksCreated} new, ${totalHooksUpdated} updated`);
2897
2911
  summary.hookGroups = totalHookGroups;
2898
- console.log(` Hook groups: ${totalHookGroups} merged into settings.json`);
2912
+ summary.codexHookGroups = totalCodexHookGroups;
2913
+ console.log(` Hook groups: ${totalHookGroups} merged into settings.json, ${totalCodexHookGroups} merged into hooks.json`);
2899
2914
  syncWrittenPaths([...allInstalledFiles, ...publishedPointerPaths]);
2900
2915
  throwIfFault(FAULT_PHASES.AFTER_SETTINGS_WRITE);
2901
2916
 
@@ -3157,15 +3172,40 @@ function resolveUninstallPlan(isManifestRequired) {
3157
3172
  });
3158
3173
  }
3159
3174
 
3160
- /**
3161
- * Apply one preflighted uninstall plan: remove removable files, prune managed
3162
- * hooks from settings, unset core.hooksPath when it points here, then drop the
3163
- * ownership manifest. Fault phases mirror the install transaction names.
3164
- *
3165
- * @param {ReturnType<typeof buildUninstallPlan>} plan
3166
- * @param {{ throwIfFault?: (phase: string) => void }} [helpers]
3167
- * @returns {number} Count of files this call removed.
3168
- */
3175
+ function pruneManagedHooksFromHostConfiguration(settingsPath, defaultIndentation) {
3176
+ if (!existsSync(settingsPath)) return false;
3177
+ let settingsText;
3178
+ let settings;
3179
+ try {
3180
+ settingsText = readFileSync(settingsPath, 'utf8');
3181
+ settings = JSON.parse(settingsText);
3182
+ } catch (readError) {
3183
+ throw new Error(`host hook configuration is malformed JSON: ${readError.message}`);
3184
+ }
3185
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
3186
+ throw new Error('host hook configuration must hold a JSON object');
3187
+ }
3188
+ if (!settings.hooks || typeof settings.hooks !== 'object') return false;
3189
+ const managedHookRelativePaths = managedHookScriptRelativePathsFromSourceRoots(
3190
+ managedPackageSourceRoots(),
3191
+ );
3192
+ pruneManagedHooksFromSettings(settings, managedHookRelativePaths);
3193
+ writeFileSync(
3194
+ settingsPath,
3195
+ JSON.stringify(settings, null, hostConfigurationIndent(settingsText) || defaultIndentation) + '\n',
3196
+ );
3197
+ console.log(` Hook entries removed from ${basename(settingsPath)}`);
3198
+ return true;
3199
+ }
3200
+
3201
+ function codexRoutingHookExists() {
3202
+ return existsSync(join(
3203
+ INSTALL_ROOT_RESOLUTION.codexHooksInstallDirectory,
3204
+ 'routing',
3205
+ 'subagent_model_routing.mjs',
3206
+ ));
3207
+ }
3208
+
3169
3209
  function executeUninstallPlan(plan, helpers = {}) {
3170
3210
  const throwIfFault = helpers.throwIfFault || (() => {});
3171
3211
  let removed = 0;
@@ -3225,6 +3265,9 @@ function executeUninstallPlan(plan, helpers = {}) {
3225
3265
  writeFileSync(plan.settingsPath, JSON.stringify(settings, null, 4) + '\n');
3226
3266
  }
3227
3267
  }
3268
+ if (!codexRoutingHookExists()) {
3269
+ pruneManagedHooksFromHostConfiguration(CODEX_HOOKS_CONFIGURATION_PATH, 2);
3270
+ }
3228
3271
  removeSessionContinuityHooksFromOtherHosts();
3229
3272
  throwIfFault(FAULT_PHASES.AFTER_SETTINGS_WRITE);
3230
3273
 
@@ -3300,6 +3343,7 @@ function uninstall() {
3300
3343
  managedRoot: CLAUDE_HOME,
3301
3344
  manifestFilePath: MANIFEST_FILE,
3302
3345
  settingsPath: plan.settingsPath,
3346
+ additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
3303
3347
  priorManifestFiles: plan.removableFiles,
3304
3348
  journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
3305
3349
  });
@@ -3447,7 +3491,9 @@ function loadDirectoryNameByProfileId() {
3447
3491
  function runInstallForAllTargets(allTargets, childArgv) {
3448
3492
  if (allTargets.length === 1) {
3449
3493
  const onlyTarget = allTargets[0];
3450
- if (onlyTarget.managedRoot !== CLAUDE_HOME || onlyTarget.targetIdentity !== INSTALL_TARGET_IDENTITY) {
3494
+ if (onlyTarget.managedRoot !== CLAUDE_HOME
3495
+ || onlyTarget.targetIdentity !== INSTALL_TARGET_IDENTITY
3496
+ || !isDefaultClaudeTarget(onlyTarget)) {
3451
3497
  const childStatus = spawnInstallChild(onlyTarget, childArgv);
3452
3498
  process.exit(childStatus);
3453
3499
  }
@@ -3474,6 +3520,13 @@ function runInstallForAllTargets(allTargets, childArgv) {
3474
3520
  * @returns {number | null}
3475
3521
  */
3476
3522
  function spawnInstallChild(target, childArgv) {
3523
+ const childEnvironment = { ...process.env };
3524
+ if (!isDefaultClaudeTarget(target)) {
3525
+ childEnvironment[CODEX_HOME_ENVIRONMENT_VARIABLE] = join(
3526
+ target.managedRoot,
3527
+ DEFAULT_CODEX_DIRECTORY_NAME,
3528
+ );
3529
+ }
3477
3530
  const childProcess = spawnSync(
3478
3531
  process.execPath,
3479
3532
  [
@@ -3486,7 +3539,7 @@ function spawnInstallChild(target, childArgv) {
3486
3539
  ],
3487
3540
  {
3488
3541
  stdio: 'inherit',
3489
- env: process.env,
3542
+ env: childEnvironment,
3490
3543
  },
3491
3544
  );
3492
3545
  if (childProcess.error) {
@@ -3496,6 +3549,10 @@ function spawnInstallChild(target, childArgv) {
3496
3549
  return childProcess.status === null ? 1 : childProcess.status;
3497
3550
  }
3498
3551
 
3552
+ function isDefaultClaudeTarget(target) {
3553
+ return resolve(target.managedRoot) === resolve(join(homedir(), '.claude'));
3554
+ }
3555
+
3499
3556
  if (invokedAsEntryPoint(import.meta.url, process.argv[1])) {
3500
3557
  const rawArgs = process.argv.slice(2);
3501
3558
  if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
@@ -3541,6 +3598,10 @@ if (invokedAsEntryPoint(import.meta.url, process.argv[1])) {
3541
3598
  || (
3542
3599
  targetSelection.mode === 'profiles'
3543
3600
  && allTargets[0].targetIdentity !== INSTALL_TARGET_IDENTITY
3601
+ )
3602
+ || (
3603
+ targetSelection.mode !== 'child-identity'
3604
+ && !isDefaultClaudeTarget(allTargets[0])
3544
3605
  );
3545
3606
 
3546
3607
  if (needsChildHop && !args.includes('--uninstall')) {
@@ -134,6 +134,44 @@ test('capture and restore recover settings, manifest, files, and hooksPath', ()
134
134
  rmSync(box.root, { recursive: true, force: true });
135
135
  });
136
136
 
137
+ test('capture and restore recover an additional host settings file', () => {
138
+ const box = sandbox();
139
+ const gitConfigPath = join(box.root, '.gitconfig');
140
+ const codexHooksPath = join(box.root, '.codex', 'hooks.json');
141
+ writeFileSync(gitConfigPath, '');
142
+ writeFileWithParents(box.settingsPath, '{"host":"claude"}\n');
143
+ writeFileWithParents(codexHooksPath, '{"host":"codex"}\n');
144
+ const io = {
145
+ env: {
146
+ ...process.env,
147
+ CDE_INSTALL_PSTACK: '0',
148
+ HOME: box.root,
149
+ USERPROFILE: box.root,
150
+ GIT_CONFIG_GLOBAL: gitConfigPath,
151
+ },
152
+ execFileSync,
153
+ };
154
+
155
+ const snapshot = capturePriorInstallSnapshot({
156
+ managedRoot: box.managedRoot,
157
+ manifestFilePath: box.manifestFilePath,
158
+ settingsPath: box.settingsPath,
159
+ additionalSettingsPaths: [codexHooksPath],
160
+ priorManifestFiles: [],
161
+ journalParentDirectory: box.journalParent,
162
+ io,
163
+ });
164
+ writeFileSync(box.settingsPath, '{"host":"changed"}\n');
165
+ writeFileSync(codexHooksPath, '{"host":"changed"}\n');
166
+
167
+ restorePriorInstallSnapshot(snapshot, { io });
168
+
169
+ assert.equal(readFileSync(box.settingsPath, 'utf8'), '{"host":"claude"}\n');
170
+ assert.equal(readFileSync(codexHooksPath, 'utf8'), '{"host":"codex"}\n');
171
+ discardInstallTransactionJournal(snapshot);
172
+ rmSync(box.root, { recursive: true, force: true });
173
+ });
174
+
137
175
  test('runWithInstallTransaction restores prior state on injected fault', () => {
138
176
  const box = sandbox();
139
177
  const gitConfigPath = join(box.root, '.gitconfig');
@@ -312,12 +350,16 @@ test('installer fault after_settings_write restores prior settings and files', (
312
350
  try {
313
351
  const { claudeDirectory, rulesFile } = seedPriorInstall(homeDirectory);
314
352
  const priorSettings = readFileSync(join(claudeDirectory, 'settings.json'), 'utf8');
353
+ const codexHooksPath = join(homeDirectory, '.codex', 'hooks.json');
354
+ writeFileWithParents(codexHooksPath, '{"hooks":{"PreToolUse":[{"matcher":"custom"}]}}\n');
355
+ const priorCodexHooks = readFileSync(codexHooksPath, 'utf8');
315
356
 
316
357
  const failedRun = runInstaller(homeDirectory, [], {
317
358
  faultPhase: FAULT_PHASES.AFTER_SETTINGS_WRITE,
318
359
  });
319
360
  assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
320
361
  assert.equal(readFileSync(join(claudeDirectory, 'settings.json'), 'utf8'), priorSettings);
362
+ assert.equal(readFileSync(codexHooksPath, 'utf8'), priorCodexHooks);
321
363
  assert.equal(readFileSync(rulesFile, 'utf8'), 'prior-body\n');
322
364
  assert.match(
323
365
  readFileSync(join(claudeDirectory, '.claude-dev-env-manifest.json'), 'utf8'),
@@ -328,6 +370,25 @@ test('installer fault after_settings_write restores prior settings and files', (
328
370
  }
329
371
  });
330
372
 
373
+ test('installer fault after_settings_write removes a newly created Codex hooks file', () => {
374
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-txn-e2e-absent-codex-hooks-'));
375
+ try {
376
+ const { claudeDirectory } = seedPriorInstall(homeDirectory);
377
+ const codexHooksPath = join(homeDirectory, '.codex', 'hooks.json');
378
+ const failedRun = runInstaller(homeDirectory, [], {
379
+ faultPhase: FAULT_PHASES.AFTER_SETTINGS_WRITE,
380
+ });
381
+ assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
382
+ assert.equal(existsSync(codexHooksPath), false);
383
+ assert.equal(
384
+ existsSync(join(claudeDirectory, '.claude-dev-env-manifest.json')),
385
+ true,
386
+ );
387
+ } finally {
388
+ rmSync(homeDirectory, { recursive: true, force: true });
389
+ }
390
+ });
391
+
331
392
  test('installer fault after_manifest_write restores prior manifest', () => {
332
393
  const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-txn-e2e-manifest-'));
333
394
  try {
@@ -404,4 +465,3 @@ test('successful install after prior install leaves one manifest and no fault',
404
465
  rmSync(homeDirectory, { recursive: true, force: true });
405
466
  }
406
467
  });
407
-
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "host": "codex",
3
+ "defaultEffort": "medium",
3
4
  "modelsByRole": {
4
5
  "feature, refactoring": ["gpt-5.6-sol"],
5
6
  "bug-fix": ["gpt-5.6-sol"],
package/bin/pstack.mjs CHANGED
@@ -67,6 +67,9 @@ function adapterFiles(root) {
67
67
  ...['common.md', ...hosts.map(host => `host-${host}.md`), 'create-skill.md'].map(name => [name, join(adapters, name)]),
68
68
  ...['pstack-host-mapping.md', 'pstack-models.md'].map(name => [name, join(root, 'rules', name)]),
69
69
  ['select_pstack_models.mjs', join(root, 'scripts', 'select_pstack_models.mjs')],
70
+ ['subagent_model_policy.mjs', join(root, 'scripts', 'subagent_model_policy.mjs')],
71
+ ['resolve_advisor_model_route.mjs', join(root, 'scripts', 'resolve_advisor_model_route.mjs')],
72
+ ['subagent-model-policy.json', join(root, 'rules', 'subagent-model-policy.json')],
70
73
  ].map(([name, path]) => [name, readFileSync(path, 'utf8')]));
71
74
  }
72
75
 
@@ -141,7 +144,7 @@ export function prepareRelease(checkout, stage, finalRoot, lock, adapters) {
141
144
  const fileDigests = Object.fromEntries(filesUnder(stage).map(path => [relative(stage, path), digest(readFileSync(path))]));
142
145
  const managedRoot = dirname(dirname(dirname(finalRoot)));
143
146
  const agentsRoot = basename(managedRoot) === '.claude' ? join(dirname(managedRoot), '.agents') : managedRoot + '.agents';
144
- const release = { preferencesDirectory: join(agentsRoot, 'rules'), selectorPath: join(finalRoot, 'compat', 'select_pstack_models.mjs'), schemaVersion: 1, commit: lock.commit, adapterVersion: lock.adapterVersion, skills, agents, fileDigests, verification: 'filesystem-only' };
147
+ const release = { preferencesDirectory: join(agentsRoot, 'rules'), policyPath: join(agentsRoot, 'rules', 'subagent-model-policy.json'), selectorPath: join(finalRoot, 'compat', 'select_pstack_models.mjs'), schemaVersion: 1, commit: lock.commit, adapterVersion: lock.adapterVersion, skills, agents, fileDigests, verification: 'filesystem-only' };
145
148
  writeFileSync(join(stage, 'release.json'), json(release));
146
149
  return release;
147
150
  }
@@ -171,7 +174,7 @@ export function verifyRelease(root) {
171
174
  function layout(options) {
172
175
  const root = resolve(options.root ?? (options.project ? join(options.project, '.claude') : process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')));
173
176
  const agents = basename(root) === '.claude' ? join(dirname(root), '.agents') : root + '.agents';
174
- return { root, store: join(root, 'pstack'), skillHomes: [join(root, 'skills'), join(agents, 'skills')] };
177
+ return { root, agents, store: join(root, 'pstack'), skillHomes: [join(root, 'skills'), join(agents, 'skills')] };
175
178
  }
176
179
 
177
180
  function loadState(store) {
@@ -224,6 +227,19 @@ function publish(releaseRoot, release, homes, prior) {
224
227
  return { links, restore };
225
228
  }
226
229
 
230
+ function seedPolicy(releaseRoot, release, agentsRoot) {
231
+ const policyPath = join(agentsRoot, 'rules', 'subagent-model-policy.json');
232
+ if (typeof release.policyPath !== 'string') throw new Error('Invalid release policy path');
233
+ if (resolve(release.policyPath) !== resolve(policyPath)) throw new Error('Invalid release policy path');
234
+ mkdirSync(dirname(policyPath), { recursive: true });
235
+ if (entryExists(policyPath)) {
236
+ if (!lstatSync(policyPath).isFile()) throw new Error(`Keep existing unmanaged policy path: ${policyPath}`);
237
+ return () => {};
238
+ }
239
+ writeFileSync(policyPath, readFileSync(join(releaseRoot, 'compat', 'subagent-model-policy.json')), { flag: 'wx' });
240
+ return () => { if (entryExists(policyPath)) unlinkSync(policyPath); };
241
+ }
242
+
227
243
  function liveLeases(store) {
228
244
  const root = join(store, 'sessions');
229
245
  if (!existsSync(root)) return false;
@@ -247,7 +263,7 @@ function readCentralLock(temporaryRoot) {
247
263
  }
248
264
 
249
265
  export function installPstack(options = {}, dependencies = {}) {
250
- const { store, skillHomes } = layout(options);
266
+ const { agents, store, skillHomes } = layout(options);
251
267
  mkdirSync(store, { recursive: true });
252
268
  const lockDirectory = join(store, '.install-lock');
253
269
  mkdirSync(lockDirectory);
@@ -291,12 +307,18 @@ export function installPstack(options = {}, dependencies = {}) {
291
307
  renameSync(stage, releaseRoot);
292
308
  }
293
309
  const release = verifyRelease(releaseRoot);
294
- const publication = publish(releaseRoot, release, skillHomes, prior);
295
- const state = { release: id, commit: lock.commit, adapterVersion: lock.adapterVersion, adapterDigest, checkedAt, lock, links: publication.links };
310
+ const policyPublication = seedPolicy(releaseRoot, release, agents);
311
+ let publication;
296
312
  try {
313
+ publication = publish(releaseRoot, release, skillHomes, prior);
314
+ const state = { release: id, commit: lock.commit, adapterVersion: lock.adapterVersion, adapterDigest, checkedAt, lock, links: publication.links };
297
315
  writeFileSync(join(store, 'installed.next.json'), json(state));
298
316
  renameSync(join(store, 'installed.next.json'), join(store, 'installed.json'));
299
- } catch (error) { publication.restore(); throw error; }
317
+ } catch (error) {
318
+ publication?.restore();
319
+ policyPublication();
320
+ throw error;
321
+ }
300
322
  return finish({ ...verifyInstallation(options), status: prior?.release === id ? 'unchanged' : 'installed' });
301
323
  } catch (error) {
302
324
  if (!options.strict) {