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.
@@ -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,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
- console.log(eachHost.warning
450
- ? ` ${eachSpec.label} (${eachHost.host}): ${eachHost.status} — ${eachHost.warning}`
451
- : ` ${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);
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
- join(CLAUDE_HOME, SOL_PROMPT_DIRECTORY_NAME, SOL_PROMPT_FILE_NAME),
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
  });
@@ -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
 
@@ -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.
@@ -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 paste path: the four question stems and an order to do the job."""
260
- numbered_stems = QUESTION_LINE_SEPARATOR.join(
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: