claude-dev-env 8.88.0 → 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.
@@ -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 installForHost(allSpecs, host, homeDirectory, environment, runCommand) {
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,8 @@ import {
126
128
  resolveFaultPhaseFromEnvironment,
127
129
  runWithInstallTransaction,
128
130
  } from './install-transaction.mjs';
131
+ import { findShadowingGlobalInstall, shadowingGlobalAdvice } from './global-install-shadow.mjs';
132
+ import { pythonExecutableProbeCommand } from './python-probe.mjs';
129
133
 
130
134
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
131
135
  const INSTALL_ROOT_RESOLUTION = resolveInstallRoot({
@@ -442,16 +446,28 @@ export function installPstackPluginForHosts() {
442
446
  });
443
447
  }
444
448
 
449
+ /** Side steps that need the user, repeated at the end of the run so none scrolls past unseen. */
450
+ const allActionNeededLines = [];
451
+
445
452
  function printPluginHostOutcomes(allSpecs, pluginOutcome) {
446
453
  for (const eachSpec of allSpecs) {
447
454
  for (const eachHost of pluginOutcome.hosts) {
448
- console.log(eachHost.warning
449
- ? ` ${eachSpec.label} (${eachHost.host}): ${eachHost.status} — ${eachHost.warning}`
450
- : ` ${eachSpec.label} (${eachHost.host}): ${eachHost.status}`);
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);
451
460
  }
452
461
  }
453
462
  }
454
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
+
455
471
  function seedSubagentModelPolicy() {
456
472
  const policyDirectory = join(AGENTS_HOME, CURSOR_RULES_DIRECTORY_NAME);
457
473
  const policyPath = join(policyDirectory, SUBAGENT_MODEL_POLICY_FILE_NAME);
@@ -550,7 +566,9 @@ function quoteStandalonePluginRootHookPath(commandString, pluginRootForward) {
550
566
  * sys.executable and that path is baked in, so a later PATH change or Microsoft
551
567
  * Store update that re-points the `py`/`python` launcher cannot silently break
552
568
  * the hooks; candidates resolving to the non-spawnable WindowsApps stub are
553
- * skipped. Other platforms keep the bare command (e.g. `python3`).
569
+ * skipped. The path probe runs in isolated mode so the folder the installer
570
+ * runs from cannot slow or change it. Other platforms keep the bare command
571
+ * (e.g. `python3`).
554
572
  *
555
573
  * @returns {string|null} The interpreter command, or null when none is usable.
556
574
  */
@@ -561,7 +579,7 @@ export function detectPython() {
561
579
  const version = execSync(`${command} ${versionFlag}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: PYTHON_PROBE_TIMEOUT_MILLISECONDS }).trim();
562
580
  if (!version.includes('Python 3.')) continue;
563
581
  if (process.platform !== 'win32') return command;
564
- const executablePath = execSync(`${command} -c "import sys; print(sys.executable)"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: PYTHON_PROBE_TIMEOUT_MILLISECONDS }).trim();
582
+ const executablePath = execSync(pythonExecutableProbeCommand(command), { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: PYTHON_PROBE_TIMEOUT_MILLISECONDS }).trim();
565
583
  if (!executablePath || isWindowsStorePythonStub(executablePath)) continue;
566
584
  return interpreterCommandFromPath(executablePath);
567
585
  } catch { /* try next */ }
@@ -2688,6 +2706,7 @@ function executeInstallPlan(plan) {
2688
2706
  ...(plan.priorManifest.files || []),
2689
2707
  join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, 'AGENTS.md'),
2690
2708
  ...codexSolSettingSnapshotPaths(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
2709
+ codexSolPromptPath(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
2691
2710
  ],
2692
2711
  journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
2693
2712
  });
@@ -2734,6 +2753,16 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2734
2753
  const groupLabel = selectedGroups ? `groups: ${selectedGroups.join(', ')}` : 'all';
2735
2754
  console.log(`\nInstalling ${PACKAGE_NAME} (${groupLabel})...\n`);
2736
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
+ }
2737
2766
  mkdirSync(CLAUDE_HOME, { recursive: true });
2738
2767
  const publishedPointerPaths = publishManagedLookupPointers();
2739
2768
 
@@ -2993,6 +3022,9 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2993
3022
  console.log(cursorOutcome.warning
2994
3023
  ? ` DeepStack (cursor): ${cursorOutcome.status} — ${cursorOutcome.warning}`
2995
3024
  : ` DeepStack (cursor): ${cursorOutcome.status}`);
