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
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
|
|
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
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
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
|
|
2214
|
-
const
|
|
2215
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
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
|
|
295
|
-
|
|
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) {
|
|
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) {
|