claude-dev-env 8.88.1 → 8.88.2
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/bin/codex-trimmed-sol.mjs +37 -2
- package/bin/codex-trimmed-sol.test.mjs +39 -1
- package/bin/folder-lockers.mjs +142 -0
- package/bin/folder-lockers.test.mjs +55 -0
- package/bin/global-install-shadow.mjs +75 -0
- package/bin/global-install-shadow.test.mjs +56 -0
- package/bin/install-deepstack-plugin.mjs +45 -16
- package/bin/install-deepstack-plugin.test.mjs +65 -1
- package/bin/install-pstack-plugin.mjs +20 -4
- package/bin/install-pstack-plugin.test.mjs +53 -0
- package/bin/install.mjs +40 -4
- package/bin/install.test.mjs +54 -0
- package/docs/agents-guidance/asks.md +1 -0
- package/docs/codex-accounts.md +4 -1
- package/docs/decisions/deepstack-gate.md +6 -2
- package/hooks/features/session-context.md +1 -1
- package/hooks/hooks_constants/deepen_default_rewrite_constants.py +39 -0
- package/hooks/session/deepen_default_rewrite.py +128 -0
- package/hooks/session/deepen_first_prompt.py +3 -15
- package/hooks/session/test_deepen_default_rewrite.py +90 -0
- package/hooks/session/test_deepen_first_prompt.py +12 -1
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { existsSync, readdirSync, rmSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { findFolderLockers, lockedFolderAdvice, namesALockedFolder } from './folder-lockers.mjs';
|
|
4
5
|
|
|
5
6
|
export const PSTACK_MARKETPLACE_REPOSITORY = 'jl-cmd/pstack-claude';
|
|
6
7
|
export const PSTACK_MARKETPLACE_NAME = 'pstack-claude';
|
|
@@ -248,7 +249,17 @@ function absentHostOutcome(allSpecs, host, executable) {
|
|
|
248
249
|
};
|
|
249
250
|
}
|
|
250
251
|
|
|
251
|
-
function
|
|
252
|
+
function lockedFolderDetail(allSpecs, host, homeDirectory, errorText, findLockers) {
|
|
253
|
+
const hostDefinition = HOST_DEFINITIONS[host];
|
|
254
|
+
if (!namesALockedFolder(errorText) || !hostDefinition.orphanedVersionsDirectory || !homeDirectory) return '';
|
|
255
|
+
return allSpecs.map(eachSpec => {
|
|
256
|
+
const pluginCacheDirectory = hostDefinition.orphanedVersionsDirectory(homeDirectory, eachSpec);
|
|
257
|
+
const allLockers = findLockers(pluginCacheDirectory);
|
|
258
|
+
return allLockers === null ? '' : ` ${lockedFolderAdvice(pluginCacheDirectory, allLockers)}`;
|
|
259
|
+
}).join('');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function installForHost(allSpecs, host, homeDirectory, environment, runCommand, findLockers) {
|
|
252
263
|
const plan = marketplacePluginsPlan(allSpecs, host);
|
|
253
264
|
const executable = environment[plan.executableVariable] || plan.executable;
|
|
254
265
|
const commandEnvironment = { [plan.homeVariable]: homeDirectory };
|
|
@@ -265,11 +276,12 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand)
|
|
|
265
276
|
if (namesAnAbsentCommand(outcome)) return absentHostOutcome(allSpecs, host, executable);
|
|
266
277
|
if (outcome.status !== 0 || outcome.error) {
|
|
267
278
|
const detail = singleLineDetail(outcome.stderr) || outcome.error?.message || `exit ${outcome.status}`;
|
|
279
|
+
const lockAdvice = lockedFolderDetail(allSpecs, host, homeDirectory, detail, findLockers);
|
|
268
280
|
return {
|
|
269
281
|
host,
|
|
270
282
|
executable,
|
|
271
283
|
status: 'failed',
|
|
272
|
-
warning: `${executable} ${commandArguments.join(' ')} failed: ${detail}`,
|
|
284
|
+
warning: `${executable} ${commandArguments.join(' ')} failed: ${detail}${lockAdvice}`,
|
|
273
285
|
};
|
|
274
286
|
}
|
|
275
287
|
}
|
|
@@ -294,7 +306,9 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand)
|
|
|
294
306
|
* and skills this run already wrote still reach their durable places. An
|
|
295
307
|
* absent tool arrives as a spawn `ENOENT` on other platforms and as
|
|
296
308
|
* `cmd.exe`'s command-not-found exit code on Windows, and both read as
|
|
297
|
-
* skipped.
|
|
309
|
+
* skipped. When a host cannot replace a plugin folder because something holds
|
|
310
|
+
* it, the warning names each process that holds it, so the user knows what to
|
|
311
|
+
* close before running the install again.
|
|
298
312
|
*
|
|
299
313
|
* @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins to install, from one marketplace.
|
|
300
314
|
* @param {object} [options] Install targets.
|
|
@@ -302,12 +316,13 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand)
|
|
|
302
316
|
* @param {string} [options.codexHome] The Codex home to install into.
|
|
303
317
|
* @param {string[]} [options.hosts] The hosts to install. Defaults to the hosts every spec names.
|
|
304
318
|
* @param {Record<string, string|undefined>} [options.environment] The process environment.
|
|
305
|
-
* @param {object} [dependencies] Seams for the command runner.
|
|
319
|
+
* @param {object} [dependencies] Seams for the command runner and the folder locker search.
|
|
306
320
|
* @returns {{status: string, hosts: object[], warning: string|null}} The outcome per host.
|
|
307
321
|
*/
|
|
308
322
|
export function installMarketplacePlugins(allSpecs, options = {}, dependencies = {}) {
|
|
309
323
|
const environment = options.environment ?? process.env;
|
|
310
324
|
const runCommand = dependencies.runCommand ?? runHostCommand;
|
|
325
|
+
const findLockers = dependencies.findLockers ?? findFolderLockers;
|
|
311
326
|
const homeDirectories = { claude: options.claudeRoot, codex: options.codexHome };
|
|
312
327
|
const hosts = options.hosts ?? allSpecs[0].hosts.filter(
|
|
313
328
|
eachHost => allSpecs.every(eachSpec => eachSpec.hosts.includes(eachHost)),
|
|
@@ -318,6 +333,7 @@ export function installMarketplacePlugins(allSpecs, options = {}, dependencies =
|
|
|
318
333
|
homeDirectories[host],
|
|
319
334
|
environment,
|
|
320
335
|
runCommand,
|
|
336
|
+
findLockers,
|
|
321
337
|
));
|
|
322
338
|
const failedCount = hostOutcomes.filter(outcome => outcome.status === 'failed').length;
|
|
323
339
|
const installedCount = hostOutcomes.filter(outcome => outcome.status === 'installed').length;
|
|
@@ -392,3 +392,56 @@ test('a clean install deletes the orphaned pstack versions and keeps the install
|
|
|
392
392
|
assert.equal(existsSync(join(versionsDirectory, '0.9.64')), true);
|
|
393
393
|
assert.deepEqual(removeOrphanedPluginVersions(versionsDirectory), []);
|
|
394
394
|
});
|
|
395
|
+
|
|
396
|
+
test('a plugin folder held by another program names the process to close', () => {
|
|
397
|
+
const lockedError = 'Failed to install plugin "pstack@pstack-claude": The installed copy of this plugin version'
|
|
398
|
+
+ ' could not be replaced: it is in use by another program (EPERM).';
|
|
399
|
+
const searchedFolders = [];
|
|
400
|
+
const runner = recordingRunner((executable, commandArguments) => (
|
|
401
|
+
executable === 'claude' && commandArguments[1] === 'install'
|
|
402
|
+
? { status: 1, stderr: lockedError }
|
|
403
|
+
: { status: 0, stderr: '' }));
|
|
404
|
+
const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
|
|
405
|
+
runCommand: runner.runCommand,
|
|
406
|
+
findLockers: folderPath => {
|
|
407
|
+
searchedFolders.push(folderPath);
|
|
408
|
+
return [{ pid: 19056, name: 'Cursor.exe', reason: 'command-line' }];
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
const claude = outcome.hosts.find(host => host.host === 'claude');
|
|
412
|
+
assert.equal(claude.status, 'failed');
|
|
413
|
+
assert.deepEqual(searchedFolders, [join('/managed/.claude', 'plugins', 'cache', 'pstack-claude', 'pstack')]);
|
|
414
|
+
assert.match(claude.warning, /Close Cursor\.exe \(PID 19056, was started on a path inside it\)/);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('a failure that is not a held folder runs no locker search', () => {
|
|
418
|
+
const runner = recordingRunner((executable, commandArguments) => (
|
|
419
|
+
executable === 'claude' && commandArguments[2] === 'add'
|
|
420
|
+
? { status: 1, stderr: 'owner: Invalid input: expected object, received undefined' }
|
|
421
|
+
: { status: 0, stderr: '' }));
|
|
422
|
+
let searched = false;
|
|
423
|
+
installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
|
|
424
|
+
runCommand: runner.runCommand,
|
|
425
|
+
findLockers: () => {
|
|
426
|
+
searched = true;
|
|
427
|
+
return [];
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
assert.equal(searched, false);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('a held folder that was not searched adds no claim about what holds it', () => {
|
|
434
|
+
const lockedError = 'Failed to install plugin "pstack@pstack-claude": EPERM: operation not permitted';
|
|
435
|
+
const runner = recordingRunner((executable, commandArguments) => (
|
|
436
|
+
executable === 'claude' && commandArguments[1] === 'install'
|
|
437
|
+
? { status: 1, stderr: lockedError }
|
|
438
|
+
: { status: 0, stderr: '' }));
|
|
439
|
+
const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
|
|
440
|
+
runCommand: runner.runCommand,
|
|
441
|
+
findLockers: () => null,
|
|
442
|
+
});
|
|
443
|
+
const claude = outcome.hosts.find(host => host.host === 'claude');
|
|
444
|
+
assert.equal(claude.status, 'failed');
|
|
445
|
+
assert.match(claude.warning, /EPERM: operation not permitted$/);
|
|
446
|
+
assert.doesNotMatch(claude.warning, /likely holder|Close /);
|
|
447
|
+
});
|
package/bin/install.mjs
CHANGED
|
@@ -72,7 +72,9 @@ import {
|
|
|
72
72
|
writeCursorAgentsGuidanceHooks,
|
|
73
73
|
} from './cursor-agents-guidance.mjs';
|
|
74
74
|
import {
|
|
75
|
+
codexSolPromptPath,
|
|
75
76
|
codexSolSettingSnapshotPaths,
|
|
77
|
+
installCodexSolPrompt,
|
|
76
78
|
removeCodexSolSetting,
|
|
77
79
|
SOL_PROMPT_DIRECTORY_NAME,
|
|
78
80
|
SOL_PROMPT_FILE_NAME,
|
|
@@ -126,6 +128,7 @@ import {
|
|
|
126
128
|
resolveFaultPhaseFromEnvironment,
|
|
127
129
|
runWithInstallTransaction,
|
|
128
130
|
} from './install-transaction.mjs';
|
|
131
|
+
import { findShadowingGlobalInstall, shadowingGlobalAdvice } from './global-install-shadow.mjs';
|
|
129
132
|
import { pythonExecutableProbeCommand } from './python-probe.mjs';
|
|
130
133
|
|
|
131
134
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -443,16 +446,28 @@ export function installPstackPluginForHosts() {
|
|
|
443
446
|
});
|
|
444
447
|
}
|
|
445
448
|
|
|
449
|
+
/** Side steps that need the user, repeated at the end of the run so none scrolls past unseen. */
|
|
450
|
+
const allActionNeededLines = [];
|
|
451
|
+
|
|
446
452
|
function printPluginHostOutcomes(allSpecs, pluginOutcome) {
|
|
447
453
|
for (const eachSpec of allSpecs) {
|
|
448
454
|
for (const eachHost of pluginOutcome.hosts) {
|
|
449
|
-
|
|
450
|
-
?
|
|
451
|
-
:
|
|
455
|
+
const outcomeLine = eachHost.warning
|
|
456
|
+
? `${eachSpec.label} (${eachHost.host}): ${eachHost.status} — ${eachHost.warning}`
|
|
457
|
+
: `${eachSpec.label} (${eachHost.host}): ${eachHost.status}`;
|
|
458
|
+
console.log(` ${outcomeLine}`);
|
|
459
|
+
if (eachHost.status === 'failed') allActionNeededLines.push(outcomeLine);
|
|
452
460
|
}
|
|
453
461
|
}
|
|
454
462
|
}
|
|
455
463
|
|
|
464
|
+
function printActionNeeded() {
|
|
465
|
+
if (allActionNeededLines.length === 0) return;
|
|
466
|
+
console.warn(`Action needed (${allActionNeededLines.length}):`);
|
|
467
|
+
for (const eachLine of allActionNeededLines) console.warn(` - ${eachLine}`);
|
|
468
|
+
console.warn('');
|
|
469
|
+
}
|
|
470
|
+
|
|
456
471
|
function seedSubagentModelPolicy() {
|
|
457
472
|
const policyDirectory = join(AGENTS_HOME, CURSOR_RULES_DIRECTORY_NAME);
|
|
458
473
|
const policyPath = join(policyDirectory, SUBAGENT_MODEL_POLICY_FILE_NAME);
|
|
@@ -2691,6 +2706,7 @@ function executeInstallPlan(plan) {
|
|
|
2691
2706
|
...(plan.priorManifest.files || []),
|
|
2692
2707
|
join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, 'AGENTS.md'),
|
|
2693
2708
|
...codexSolSettingSnapshotPaths(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
|
|
2709
|
+
codexSolPromptPath(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
|
|
2694
2710
|
],
|
|
2695
2711
|
journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
|
|
2696
2712
|
});
|
|
@@ -2737,6 +2753,16 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
2737
2753
|
const groupLabel = selectedGroups ? `groups: ${selectedGroups.join(', ')}` : 'all';
|
|
2738
2754
|
console.log(`\nInstalling ${PACKAGE_NAME} (${groupLabel})...\n`);
|
|
2739
2755
|
console.log(` Python: ${pythonCommand}`);
|
|
2756
|
+
const shadowingGlobal = findShadowingGlobalInstall({
|
|
2757
|
+
packageName: PACKAGE_NAME,
|
|
2758
|
+
packageRoot: PACKAGE_ROOT,
|
|
2759
|
+
packageVersion: PACKAGE_VERSION,
|
|
2760
|
+
});
|
|
2761
|
+
if (shadowingGlobal) {
|
|
2762
|
+
const shadowAdvice = shadowingGlobalAdvice(PACKAGE_NAME, shadowingGlobal, PACKAGE_VERSION);
|
|
2763
|
+
console.warn(` Warning: ${shadowAdvice}`);
|
|
2764
|
+
allActionNeededLines.push(shadowAdvice);
|
|
2765
|
+
}
|
|
2740
2766
|
mkdirSync(CLAUDE_HOME, { recursive: true });
|
|
2741
2767
|
const publishedPointerPaths = publishManagedLookupPointers();
|
|
2742
2768
|
|
|
@@ -2996,6 +3022,9 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
2996
3022
|
console.log(cursorOutcome.warning
|
|
2997
3023
|
? ` DeepStack (cursor): ${cursorOutcome.status} — ${cursorOutcome.warning}`
|
|
2998
3024
|
: ` DeepStack (cursor): ${cursorOutcome.status}`);
|
|
3025
|
+
if (cursorOutcome.status === 'failed') {
|
|
3026
|
+
allActionNeededLines.push(`DeepStack (cursor): failed — ${cursorOutcome.warning}`);
|
|
3027
|
+
}
|
|
2999
3028
|
}
|
|
3000
3029
|
const allRepositoryPluginSpecs = [
|
|
3001
3030
|
USAGE_WRAPUP_PLUGIN_SPEC,
|
|
@@ -3028,9 +3057,14 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
3028
3057
|
INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
|
|
3029
3058
|
);
|
|
3030
3059
|
if (deepenGuidancePath) allCodexGuidancePaths.push(deepenGuidancePath);
|
|
3060
|
+
const solPromptPath = installCodexSolPrompt(
|
|
3061
|
+
INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
|
|
3062
|
+
join(PACKAGE_ROOT, SOL_PROMPT_DIRECTORY_NAME, SOL_PROMPT_FILE_NAME),
|
|
3063
|
+
);
|
|
3064
|
+
if (solPromptPath) allCodexGuidancePaths.push(solPromptPath);
|
|
3031
3065
|
const solSettingPath = writeCodexSolSetting(
|
|
3032
3066
|
INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
|
|
3033
|
-
|
|
3067
|
+
codexSolPromptPath(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
|
|
3034
3068
|
);
|
|
3035
3069
|
if (solSettingPath) {
|
|
3036
3070
|
allCodexGuidancePaths.push(solSettingPath);
|
|
@@ -3248,6 +3282,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
3248
3282
|
console.log(` hooks: ${summary.hookFiles.created + summary.hookFiles.updated} files, ${summary.hookGroups} groups in settings.json`);
|
|
3249
3283
|
}
|
|
3250
3284
|
console.log(` python: ${pythonCommand}\n`);
|
|
3285
|
+
printActionNeeded();
|
|
3251
3286
|
}
|
|
3252
3287
|
|
|
3253
3288
|
function normalizePathForComparison(rawPath) {
|
|
@@ -3494,6 +3529,7 @@ function uninstall() {
|
|
|
3494
3529
|
...plan.removableFiles,
|
|
3495
3530
|
join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, 'AGENTS.md'),
|
|
3496
3531
|
...codexSolSettingSnapshotPaths(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
|
|
3532
|
+
codexSolPromptPath(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
|
|
3497
3533
|
],
|
|
3498
3534
|
journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
|
|
3499
3535
|
});
|
package/bin/install.test.mjs
CHANGED
|
@@ -3633,3 +3633,57 @@ test('a full install clears a retired session-continuity registration from every
|
|
|
3633
3633
|
);
|
|
3634
3634
|
}
|
|
3635
3635
|
});
|
|
3636
|
+
|
|
3637
|
+
function installWithGlobalPrefix(context, globalPrefix) {
|
|
3638
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cde-action-needed-'));
|
|
3639
|
+
context.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
|
|
3640
|
+
return spawnSync(process.execPath, [join(dirname(fileURLToPath(import.meta.url)), 'install.mjs')], {
|
|
3641
|
+
cwd: dirname(dirname(fileURLToPath(import.meta.url))),
|
|
3642
|
+
encoding: 'utf8',
|
|
3643
|
+
env: {
|
|
3644
|
+
...process.env,
|
|
3645
|
+
CLAUDE_CONFIG_DIR: undefined,
|
|
3646
|
+
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
3647
|
+
CLAUDE_DEV_ENV_INSTALL_FAULT: undefined,
|
|
3648
|
+
CDE_INSTALL_PSTACK: '0',
|
|
3649
|
+
CDE_INSTALL_DEEPSTACK: '0',
|
|
3650
|
+
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
3651
|
+
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
3652
|
+
CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
|
|
3653
|
+
HOME: homeDirectory,
|
|
3654
|
+
USERPROFILE: homeDirectory,
|
|
3655
|
+
APPDATA: join(homeDirectory, 'AppData', 'Roaming'),
|
|
3656
|
+
CODEX_HOME: join(homeDirectory, '.codex'),
|
|
3657
|
+
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
3658
|
+
TEMP: homeDirectory,
|
|
3659
|
+
TMP: homeDirectory,
|
|
3660
|
+
npm_config_prefix: undefined,
|
|
3661
|
+
npm_config_global_prefix: globalPrefix ?? join(homeDirectory, 'no-global-prefix'),
|
|
3662
|
+
},
|
|
3663
|
+
});
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
test('install ends with an Action needed block naming an older global copy that shadows npx', (context) => {
|
|
3667
|
+
const globalPrefix = mkdtempSync(join(tmpdir(), 'cde-global-prefix-'));
|
|
3668
|
+
context.after(() => rmSync(globalPrefix, { recursive: true, force: true }));
|
|
3669
|
+
const globalPackage = process.platform === 'win32'
|
|
3670
|
+
? join(globalPrefix, 'node_modules', 'claude-dev-env')
|
|
3671
|
+
: join(globalPrefix, 'lib', 'node_modules', 'claude-dev-env');
|
|
3672
|
+
mkdirSync(globalPackage, { recursive: true });
|
|
3673
|
+
writeFileSync(join(globalPackage, 'package.json'), JSON.stringify({ name: 'claude-dev-env', version: '0.0.1' }));
|
|
3674
|
+
|
|
3675
|
+
const result = installWithGlobalPrefix(context, globalPrefix);
|
|
3676
|
+
|
|
3677
|
+
assert.equal(result.status, 0, result.stderr);
|
|
3678
|
+
const actionIndex = result.stderr.lastIndexOf('Action needed (1):');
|
|
3679
|
+
assert.ok(actionIndex >= 0, result.stderr);
|
|
3680
|
+
assert.match(result.stderr.slice(actionIndex), /claude-dev-env 0\.0\.1/);
|
|
3681
|
+
assert.match(result.stderr.slice(actionIndex), /npm uninstall -g claude-dev-env/);
|
|
3682
|
+
});
|
|
3683
|
+
|
|
3684
|
+
test('install prints no Action needed block when every side step finished', (context) => {
|
|
3685
|
+
const result = installWithGlobalPrefix(context, null);
|
|
3686
|
+
|
|
3687
|
+
assert.equal(result.status, 0, result.stderr);
|
|
3688
|
+
assert.equal(result.stderr.includes('Action needed'), false, result.stderr);
|
|
3689
|
+
});
|
|
@@ -40,6 +40,7 @@ What has been seen to work, in an interactive `cursor-agent` session (version 20
|
|
|
40
40
|
- The `sessionStart` entry runs, and the session can quote the Asks line. Without it, the session cannot.
|
|
41
41
|
- The `stop` entry runs at the end of each turn. With an open todo left, the gate sent one follow-up, and the agent closed the turn with a `Blocked:` line. Without the gate, the turn ended at "Done." with the todo still open.
|
|
42
42
|
- A turn cancelled with Esc sends `stop` with status `aborted`, then `error`. The gate stays silent for both.
|
|
43
|
+
- On Windows, each hook command runs in PowerShell: Cursor writes it into a temporary `.ps1` script and starts `pwsh -NonInteractive -File` on it. A command that starts with a quote gets the `&` call operator first, so a quoted Python path with a space runs. A probe from `C:/.../py space/venv/Scripts/python.exe` ran with both a bare and a quoted script path. The Cursor editor (3.24.12) has the same `&` rule in its hook runner.
|
|
43
44
|
|
|
44
45
|
Limits:
|
|
45
46
|
|
package/docs/codex-accounts.md
CHANGED
|
@@ -133,7 +133,10 @@ Once installed, that is `~/.claude/scripts/account_broker.py`.
|
|
|
133
133
|
|
|
134
134
|
The installer writes one top-level line into `~/.codex/config.toml`, under a
|
|
135
135
|
`# claude-dev-env trimmed Sol prompt` marker. The line points
|
|
136
|
-
`model_instructions_file` at `~/.
|
|
136
|
+
`model_instructions_file` at `~/.codex/codex-sol.md`, a copy of the package's
|
|
137
|
+
`system-prompts/codex-sol.md`. The copy sits beside `config.toml`, so pruning
|
|
138
|
+
the managed files under `~/.claude`, which an older package version does on
|
|
139
|
+
`--update`, cannot leave Codex pointing at a missing file. The file
|
|
137
140
|
is Codex's default `gpt-6.1-sol` prompt with the lines that conflict with the
|
|
138
141
|
package rules rewritten. The line names no model, so every Codex run uses the
|
|
139
142
|
file, whichever model it selects. `sync` links `config.toml` into every account
|
|
@@ -14,15 +14,19 @@ The filled four-answer stub printed `decision` `deepen`. The rewritten prompt ca
|
|
|
14
14
|
|
|
15
15
|
| Reason | When |
|
|
16
16
|
|---|---|
|
|
17
|
-
| `restates-four-questions` | The rewrite contains every canonical question stem. The paste path
|
|
17
|
+
| `restates-four-questions` | The rewrite contains every canonical question stem. The old paste path was this case. |
|
|
18
18
|
| `missing-role-lines` | `SCOPE (role):`, `EXAMPLES (illustration only):`, or `ZOOM-IN BLOCKER:` is missing or blank. |
|
|
19
19
|
| `example-noun-in-scope` | The scope line contains an example noun and `ZOOM-IN BLOCKER:` is `none`. |
|
|
20
20
|
| `dropped-hard-constraint` | A bullet under `HARD CONSTRAINTS` in the task is absent as its own line in the rewrite. |
|
|
21
21
|
|
|
22
22
|
Json mode prints `{"decision": "refuse", "reason": "..."}`. Rewrite mode and the command-hook path print nothing, so the prompt-submit mod leaves the submitted text in place.
|
|
23
23
|
|
|
24
|
+
## The default rewrite
|
|
25
|
+
|
|
26
|
+
With no rewrite stub and no `CDE_DEEPEN_COMMAND`, `hooks/session/deepen_default_rewrite.py` builds the rewrite. It takes the scope from the task's own sentences, moves clauses the task marks as examples (`for example`, `for instance`, `e.g.`, `such as`) to the examples line, clips a scope over 240 characters with a pointer to the full task, copies the task with its hard constraints, and ends with one order to do the job. When the scope still names an example, the blocker line asks the worker to confirm before narrowing to it. The gate checks this rewrite like any other, so a first task deepens on every host with nothing configured.
|
|
27
|
+
|
|
24
28
|
## Proof shape
|
|
25
29
|
|
|
26
|
-
Run the hook on stdin with `CDE_DEEPEN_OUTPUT=json`.
|
|
30
|
+
Run the hook on stdin with `CDE_DEEPEN_OUTPUT=json`. A stub that pastes the four question stems refuses, and the default rewrite deepens. A role rewrite that leaves the example noun out deepens. Removing `refusal_reason` from `decide_first_prompt` lets the paste path deepen again, and `test_should_refuse_the_paste_path_as_the_known_bad` fails. The wrong twin puts the example noun in the scope line with `ZOOM-IN BLOCKER: none` and is refused.
|
|
27
31
|
|
|
28
32
|
`hooks/session/grade_deepstack_replay.py` scores a fixture whose task names one failure as an example. It calls `refusal_reason` and does not rewrite.
|
|
@@ -33,7 +33,7 @@ Preconditions:
|
|
|
33
33
|
- **Working style.** Input is a SessionStart event. Run `python -m pytest packages/claude-dev-env/hooks/session/test_working_style_prompt.py -q`. The adjacent test observes the fixed context text.
|
|
34
34
|
- **Advisor guidance.** Input is SessionStart with `advisorModel` set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_advisor_rules_prompt.py -q`. The adjacent test observes guidance only when the advisor setting is enabled.
|
|
35
35
|
- **Orchestrator start.** Input is SessionStart with its opt-in flag set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_orchestrator_auto_starter.py -q`. The adjacent test observes an orchestrator directive.
|
|
36
|
-
- **First-task deepen.** Input is a first UserPromptSubmit task. Run `python -m pytest packages/claude-dev-env/hooks/session/test_deepen_first_prompt.py -q`. The adjacent test observes a skip table, a refusal of the paste path, a rewrite that carries the SCOPE, EXAMPLES, and ZOOM-IN BLOCKER lines, and one injection when two copies see the same Cursor prompt. `python -m pytest packages/claude-dev-env/hooks/session/test_deepen_turn_marker.py -q` observes that only the first marker claim wins.
|
|
36
|
+
- **First-task deepen.** Input is a first UserPromptSubmit task. Run `python -m pytest packages/claude-dev-env/hooks/session/test_deepen_first_prompt.py -q`. The adjacent test observes a skip table, a refusal of the paste path, a rewrite that carries the SCOPE, EXAMPLES, and ZOOM-IN BLOCKER lines, and one injection when two copies see the same Cursor prompt. `python -m pytest packages/claude-dev-env/hooks/session/test_deepen_turn_marker.py -q` observes that only the first marker claim wins. With nothing configured, `deepen_default_rewrite.py` builds that role-shaped rewrite, and `test_deepen_default_rewrite.py` checks the gate accepts it.
|
|
37
37
|
- **Artifact template pointer.** Input is an `Artifact` quickstart call. Run `python -m pytest packages/claude-dev-env/hooks/session/test_artifact_template_pointer.py -q`. The adjacent test observes the status flowchart path first, the html-plan path ahead of the template path in `additionalContext`, and silence on a publish call or another skill.
|
|
38
38
|
- **Denial quick fix.** Input is a `PermissionDenied` event with a bracketed rule label and a classifier verdict. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_auto_mode_denial_quick_fix.py -q`. The adjacent test observes the named rule, the allow entry, and the PowerShell block; a denial with no verdict gets a note and no block.
|
|
39
39
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Named values for the role-shaped default rewrite of a first task."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ALL_EXAMPLE_SENTENCE_MARKERS",
|
|
7
|
+
"DEFAULT_BLOCKER_TEMPLATE",
|
|
8
|
+
"DEFAULT_CLOSING_LINE",
|
|
9
|
+
"DEFAULT_SCOPE_MAX_CHARACTERS",
|
|
10
|
+
"DEFAULT_TASK_HEADING",
|
|
11
|
+
"ALL_EXAMPLE_END_MARKERS",
|
|
12
|
+
"EXAMPLE_LEAD_PUNCTUATION",
|
|
13
|
+
"EXAMPLE_LIST_SEPARATOR",
|
|
14
|
+
"EXAMPLE_MARKER_ALTERNATION_SEPARATOR",
|
|
15
|
+
"EXAMPLE_MARKER_PATTERN_TEMPLATE",
|
|
16
|
+
"SCOPE_CLIPPED_SUFFIX",
|
|
17
|
+
"SENTENCE_BREAK_PATTERN",
|
|
18
|
+
"WORD_SEPARATOR",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
ALL_EXAMPLE_SENTENCE_MARKERS: tuple[str, ...] = ("for example", "for instance", "e.g.", "such as")
|
|
22
|
+
EXAMPLE_LEAD_PUNCTUATION: str = " ,:;-"
|
|
23
|
+
EXAMPLE_MARKER_ALTERNATION_SEPARATOR: str = "|"
|
|
24
|
+
EXAMPLE_MARKER_PATTERN_TEMPLATE: str = r"(?<!\w)(?:{alternatives})(?!\w)"
|
|
25
|
+
EXAMPLE_LIST_SEPARATOR: str = "; "
|
|
26
|
+
WORD_SEPARATOR: str = " "
|
|
27
|
+
ALL_EXAMPLE_END_MARKERS: tuple[str, ...] = (", and ", ", but ", ", then ", ", so ", ";", ")")
|
|
28
|
+
SENTENCE_BREAK_PATTERN: str = r"(?<=[.!?])\s+(?=[A-Z0-9\"'`(\[])"
|
|
29
|
+
DEFAULT_SCOPE_MAX_CHARACTERS: int = 240
|
|
30
|
+
SCOPE_CLIPPED_SUFFIX: str = "(the rest is in the task below)"
|
|
31
|
+
DEFAULT_TASK_HEADING: str = "Task as given:"
|
|
32
|
+
DEFAULT_BLOCKER_TEMPLATE: str = (
|
|
33
|
+
"the scope names something the task also gives as an example ({examples}); "
|
|
34
|
+
"confirm the job is only that case before narrowing to it"
|
|
35
|
+
)
|
|
36
|
+
DEFAULT_CLOSING_LINE: str = (
|
|
37
|
+
"Work the whole scope as a leading practitioner in this field would, "
|
|
38
|
+
"keep every hard constraint, and then do exactly that job."
|
|
39
|
+
)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Role-shaped default rewrite for a first task when no rewrite command is set.
|
|
2
|
+
|
|
3
|
+
``refusal_reason`` in ``deepen_role_gate.py`` refuses a rewrite that pastes the
|
|
4
|
+
four question stems back at the worker, and one without the SCOPE, EXAMPLES,
|
|
5
|
+
and ZOOM-IN BLOCKER lines. The default rewrite is built to carry those lines,
|
|
6
|
+
so a first task deepens on every host with nothing configured: the scope is
|
|
7
|
+
the task's own sentences with the marked examples taken out, the examples are
|
|
8
|
+
the parts the task marks as examples, and the blocker asks for confirmation
|
|
9
|
+
when the scope names one of those examples.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
from hooks_constants.deepen_default_rewrite_constants import (
|
|
17
|
+
ALL_EXAMPLE_END_MARKERS,
|
|
18
|
+
ALL_EXAMPLE_SENTENCE_MARKERS,
|
|
19
|
+
DEFAULT_BLOCKER_TEMPLATE,
|
|
20
|
+
DEFAULT_CLOSING_LINE,
|
|
21
|
+
DEFAULT_SCOPE_MAX_CHARACTERS,
|
|
22
|
+
DEFAULT_TASK_HEADING,
|
|
23
|
+
EXAMPLE_LEAD_PUNCTUATION,
|
|
24
|
+
EXAMPLE_LIST_SEPARATOR,
|
|
25
|
+
EXAMPLE_MARKER_ALTERNATION_SEPARATOR,
|
|
26
|
+
EXAMPLE_MARKER_PATTERN_TEMPLATE,
|
|
27
|
+
SCOPE_CLIPPED_SUFFIX,
|
|
28
|
+
SENTENCE_BREAK_PATTERN,
|
|
29
|
+
WORD_SEPARATOR,
|
|
30
|
+
)
|
|
31
|
+
from hooks_constants.deepen_first_prompt_constants import (
|
|
32
|
+
DEEPEN_MARKER,
|
|
33
|
+
HARD_CONSTRAINTS_LABEL,
|
|
34
|
+
REFUSAL_EXAMPLE_NOUN_IN_SCOPE,
|
|
35
|
+
ROLE_BLOCKER_LABEL,
|
|
36
|
+
ROLE_BLOCKER_NONE,
|
|
37
|
+
ROLE_EXAMPLES_LABEL,
|
|
38
|
+
ROLE_SCOPE_LABEL,
|
|
39
|
+
)
|
|
40
|
+
from deepen_role_gate import refusal_reason
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _body_before_constraints(task_text: str) -> str:
|
|
44
|
+
all_body_lines: list[str] = []
|
|
45
|
+
for each_line in task_text.splitlines():
|
|
46
|
+
if each_line.strip().upper().startswith(HARD_CONSTRAINTS_LABEL):
|
|
47
|
+
break
|
|
48
|
+
all_body_lines.append(each_line)
|
|
49
|
+
return WORD_SEPARATOR.join(WORD_SEPARATOR.join(all_body_lines).split())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _sentences(body_text: str) -> list[str]:
|
|
53
|
+
return [each.strip() for each in re.split(SENTENCE_BREAK_PATTERN, body_text) if each.strip()]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _example_marker_pattern() -> re.Pattern[str]:
|
|
57
|
+
alternatives = EXAMPLE_MARKER_ALTERNATION_SEPARATOR.join(re.escape(each_marker) for each_marker in ALL_EXAMPLE_SENTENCE_MARKERS)
|
|
58
|
+
return re.compile(EXAMPLE_MARKER_PATTERN_TEMPLATE.format(alternatives=alternatives), re.IGNORECASE)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _split_example(sentence: str) -> tuple[str, list[str]]:
|
|
62
|
+
"""Return the sentence without its marked examples, and the example texts.
|
|
63
|
+
|
|
64
|
+
A marker counts only as a whole phrase, so "for examples of" and "such
|
|
65
|
+
assumptions" stay in the scope. The earliest marker splits first, and the
|
|
66
|
+
text after the example is split again for later markers.
|
|
67
|
+
"""
|
|
68
|
+
marker_match = _example_marker_pattern().search(sentence)
|
|
69
|
+
if marker_match is None:
|
|
70
|
+
return sentence, []
|
|
71
|
+
head = sentence[: marker_match.start()].rstrip(EXAMPLE_LEAD_PUNCTUATION + "(")
|
|
72
|
+
tail = sentence[marker_match.end() :].lstrip(EXAMPLE_LEAD_PUNCTUATION)
|
|
73
|
+
end_index = min(
|
|
74
|
+
(tail.find(each_end) for each_end in ALL_EXAMPLE_END_MARKERS if tail.find(each_end) >= 0),
|
|
75
|
+
default=len(tail),
|
|
76
|
+
)
|
|
77
|
+
example = tail[:end_index].rstrip(".!? ")
|
|
78
|
+
remainder_scope, all_remainder_examples = _split_example(
|
|
79
|
+
tail[end_index:].lstrip(EXAMPLE_LEAD_PUNCTUATION + ")")
|
|
80
|
+
)
|
|
81
|
+
all_examples = ([example] if example else []) + all_remainder_examples
|
|
82
|
+
scope_part = f"{head} {remainder_scope}".strip() if head else ""
|
|
83
|
+
return scope_part, all_examples
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _clipped(text: str) -> str:
|
|
87
|
+
if len(text) <= DEFAULT_SCOPE_MAX_CHARACTERS:
|
|
88
|
+
return text
|
|
89
|
+
cut = text[:DEFAULT_SCOPE_MAX_CHARACTERS]
|
|
90
|
+
kept = cut[: cut.rfind(" ")] if " " in cut else cut
|
|
91
|
+
return f"{kept} {SCOPE_CLIPPED_SUFFIX}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _scope_and_examples(task_text: str) -> tuple[str, str]:
|
|
95
|
+
all_splits = [_split_example(each) for each in _sentences(_body_before_constraints(task_text))]
|
|
96
|
+
all_examples = [each_example for _, all_sentence_examples in all_splits for each_example in all_sentence_examples]
|
|
97
|
+
all_scope_parts = [each_part for each_part, _ in all_splits if each_part]
|
|
98
|
+
scope_text = WORD_SEPARATOR.join(all_scope_parts) or task_text.strip()
|
|
99
|
+
examples_text = EXAMPLE_LIST_SEPARATOR.join(all_examples) if all_examples else ROLE_BLOCKER_NONE
|
|
100
|
+
return _clipped(scope_text), examples_text
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _rewrite_with_blocker(scope: str, examples: str, blocker: str, task_text: str) -> str:
|
|
104
|
+
return (
|
|
105
|
+
f"{DEEPEN_MARKER}\n\n"
|
|
106
|
+
f"{ROLE_SCOPE_LABEL} {scope}\n"
|
|
107
|
+
f"{ROLE_EXAMPLES_LABEL} {examples}\n"
|
|
108
|
+
f"{ROLE_BLOCKER_LABEL} {blocker}\n\n"
|
|
109
|
+
f"{DEFAULT_TASK_HEADING}\n{task_text}\n\n"
|
|
110
|
+
f"{DEFAULT_CLOSING_LINE}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def role_default_rewrite(task_text: str) -> str:
|
|
115
|
+
"""Return the default rewrite: role lines from the task, the task itself, and one closing order.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
task_text: The first task with any leading /deepen removed.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
A rewrite that opens with the deepen marker and carries the three role lines.
|
|
122
|
+
"""
|
|
123
|
+
scope, examples = _scope_and_examples(task_text)
|
|
124
|
+
rewrite = _rewrite_with_blocker(scope, examples, ROLE_BLOCKER_NONE, task_text)
|
|
125
|
+
if refusal_reason(task_text, rewrite) != REFUSAL_EXAMPLE_NOUN_IN_SCOPE:
|
|
126
|
+
return rewrite
|
|
127
|
+
blocker = DEFAULT_BLOCKER_TEMPLATE.format(examples=examples)
|
|
128
|
+
return _rewrite_with_blocker(scope, examples, blocker, task_text)
|
|
@@ -31,8 +31,6 @@ from hooks_constants.cloud_session import is_cloud_session
|
|
|
31
31
|
from hooks_constants.deepen_first_prompt_constants import (
|
|
32
32
|
ADDITIONAL_CONTEXT_KEY,
|
|
33
33
|
ALL_FIRST_PROMPT_EVENT_NAMES,
|
|
34
|
-
ALL_FOUR_QUESTION_STEMS,
|
|
35
|
-
QUESTION_LINE_SEPARATOR,
|
|
36
34
|
ALL_HELPER_REPORT_MARKERS,
|
|
37
35
|
ALL_LOOP_PROMPT_PREFIXES,
|
|
38
36
|
ALL_NONINTERACTIVE_ENV_TRUE_VALUES,
|
|
@@ -68,6 +66,7 @@ from hooks_constants.deepen_first_prompt_constants import (
|
|
|
68
66
|
from hooks_constants.mod_handoff import is_mod_plugin_enabled
|
|
69
67
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
70
68
|
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
69
|
+
from deepen_default_rewrite import role_default_rewrite
|
|
71
70
|
from deepen_role_gate import refusal_reason
|
|
72
71
|
from deepen_turn_marker import claim_turn_marker, turn_marker_exists
|
|
73
72
|
|
|
@@ -256,19 +255,8 @@ def _command_rewrite(task_text: str) -> str | None:
|
|
|
256
255
|
|
|
257
256
|
|
|
258
257
|
def _default_rewrite(task_text: str) -> str:
|
|
259
|
-
"""Return the
|
|
260
|
-
|
|
261
|
-
f"{each_index}. {each_stem}"
|
|
262
|
-
for each_index, each_stem in enumerate(ALL_FOUR_QUESTION_STEMS, start=1)
|
|
263
|
-
)
|
|
264
|
-
return (
|
|
265
|
-
f"{DEEPEN_MARKER}\n\n"
|
|
266
|
-
"Do this task as a leading practitioner in its field would.\n\n"
|
|
267
|
-
f"{task_text}\n\n"
|
|
268
|
-
"Cover four things the original prompt left out:\n"
|
|
269
|
-
f"{numbered_stems}\n"
|
|
270
|
-
"Then do exactly that job."
|
|
271
|
-
)
|
|
258
|
+
"""Return the role-shaped default rewrite the gate accepts."""
|
|
259
|
+
return role_default_rewrite(task_text)
|
|
272
260
|
|
|
273
261
|
|
|
274
262
|
def expand_first_task(prompt: str) -> str:
|