3025
+ if (cursorOutcome.status === 'failed') {
3026
+ allActionNeededLines.push(`DeepStack (cursor): failed — ${cursorOutcome.warning}`);
3027
+ }
2996
3028
  }
2997
3029
  const allRepositoryPluginSpecs = [
2998
3030
  USAGE_WRAPUP_PLUGIN_SPEC,
@@ -3025,9 +3057,14 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
3025
3057
  INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
3026
3058
  );
3027
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);
3028
3065
  const solSettingPath = writeCodexSolSetting(
3029
3066
  INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
3030
- join(CLAUDE_HOME, SOL_PROMPT_DIRECTORY_NAME, SOL_PROMPT_FILE_NAME),
3067
+ codexSolPromptPath(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
3031
3068
  );
3032
3069
  if (solSettingPath) {
3033
3070
  allCodexGuidancePaths.push(solSettingPath);
@@ -3245,6 +3282,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
3245
3282
  console.log(` hooks: ${summary.hookFiles.created + summary.hookFiles.updated} files, ${summary.hookGroups} groups in settings.json`);
3246
3283
  }
3247
3284
  console.log(` python: ${pythonCommand}\n`);
3285
+ printActionNeeded();
3248
3286
  }
3249
3287
 
3250
3288
  function normalizePathForComparison(rawPath) {
@@ -3491,6 +3529,7 @@ function uninstall() {
3491
3529
  ...plan.removableFiles,
3492
3530
  join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, 'AGENTS.md'),
3493
3531
  ...codexSolSettingSnapshotPaths(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
3532
+ codexSolPromptPath(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
3494
3533
  ],
3495
3534
  journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
3496
3535
  });
@@ -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
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Command line for the probe that asks a Python candidate for its absolute
3
+ * interpreter path.
4
+ *
5
+ * `python -c` puts the current directory first on `sys.path`, and Python
6
+ * lists that whole directory the first time it imports a module from disk.
7
+ * When the installer runs from a large folder, such as a temp folder with
8
+ * hundreds of thousands of entries, that listing can outlast the probe
9
+ * timeout, so every candidate fails and the install stops with "No usable
10
+ * Python 3 found". A module in that folder with a standard library name is
11
+ * imported in place of the real one. `-I` (isolated mode, Python 3.4+) keeps
12
+ * the current directory off `sys.path`, so the probe gives the same answer
13
+ * from any folder.
14
+ *
15
+ * @param {string} candidateCommand A Python launcher command, such as `py -3`.
16
+ * @returns {string} The shell command that prints the interpreter path.
17
+ */
18
+ export function pythonExecutableProbeCommand(candidateCommand) {
19
+ return `${candidateCommand} -I -c "import sys; print(sys.executable)"`;
20
+ }
@@ -0,0 +1,37 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { execSync } from 'node:child_process';
4
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { detectPython } from './install.mjs';
8
+ import { pythonExecutableProbeCommand } from './python-probe.mjs';
9
+
10
+ const SHADOWED_STANDARD_LIBRARY_MODULES = ['linecache.py', 'encodings.py', 'site.py', 'os.py'];
11
+
12
+ test('the interpreter path probe ignores modules in the current directory', () => {
13
+ const candidateCommand = detectPython();
14
+ assert.ok(candidateCommand, 'a Python 3 interpreter is needed for this test');
15
+ const shadowDirectory = mkdtempSync(join(tmpdir(), 'cde-python-probe-'));
16
+ try {
17
+ for (const moduleFileName of SHADOWED_STANDARD_LIBRARY_MODULES) {
18
+ writeFileSync(join(shadowDirectory, moduleFileName), 'raise SystemExit("shadow module imported")\n');
19
+ }
20
+ const printedPath = execSync(pythonExecutableProbeCommand(candidateCommand), {
21
+ cwd: shadowDirectory,
22
+ encoding: 'utf8',
23
+ stdio: ['pipe', 'pipe', 'pipe'],
24
+ }).trim();
25
+ assert.ok(printedPath.length > 0);
26
+ assert.doesNotMatch(printedPath, /shadow module imported/);
27
+ } finally {
28
+ rmSync(shadowDirectory, { recursive: true, force: true });
29
+ }
30
+ });
31
+
32
+ test('the interpreter path probe runs Python in isolated mode', () => {
33
+ assert.equal(
34
+ pythonExecutableProbeCommand('py -3'),
35
+ 'py -3 -I -c "import sys; print(sys.executable)"',
36
+ );
37
+ });
@@ -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
 
@@ -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 `~/.claude/system-prompts/codex-sol.md`. The file
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 is this case. |
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`. The paste path refuses. 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.
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.
@@ -18,6 +18,7 @@ This family injects reminders and task guidance when an agent starts, resumes, s
18
18
  - `session/skill_loaded_reminder.py` runs on `PreToolUse` matcher `Agent|Task` at `10` seconds, `UserPromptSubmit` matcher empty at `10` seconds, `SessionStart` matcher `compact` at `10` seconds, and `SubagentStart` matcher `workflow-subagent` at `10` seconds in `hooks.json`.
19
19
  - `session/task_tool_prompt.py`, `session/working_style_prompt.py`, `session/advisor_rules_prompt.py`, and `session/orchestrator_auto_starter.py` run on `SessionStart`, matcher empty, timeout `10` seconds each in `hooks.json`.
20
20
  - `session/deepen_first_prompt.py` runs on `UserPromptSubmit`, matcher empty, timeout `10` seconds in `hooks.json`.
21
+ - In Cursor, `session/deepen_first_prompt.py` also runs on `beforeSubmitPrompt`. Cursor passes that event name to the `UserPromptSubmit` copy it imports from the Claude Code settings too. When both copies run, `session/deepen_turn_marker.py` creates the turn marker only when it does not exist yet, so one copy injects. A later prompt skips when either the transcript marker or the session marker exists, or when the transcript already has a user line in Claude Code's or Cursor's shape.
21
22
  - `session/artifact_template_pointer.py` runs on `PreToolUse` matcher `Artifact|Skill` at `10` seconds in `hooks.json`. It speaks only on an `Artifact` call with action `quickstart` or a `Skill` call that loads `artifact-design`.
22
23
  - `advisory/auto_mode_denial_quick_fix.py` runs on `PermissionDenied`, matcher `*`, timeout `10` seconds in `hooks.json`.
23
24
 
@@ -32,7 +33,7 @@ Preconditions:
32
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.
33
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.
34
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.
35
- - **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, and a rewrite that carries the SCOPE, EXAMPLES, and ZOOM-IN BLOCKER lines.
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.
36
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.
37
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.
38
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
+ )
@@ -45,6 +45,9 @@ __all__ = [
45
45
  "DEEPEN_SLASH_COMMAND",
46
46
  "DEEPEN_TIMEOUT_SECONDS",
47
47
  "DEEPEN_TURN_MARKER_SUFFIX",
48
+ "DEEPEN_SESSION_MARKER_PREFIX",
49
+ "ALL_FIRST_PROMPT_EVENT_NAMES",
50
+ "CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME",
48
51
  "HOOK_EVENT_NAME_KEY",
49
52
  "HOOK_SPECIFIC_OUTPUT_KEY",
50
53
  "JSONL_ROLE_KEY",
@@ -66,10 +69,15 @@ DEEPEN_MARKER: str = "CDE_DEEPEN:v1"
66
69
  DEEPEN_AGENT_MARKER: str = "CDE_DEEPEN_AGENT"
67
70
  DEEPEN_SLASH_COMMAND: str = "/deepen"
68
71
  DEEPEN_TURN_MARKER_SUFFIX: str = ".cde-deepen-turn1"
72
+ DEEPEN_SESSION_MARKER_PREFIX: str = "cde-deepen-"
69
73
  DEEPEN_TIMEOUT_SECONDS: int = 10
70
74
  DEEPEN_COMMAND_TIMEOUT_MARGIN_SECONDS: int = 2
71
75
  DEEPEN_COMMAND_TIMEOUT_SECONDS: int = DEEPEN_TIMEOUT_SECONDS - DEEPEN_COMMAND_TIMEOUT_MARGIN_SECONDS
72
76
  USER_PROMPT_SUBMIT_EVENT_NAME: str = "UserPromptSubmit"
77
+ CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME: str = "beforeSubmitPrompt"
78
+ ALL_FIRST_PROMPT_EVENT_NAMES: frozenset[str] = frozenset(
79
+ {USER_PROMPT_SUBMIT_EVENT_NAME, CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME}
80
+ )
73
81
  PROMPT_FIELD_NAME: str = "prompt"
74
82
  TRANSCRIPT_PATH_FIELD_NAME: str = "transcript_path"
75
83
  SESSION_ID_FIELD_NAME: str = "session_id"
@@ -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)