claude-dev-env 8.89.0 → 8.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.agents/skills/pr-visual/README.md +29 -0
  2. package/.agents/skills/pr-visual/SKILL.md +62 -0
  3. package/.agents/skills/pr-visual/assets/icons.symbols +27 -0
  4. package/.agents/skills/pr-visual/assets/viewer.html +147 -0
  5. package/.agents/skills/pr-visual/fixtures/bar.scene.json +193 -0
  6. package/.agents/skills/pr-visual/fixtures/flow.scene.json +201 -0
  7. package/.agents/skills/pr-visual/fixtures/head/metrics.json +16 -0
  8. package/.agents/skills/pr-visual/fixtures/integrated.scene.json +239 -0
  9. package/.agents/skills/pr-visual/fixtures/line.scene.json +193 -0
  10. package/.agents/skills/pr-visual/fixtures/scatter.scene.json +193 -0
  11. package/.agents/skills/pr-visual/fixtures/snapshot.json +21 -0
  12. package/.agents/skills/pr-visual/references/evidence.md +9 -0
  13. package/.agents/skills/pr-visual/references/formats.md +30 -0
  14. package/.agents/skills/pr-visual/references/scene-contract.md +48 -0
  15. package/.agents/skills/pr-visual/references/visual-contract.md +23 -0
  16. package/.agents/skills/pr-visual/scripts/collect.py +111 -0
  17. package/.agents/skills/pr-visual/scripts/metrics.py +99 -0
  18. package/.agents/skills/pr-visual/scripts/render.py +133 -0
  19. package/.agents/skills/pr-visual/scripts/test_collect.py +58 -0
  20. package/.agents/skills/pr-visual/scripts/test_metrics.py +47 -0
  21. package/.agents/skills/pr-visual/scripts/test_render.py +77 -0
  22. package/.agents/skills/pr-visual/scripts/verify.py +218 -0
  23. package/.agents/skills/pr-visual/scripts/visual_config/config/constants.py +104 -0
  24. package/bin/codex-skill-load-block.mjs +0 -25
  25. package/bin/codex-skill-load-block.test.mjs +5 -17
  26. package/bin/codex-trimmed-sol.test.mjs +0 -1
  27. package/bin/cursor-native-hooks.test.mjs +3 -3
  28. package/bin/ever-shipped-skills.mjs +1 -0
  29. package/bin/install-deepstack-plugin.mjs +42 -25
  30. package/bin/install-deepstack-plugin.test.mjs +37 -11
  31. package/bin/install-pstack-plugin.mjs +58 -11
  32. package/bin/install-pstack-plugin.test.mjs +54 -21
  33. package/bin/install.agents-home.test.mjs +0 -1
  34. package/bin/install.codex-rules.test.mjs +0 -2
  35. package/bin/install.cursor-rules.test.mjs +90 -26
  36. package/bin/install.mjs +30 -36
  37. package/bin/install.pr-visual.test.mjs +40 -0
  38. package/bin/install.prompt-context.test.mjs +3 -4
  39. package/bin/install.prune.test.mjs +0 -1
  40. package/bin/install.question-presentation.test.mjs +0 -4
  41. package/bin/install.settings-defaults.test.mjs +0 -1
  42. package/bin/install.shared-settings.test.mjs +0 -1
  43. package/bin/install.test.mjs +14 -44
  44. package/bin/install.transaction.test.mjs +0 -8
  45. package/bin/install.uninstall-transaction.test.mjs +0 -2
  46. package/bin/install.update-keeps-hooks.test.mjs +0 -1
  47. package/docs/BDD_SCENARIO_QUALITY.md +1 -1
  48. package/docs/BDD_TEST_LAYOUT.md +1 -1
  49. package/docs/CODE_RULES.md +1 -1
  50. package/docs/rule-guides/deepstack-wording.md +3 -5
  51. package/docs/rule-guides/session-title.md +5 -4
  52. package/hooks/blocking/session_title_turn_gate.py +287 -0
  53. package/hooks/blocking/test_session_title_format_gate.py +5 -0
  54. package/hooks/blocking/test_session_title_turn_gate.py +264 -0
  55. package/hooks/features/conduct-gates.md +3 -0
  56. package/hooks/features/session-context.md +0 -4
  57. package/hooks/hooks.json +5 -5
  58. package/hooks/hooks_constants/mod_handoff_constants.py +0 -2
  59. package/hooks/hooks_constants/session_title_constants.py +41 -3
  60. package/hooks/lifecycle/nested_project_hooks.py +2 -2
  61. package/hooks/lifecycle/test_nested_project_hooks.py +2 -2
  62. package/hooks/session/compaction_user_messages.py +2 -2
  63. package/package.json +2 -2
  64. package/rules/deepstack-wording.md +3 -3
  65. package/scripts/ci/install-playtest.mjs +58 -314
  66. package/scripts/ci/install-playtest.test.mjs +24 -6
  67. package/scripts/policy_lint/adapter_configuration.py +1 -0
  68. package/scripts/policy_lint/config/constants.py +1 -0
  69. package/scripts/test_deepstack_review_criterion.py +2 -3
  70. package/scripts/test_deepstack_wording_rule.py +6 -4
  71. package/scripts/tests/test_adapter_configuration.py +8 -0
  72. package/.agents/skills/deepen/SKILL.md +0 -26
  73. package/commands/deepen.md +0 -14
  74. package/docs/decisions/deepstack-gate.md +0 -32
  75. package/hooks/hooks_constants/deepen_first_prompt_constants.py +0 -187
  76. package/hooks/session/deepen_default_rewrite.py +0 -128
  77. package/hooks/session/deepen_first_prompt.py +0 -383
  78. package/hooks/session/deepen_role_gate.py +0 -171
  79. package/hooks/session/deepen_turn_marker.py +0 -75
  80. package/hooks/session/fixtures/deepstack_replay.json +0 -15
  81. package/hooks/session/grade_deepstack_replay.py +0 -78
  82. package/hooks/session/test_deepen_default_rewrite.py +0 -90
  83. package/hooks/session/test_deepen_first_prompt.py +0 -452
  84. package/hooks/session/test_deepen_role_gate.py +0 -83
  85. package/hooks/session/test_deepen_turn_marker.py +0 -59
  86. package/hooks/session/test_grade_deepstack_replay.py +0 -47
@@ -9,14 +9,6 @@ export const QUESTION_PRESENTATION_BLOCK_START = '<!-- claude-dev-env question p
9
9
  export const QUESTION_PRESENTATION_BLOCK_END = '<!-- claude-dev-env question presentation: end -->';
10
10
  export const DEEPEN_GUIDANCE_BLOCK_START = '<!-- claude-dev-env deepen: start -->';
11
11
  export const DEEPEN_GUIDANCE_BLOCK_END = '<!-- claude-dev-env deepen: end -->';
12
- export const DEEPEN_GUIDANCE_TEXT = [
13
- 'Deepen the first interactive user task before any work starts.',
14
- 'Follow DeepStack intake. Write SCOPE (role), EXAMPLES (illustration only), and ZOOM-IN BLOCKER before any plan.',
15
- 'The worker prompt carries the marker CDE_DEEPEN:v1 on line 1 and the SCOPE (role) line.',
16
- 'On demand, follow the DeepStack skill or /deepen with a task.',
17
- 'Cursor Cloud registers the DeepStack SessionStart hook and the first-task gate. Use DeepStack intake on the launch prompt.',
18
- 'Codex cloud orchestration skips command hooks. This block is the local Codex instruction.',
19
- ].join('\n');
20
12
 
21
13
  const WINDOWS_LONG_PATH_PREFIX = '\\\\?\\';
22
14
 
@@ -124,23 +116,6 @@ export function writeCodexPackageGuidance(codexHome, packageGuidanceText, allPac
124
116
  );
125
117
  }
126
118
 
127
- export function writeCodexDeepenGuidance(codexHome, deepenText = DEEPEN_GUIDANCE_TEXT) {
128
- const agentsPath = join(codexHome, 'AGENTS.md');
129
- const agentsEntry = lstatSync(agentsPath, { throwIfNoEntry: false });
130
- if (agentsEntry && !agentsEntry.isFile()) return null;
131
- const currentText = agentsEntry ? readFileSync(agentsPath, 'utf8') : '';
132
- const block = managedBlock(DEEPEN_GUIDANCE_BLOCK_START, deepenText, DEEPEN_GUIDANCE_BLOCK_END);
133
- const replacedText = withBlockReplaced(
134
- currentText, DEEPEN_GUIDANCE_BLOCK_START, DEEPEN_GUIDANCE_BLOCK_END, block,
135
- );
136
- const separator = currentText && !currentText.endsWith('\n') ? '\n' : '';
137
- const updatedText = replacedText ?? `${currentText}${separator}${block}`;
138
- if (updatedText === currentText) return null;
139
- mkdirSync(codexHome, { recursive: true });
140
- writeFileSync(agentsPath, updatedText, 'utf8');
141
- return agentsPath;
142
- }
143
-
144
119
  export function writeCodexQuestionGuidance(codexHome, policyText) {
145
120
  const agentsPath = join(codexHome, 'AGENTS.md');
146
121
  const agentsEntry = lstatSync(agentsPath, { throwIfNoEntry: false });
@@ -16,9 +16,7 @@ import {
16
16
  withoutSkillLoadBlock,
17
17
  DEEPEN_GUIDANCE_BLOCK_END,
18
18
  DEEPEN_GUIDANCE_BLOCK_START,
19
- DEEPEN_GUIDANCE_TEXT,
20
19
  writeCodexAgentsGuidance,
21
- writeCodexDeepenGuidance,
22
20
  writeCodexPackageGuidance,
23
21
  writeCodexQuestionGuidance,
24
22
  } from './codex-skill-load-block.mjs';
@@ -28,6 +26,7 @@ const STALE_SKILL_LOAD_SENTENCE = 'Load the pstack:poteto-mode skill before your
28
26
  + 'including a helper started with spawn_agent, whatever its task message says.';
29
27
  const STALE_SKILL_LOAD_BLOCK = `${SKILL_LOAD_BLOCK_START}\n${STALE_SKILL_LOAD_SENTENCE}\n${SKILL_LOAD_BLOCK_END}\n`;
30
28
  const PACKAGE_GUIDANCE = 'Status\n\nChanged / proof / blocked.\n';
29
+ const RETIRED_DEEPEN_BLOCK = `${DEEPEN_GUIDANCE_BLOCK_START}\nDeepen the first interactive user task.\n${DEEPEN_GUIDANCE_BLOCK_END}\n`;
31
30
  const QUESTION_POLICY = '# Present questions clearly\n\nAsk one question.\n';
32
31
 
33
32
  function guidanceBlock(guidanceText) {
@@ -109,7 +108,7 @@ test('a stale skill-load sentence is removed and question and deepen blocks stay
109
108
  const homes = makeHomes(context);
110
109
  mkdirSync(homes.codexHome);
111
110
  const questionBlock = `${QUESTION_PRESENTATION_BLOCK_START}\n${QUESTION_POLICY}${QUESTION_PRESENTATION_BLOCK_END}\n`;
112
- const deepenBlock = `${DEEPEN_GUIDANCE_BLOCK_START}\n${DEEPEN_GUIDANCE_TEXT}\n${DEEPEN_GUIDANCE_BLOCK_END}\n`;
111
+ const deepenBlock = RETIRED_DEEPEN_BLOCK;
113
112
  writeFileSync(
114
113
  homes.agentsPath,
115
114
  `${STALE_SKILL_LOAD_BLOCK}\n${guidanceBlock('Old guidance\n')}${questionBlock}${deepenBlock}`,
@@ -397,24 +396,13 @@ test('question removal leaves a missing guidance file absent', (context) => {
397
396
  assert.equal(lstatSync(homes.agentsPath, { throwIfNoEntry: false }), undefined);
398
397
  });
399
398
 
400
- test('should write the deepen block into Codex AGENTS.md', (context) => {
401
- const homes = makeHomes(context);
402
-
403
- assert.equal(writeCodexDeepenGuidance(homes.codexHome), homes.agentsPath);
404
- const guidanceText = readFileSync(homes.agentsPath, 'utf8');
405
- assert.ok(guidanceText.includes(DEEPEN_GUIDANCE_BLOCK_START));
406
- assert.ok(guidanceText.includes(DEEPEN_GUIDANCE_BLOCK_END));
407
- assert.ok(guidanceText.includes(DEEPEN_GUIDANCE_TEXT.split('\n')[0]));
408
- assert.equal(writeCodexDeepenGuidance(homes.codexHome), null);
409
- });
410
-
411
- test('should restore the custom guidance bytes when the deepen block is removed', (context) => {
399
+ test('should restore the custom guidance bytes when a retired deepen block is removed', (context) => {
412
400
  const homes = makeHomes(context);
413
401
  mkdirSync(homes.codexHome);
414
402
  const customGuidance = '# My notes\n\nKeep this line.\n';
415
- writeFileSync(homes.agentsPath, customGuidance);
403
+ writeFileSync(homes.agentsPath, `${customGuidance}${RETIRED_DEEPEN_BLOCK}`);
416
404
 
417
- assert.equal(writeCodexDeepenGuidance(homes.codexHome), homes.agentsPath);
418
405
  assert.equal(removeCodexDeepenGuidance(homes.codexHome), homes.agentsPath);
419
406
  assert.equal(readFileSync(homes.agentsPath, 'utf8'), customGuidance);
407
+ assert.equal(removeCodexDeepenGuidance(homes.codexHome), null);
420
408
  });
@@ -35,7 +35,6 @@ function installInScratchHome(homeDirectory, argumentsList = [], environment = {
35
35
  CDE_INSTALL_DEEPSTACK: '0',
36
36
  CDE_INSTALL_USAGE_WRAPUP: '0',
37
37
  CDE_INSTALL_SUBAGENT_MODELS: '0',
38
- CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
39
38
  CLAUDE_DEV_ENV_INSTALL_FAULT: undefined,
40
39
  HOME: homeDirectory,
41
40
  USERPROFILE: homeDirectory,
@@ -17,10 +17,10 @@ const LEGACY_SESSION_GROUP = {
17
17
  };
18
18
  const LEGACY_PROMPT_GROUP = {
19
19
  matcher: '',
20
- hooks: [{ type: 'command', command: 'python3 /home/dev/.agents/hooks/session/deepen_first_prompt.py', timeout: 10 }],
20
+ hooks: [{ type: 'command', command: 'python3 /home/dev/.agents/hooks/session/first_prompt_gate.py', timeout: 10 }],
21
21
  };
22
22
  const USER_TOOL_GROUP = { matcher: 'Shell', hooks: [{ type: 'command', command: './audit.sh' }] };
23
- const isOwned = command => command.includes('plugins/deepstack/hooks/session-start.') || command.includes('deepen_first_prompt.py');
23
+ const isOwned = command => command.includes('plugins/deepstack/hooks/session-start.') || command.includes('first_prompt_gate.py');
24
24
 
25
25
  test('Claude-style groups become flat native entries under the Cursor event names', () => {
26
26
  const native = nativeCursorHooksConfiguration({
@@ -54,7 +54,7 @@ test('owned entries collapse to one copy and removal drops only owned entries',
54
54
  };
55
55
  const ownedEntries = {
56
56
  sessionStart: [{ command: "'/home/dev/.cursor/plugins/deepstack/hooks/session-start.sh' cursor", timeout: 10 }],
57
- beforeSubmitPrompt: [{ command: 'python3 /home/dev/.agents/hooks/session/deepen_first_prompt.py', timeout: 10 }],
57
+ beforeSubmitPrompt: [{ command: 'python3 /home/dev/.agents/hooks/session/first_prompt_gate.py', timeout: 10 }],
58
58
  };
59
59
  const merged = withOwnedCursorHooks(legacyFile, isOwned, ownedEntries);
60
60
  assert.deepEqual(merged.hooks, { ...ownedEntries, stop: [{ command: './user-stop.sh' }] });
@@ -79,6 +79,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
79
79
  'pull-request',
80
80
  'pre-compact',
81
81
  'privacy-hygiene',
82
+ 'pr-visual',
82
83
  'prototype',
83
84
  'prompt-generator',
84
85
  'qbug',
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import {
@@ -8,8 +8,8 @@ import {
8
8
  shouldInstallMarketplacePlugin,
9
9
  } from './install-pstack-plugin.mjs';
10
10
  import {
11
- CURSOR_BEFORE_SUBMIT_PROMPT_EVENT,
12
11
  CURSOR_HOOK_TIMEOUT_SECONDS,
12
+ CURSOR_HOOKS_FILE_NAME,
13
13
  CURSOR_SESSION_START_EVENT,
14
14
  } from './install-constants.mjs';
15
15
  import { updateCursorHooksFile, withOwnedCursorHooks, withoutOwnedCursorHooks } from './cursor-native-hooks.mjs';
@@ -32,8 +32,8 @@ export const DEEPSTACK_SESSION_POWERSHELL_RELATIVE_PATH = 'hooks/session-start.p
32
32
  export const DEEPSTACK_CURSOR_SESSION_COMMAND_MARKER = 'plugins/deepstack/hooks/session-start.';
33
33
  export const DEEPSTACK_SESSION_RUNTIME = 'cursor';
34
34
  const WINDOWS_POWERSHELL_FILE_PREFIX = 'powershell -NoProfile -ExecutionPolicy Bypass -File';
35
- const DEFAULT_PYTHON_COMMAND = 'python3';
36
- export const DEEPSTACK_FIRST_TASK_HOOK_NAME = 'deepen_first_prompt.py';
35
+ /** Script name of the retired first-task hook that earlier installs registered in Cursor. */
36
+ export const RETIRED_CURSOR_FIRST_TASK_HOOK_NAME = 'deepen_first_prompt.py';
37
37
  export const DEEPSTACK_CODEX_MODEL_SHEET_NAME = 'deepstack-models.md';
38
38
 
39
39
  export const DEEPSTACK_PLUGIN_SPEC = Object.freeze({
@@ -62,14 +62,13 @@ function quoteCommandPath(pathValue) {
62
62
  * Read the Claude, Codex, and Cursor actions one DeepStack install performs.
63
63
  *
64
64
  * Claude and Codex use the marketplace verbs. Cursor has no plugin command, so
65
- * its action is a copy into the Cursor home plus two native hook registrations.
65
+ * its action is a copy into the Cursor home plus one native session hook.
66
66
  *
67
67
  * @returns {{claude: object, codex: object, cursor: {
68
68
  * pluginDirectoryName: string,
69
69
  * skillRelativePath: string,
70
70
  * sessionScriptRelativePath: string,
71
71
  * sessionRuntimeArgument: string,
72
- * firstTaskHookName: string,
73
72
  * }}} The three host actions.
74
73
  */
75
74
  export function deepStackInstallPlan() {
@@ -81,7 +80,6 @@ export function deepStackInstallPlan() {
81
80
  skillRelativePath: DEEPSTACK_SKILL_RELATIVE_PATH,
82
81
  sessionScriptRelativePath: DEEPSTACK_SESSION_SCRIPT_RELATIVE_PATH,
83
82
  sessionRuntimeArgument: DEEPSTACK_SESSION_RUNTIME,
84
- firstTaskHookName: DEEPSTACK_FIRST_TASK_HOOK_NAME,
85
83
  },
86
84
  };
87
85
  }
@@ -142,33 +140,36 @@ export function resolveDeepStackPluginSource(options = {}) {
142
140
  /**
143
141
  * True for a Cursor hook command the DeepStack install owns.
144
142
  *
143
+ * The retired first-task hook counts as owned, so the merge and the removal
144
+ * both strip the entry an earlier install registered.
145
+ *
145
146
  * @param {string} forwardSlashedCommand A hook command with forward slashes.
146
- * @returns {boolean} True for the DeepStack session hook or the first-task hook.
147
+ * @returns {boolean} True for the DeepStack session hook or the retired first-task hook.
147
148
  */
148
149
  export function isDeepStackCursorCommand(forwardSlashedCommand) {
149
150
  return forwardSlashedCommand.includes(DEEPSTACK_CURSOR_SESSION_COMMAND_MARKER)
150
- || forwardSlashedCommand.includes(DEEPSTACK_FIRST_TASK_HOOK_NAME);
151
+ || forwardSlashedCommand.includes(RETIRED_CURSOR_FIRST_TASK_HOOK_NAME);
151
152
  }
152
153
 
153
154
  /**
154
- * Return the native Cursor configuration with one DeepStack session entry and one first-task entry.
155
+ * Return the native Cursor configuration with one DeepStack session entry.
155
156
  *
156
- * Earlier installs wrote these as Claude-style groups, which Cursor never ran;
157
- * every owned copy, old or new, is replaced by one native entry each.
157
+ * Earlier installs wrote the session entry as a Claude-style group, which
158
+ * Cursor never ran, and also registered the retired first-task hook. Every
159
+ * owned entry, old or new, is replaced by one native session entry.
158
160
  *
159
161
  * @param {object} configuration The parsed Cursor hooks file.
160
- * @param {{sessionCommand: string, firstTaskCommand: string}} commands The two commands.
161
- * @returns {object} The configuration with both native entries present once.
162
+ * @param {{sessionCommand: string}} commands The session command.
163
+ * @returns {object} The configuration with the session entry present once.
162
164
  */
163
165
  export function mergeDeepStackCursorHooks(configuration, commands) {
164
166
  return withOwnedCursorHooks(configuration, isDeepStackCursorCommand, {
165
167
  [CURSOR_SESSION_START_EVENT]: [{ command: commands.sessionCommand, timeout: CURSOR_HOOK_TIMEOUT_SECONDS }],
166
- [CURSOR_BEFORE_SUBMIT_PROMPT_EVENT]: [{ command: commands.firstTaskCommand, timeout: CURSOR_HOOK_TIMEOUT_SECONDS }],
167
168
  });
168
169
  }
169
170
 
170
171
  /**
171
- * Remove the DeepStack session and first-task entries from the Cursor hooks file.
172
+ * Remove the DeepStack session entry and the retired first-task entry from the Cursor hooks file.
172
173
  *
173
174
  * @param {string} cursorDirectory The Cursor home.
174
175
  * @returns {string|null} The hooks.json path when this call changed it.
@@ -180,6 +181,30 @@ export function removeDeepStackCursorHooks(cursorDirectory) {
180
181
  );
181
182
  }
182
183
 
184
+ /**
185
+ * Remove the retired first-task entry from the Cursor hooks file.
186
+ *
187
+ * A full install runs this whether or not it installs DeepStack, so an install
188
+ * that opts out of DeepStack still drops the entry an earlier install wrote.
189
+ * A hooks file that never names the retired script is left byte for byte.
190
+ *
191
+ * @param {string} cursorDirectory The Cursor home.
192
+ * @returns {string|null} The hooks.json path when this call changed it.
193
+ */
194
+ export function removeRetiredCursorFirstTaskHook(cursorDirectory) {
195
+ if (!cursorDirectory) return null;
196
+ const hooksPath = join(cursorDirectory, CURSOR_HOOKS_FILE_NAME);
197
+ if (!existsSync(hooksPath)) return null;
198
+ if (!readFileSync(hooksPath, 'utf8').includes(RETIRED_CURSOR_FIRST_TASK_HOOK_NAME)) return null;
199
+ return updateCursorHooksFile(
200
+ cursorDirectory,
201
+ configuration => withoutOwnedCursorHooks(
202
+ configuration,
203
+ forwardSlashedCommand => forwardSlashedCommand.includes(RETIRED_CURSOR_FIRST_TASK_HOOK_NAME),
204
+ ),
205
+ );
206
+ }
207
+
183
208
  /**
184
209
  * Build the Cursor command for the DeepStack session script.
185
210
  *
@@ -201,13 +226,11 @@ export function deepStackCursorSessionCommand(pluginDirectory, formatPathArgumen
201
226
  }
202
227
 
203
228
  /**
204
- * Copy the plugin into the Cursor home and register both hooks in Cursor's native format.
229
+ * Copy the plugin into the Cursor home and register its session hook in Cursor's native format.
205
230
  *
206
231
  * @param {object} options Copy inputs.
207
232
  * @param {string} options.cursorDirectory The Cursor home, usually ~/.cursor.
208
233
  * @param {string|null} options.pluginSourceDirectory The plugin tree to copy.
209
- * @param {string} options.firstTaskHookPath Absolute path of deepen_first_prompt.py.
210
- * @param {string} [options.pythonCommand] The interpreter the installer detected.
211
234
  * @param {(pathValue: string) => string} [options.formatPathArgument] The platform path quoting.
212
235
  * @param {string} [options.platform] The Node platform name.
213
236
  * @returns {{status: string, pluginDirectory: string|null, hooksPath: string|null, warning: string|null}} The outcome.
@@ -229,10 +252,8 @@ export function installDeepStackCursor(options) {
229
252
  mkdirSync(options.cursorDirectory, { recursive: true });
230
253
  cpSync(options.pluginSourceDirectory, pluginDirectory, { recursive: true, force: true });
231
254
  const formatPathArgument = options.formatPathArgument ?? quoteCommandPath;
232
- const pythonCommand = options.pythonCommand ?? DEFAULT_PYTHON_COMMAND;
233
255
  const commands = {
234
256
  sessionCommand: deepStackCursorSessionCommand(pluginDirectory, formatPathArgument, options.platform ?? process.platform),
235
- firstTaskCommand: `${pythonCommand} ${formatPathArgument(options.firstTaskHookPath)}`,
236
257
  };
237
258
  updateCursorHooksFile(
238
259
  options.cursorDirectory,
@@ -263,8 +284,6 @@ export function seedCodexDeepStackModels(codexHome) {
263
284
  * @param {string} [options.claudeRoot] The managed Claude root.
264
285
  * @param {string} [options.codexHome] The Codex home.
265
286
  * @param {string} [options.cursorDirectory] The Cursor home.
266
- * @param {string} [options.firstTaskHookPath] Absolute path of the hook the first-task command runs.
267
- * @param {string} [options.pythonCommand] The interpreter the installer detected.
268
287
  * @param {(pathValue: string) => string} [options.formatPathArgument] The platform path quoting.
269
288
  * @param {string} [options.sourceDirectory] A local plugin tree for the Cursor copy.
270
289
  * @param {string[]} [options.argumentList] Command-line arguments, for the opt-out flag.
@@ -307,8 +326,6 @@ export function installDeepStackForHosts(options = {}, dependencies = {}) {
307
326
  cursor = installDeepStackCursor({
308
327
  cursorDirectory: options.cursorDirectory,
309
328
  pluginSourceDirectory: sourceDirectory,
310
- firstTaskHookPath: options.firstTaskHookPath,
311
- pythonCommand: options.pythonCommand,
312
329
  formatPathArgument: options.formatPathArgument,
313
330
  });
314
331
  } finally {
@@ -15,6 +15,7 @@ import {
15
15
  installDeepStackForHosts,
16
16
  mergeDeepStackCursorHooks,
17
17
  removeDeepStackCursorHooks,
18
+ removeRetiredCursorFirstTaskHook,
18
19
  } from './install-deepstack-plugin.mjs';
19
20
  import { marketplacePluginsPlan, shouldInstallMarketplacePlugin } from './install-pstack-plugin.mjs';
20
21
 
@@ -31,23 +32,23 @@ test('Claude and Codex plans name jl-cmd-projects/DeepStack', () => {
31
32
  assert.equal(DEEPSTACK_MARKETPLACE_REPOSITORY, 'jl-cmd-projects/DeepStack');
32
33
  assert.equal(deepStackCloneUrl(), 'https://github.com/jl-cmd-projects/DeepStack.git');
33
34
  assert.equal(plan.cursor.skillRelativePath, 'skills/deepstack-mode/SKILL.md');
34
- assert.equal(plan.cursor.firstTaskHookName, 'deepen_first_prompt.py');
35
+ assert.equal('firstTaskHookName' in plan.cursor, false);
35
36
  assert.throws(() => marketplacePluginsPlan([DEEPSTACK_PLUGIN_SPEC], 'cursor'), /cursor/);
36
37
  });
37
38
 
38
- test('cursor hooks are one native sessionStart entry and one native beforeSubmitPrompt entry', () => {
39
+ test('cursor hooks are one native sessionStart entry and the retired first-task entry is dropped', () => {
39
40
  const legacySessionGroup = {
40
41
  matcher: 'startup|resume|clear|compact',
41
42
  hooks: [{ type: 'command', command: "'C:\\Users\\dev\\.cursor\\plugins\\deepstack\\hooks\\session-start.sh' cursor" }],
42
43
  };
43
44
  const commands = {
44
45
  sessionCommand: "'/cursor/plugins/deepstack/hooks/session-start.sh' cursor",
45
- firstTaskCommand: "python3 '/claude/hooks/session/deepen_first_prompt.py'",
46
46
  };
47
47
  const merged = mergeDeepStackCursorHooks({
48
48
  hooks: {
49
49
  SessionStart: [legacySessionGroup, legacySessionGroup, legacySessionGroup],
50
50
  UserPromptSubmit: [{ matcher: '', hooks: [{ type: 'command', command: 'python3 /old/deepen_first_prompt.py', timeout: 10 }] }],
51
+ beforeSubmitPrompt: [{ command: "python3 '/claude/hooks/session/deepen_first_prompt.py'", timeout: 10 }],
51
52
  preToolUse: [],
52
53
  },
53
54
  }, commands);
@@ -55,7 +56,6 @@ test('cursor hooks are one native sessionStart entry and one native beforeSubmit
55
56
  version: 1,
56
57
  hooks: {
57
58
  sessionStart: [{ command: commands.sessionCommand, timeout: 10 }],
58
- beforeSubmitPrompt: [{ command: commands.firstTaskCommand, timeout: 10 }],
59
59
  preToolUse: [],
60
60
  },
61
61
  });
@@ -75,7 +75,7 @@ test('the Windows session command runs the PowerShell script, other platforms th
75
75
  }
76
76
  });
77
77
 
78
- test('the cursor copy writes the skill and both native hook commands, and removal takes out only those', () => {
78
+ test('the cursor copy writes the skill and the native session hook, and removal takes out only that', () => {
79
79
  const root = mkdtempSync(join(tmpdir(), 'deepstack-cursor-'));
80
80
  try {
81
81
  const source = join(root, 'source');
@@ -89,8 +89,6 @@ test('the cursor copy writes the skill and both native hook commands, and remova
89
89
  const outcome = installDeepStackCursor({
90
90
  cursorDirectory,
91
91
  pluginSourceDirectory: source,
92
- firstTaskHookPath: join(root, '.agents', 'hooks', 'session', 'deepen_first_prompt.py'),
93
- pythonCommand: '/usr/bin/python3.12',
94
92
  platform: 'linux',
95
93
  });
96
94
  assert.equal(outcome.status, 'installed');
@@ -102,7 +100,7 @@ test('the cursor copy writes the skill and both native hook commands, and remova
102
100
  assert.equal(hooks.version, 1);
103
101
  assert.equal(hooks.hooks.sessionStart.length, 1);
104
102
  assert.match(hooks.hooks.sessionStart[0].command, /session-start\.sh' cursor$/);
105
- assert.match(hooks.hooks.beforeSubmitPrompt[0].command, /^\/usr\/bin\/python3\.12 '.*deepen_first_prompt\.py'$/);
103
+ assert.equal(hooks.hooks.beforeSubmitPrompt, undefined);
106
104
  removeDeepStackCursorHooks(cursorDirectory);
107
105
  assert.deepEqual(
108
106
  JSON.parse(readFileSync(join(cursorDirectory, 'hooks.json'), 'utf8')),
@@ -113,11 +111,41 @@ test('the cursor copy writes the skill and both native hook commands, and remova
113
111
  }
114
112
  });
115
113
 
114
+ test('the retired first-task entry leaves the Cursor hooks file and every other entry stays', () => {
115
+ const root = mkdtempSync(join(tmpdir(), 'deepstack-cursor-'));
116
+ try {
117
+ const hooksPath = join(root, 'hooks.json');
118
+ writeFileSync(hooksPath, JSON.stringify({
119
+ version: 1,
120
+ hooks: {
121
+ beforeSubmitPrompt: [
122
+ { command: "python3 '/home/dev/.claude/hooks/session/deepen_first_prompt.py'", timeout: 10 },
123
+ { command: './user-prompt.sh' },
124
+ ],
125
+ stop: [{ command: './user-stop.sh' }],
126
+ },
127
+ }));
128
+ assert.equal(removeRetiredCursorFirstTaskHook(root), hooksPath);
129
+ assert.deepEqual(JSON.parse(readFileSync(hooksPath, 'utf8')), {
130
+ version: 1,
131
+ hooks: {
132
+ beforeSubmitPrompt: [{ command: './user-prompt.sh' }],
133
+ stop: [{ command: './user-stop.sh' }],
134
+ },
135
+ });
136
+ const cleanText = readFileSync(hooksPath, 'utf8');
137
+ assert.equal(removeRetiredCursorFirstTaskHook(root), null);
138
+ assert.equal(readFileSync(hooksPath, 'utf8'), cleanText);
139
+ assert.equal(removeRetiredCursorFirstTaskHook(join(root, 'absent')), null);
140
+ } finally {
141
+ rmSync(root, { recursive: true, force: true });
142
+ }
143
+ });
144
+
116
145
  test('a missing plugin tree fails the cursor copy', () => {
117
146
  const outcome = installDeepStackCursor({
118
147
  cursorDirectory: '/tmp/unused-cursor',
119
148
  pluginSourceDirectory: null,
120
- firstTaskHookPath: '/tmp/deepen_first_prompt.py',
121
149
  });
122
150
  assert.equal(outcome.status, 'failed');
123
151
  assert.match(outcome.warning, /jl-cmd-projects\/DeepStack/);
@@ -162,7 +190,6 @@ test('the temporary clone is removed once the Cursor copy is written', () => {
162
190
  claudeRoot: join(root, 'claude'),
163
191
  codexHome: join(root, 'codex'),
164
192
  cursorDirectory,
165
- firstTaskHookPath: join(root, 'hooks', 'deepen_first_prompt.py'),
166
193
  environment: {},
167
194
  cloneParentDirectory,
168
195
  }, {
@@ -190,7 +217,6 @@ test('a failed clone leaves no temporary folder behind', () => {
190
217
  claudeRoot: join(root, 'claude'),
191
218
  codexHome: join(root, 'codex'),
192
219
  cursorDirectory: join(root, 'cursor'),
193
- firstTaskHookPath: join(root, 'hooks', 'deepen_first_prompt.py'),
194
220
  environment: {},
195
221
  cloneParentDirectory,
196
222
  }, { runCommand: absentHostRunner, cloneRepository: () => null });
@@ -35,17 +35,17 @@ export const USAGE_WRAPUP_PLUGIN_SPEC = Object.freeze({
35
35
  optOutVariable: 'CDE_INSTALL_USAGE_WRAPUP',
36
36
  });
37
37
 
38
- export const DEEPEN_FIRST_PROMPT_PLUGIN_SPEC = Object.freeze({
39
- name: 'deepen-first-prompt',
40
- label: 'Deepen-first-prompt',
41
- marketplaceRepository: 'jl-cmd-projects/claude-dev-env',
42
- marketplaceName: 'claude-dev-env',
43
- sparsePaths: Object.freeze(['.claude-plugin', 'packages/deepen-first-prompt']),
44
- pluginIdentifier: 'deepen-first-prompt@claude-dev-env',
45
- hosts: Object.freeze(['claude']),
46
- optOutFlag: '--no-deepen-first-prompt',
47
- optOutVariable: 'CDE_INSTALL_DEEPEN_FIRST_PROMPT',
48
- });
38
+ /**
39
+ * Plugins this installer used to install and now removes from each Claude home
40
+ * that still holds them, so an update leaves none behind.
41
+ */
42
+ export const ALL_RETIRED_PLUGIN_SPECS = Object.freeze([
43
+ Object.freeze({
44
+ name: 'deepen-first-prompt',
45
+ marketplaceName: 'claude-dev-env',
46
+ pluginIdentifier: 'deepen-first-prompt@claude-dev-env',
47
+ }),
48
+ ]);
49
49
 
50
50
  export const SUBAGENT_MODELS_PLUGIN_SPEC = Object.freeze({
51
51
  name: 'subagent-models',
@@ -341,3 +341,50 @@ export function installMarketplacePlugins(allSpecs, options = {}, dependencies =
341
341
  const warnings = hostOutcomes.map(outcome => outcome.warning).filter(Boolean);
342
342
  return { status, hosts: hostOutcomes, warning: warnings.join(' ') || null };
343
343
  }
344
+
345
+ /**
346
+ * Uninstall each retired plugin a Claude home still holds.
347
+ *
348
+ * A home holds a retired plugin when its plugin cache has a folder for it. For
349
+ * each such plugin the step runs `claude plugin uninstall` and then deletes
350
+ * the cached versions Claude Code marked as orphaned. A home with no cached
351
+ * folder runs no command, so a fresh install and a second update both leave
352
+ * the home as it stands. A missing `claude` command skips the step. A failed
353
+ * uninstall is recorded in `failedRemovals` with its detail, and its cached
354
+ * versions stay on disk.
355
+ *
356
+ * @param {ReadonlyArray<{name: string, marketplaceName: string, pluginIdentifier: string}>} allRetiredSpecs The retired plugins.
357
+ * @param {object} options Removal targets.
358
+ * @param {string|undefined} options.claudeRoot The managed Claude root.
359
+ * @param {Record<string, string|undefined>} [options.environment] The process environment.
360
+ * @param {object} [dependencies] Seams for the command runner.
361
+ * @returns {{removedIdentifiers: string[], removedVersionPaths: string[], failedRemovals: Array<{pluginIdentifier: string, detail: string}>}} What the step removed and what it could not.
362
+ */
363
+ export function removeRetiredMarketplacePlugins(allRetiredSpecs, options, dependencies = {}) {
364
+ const environment = options.environment ?? process.env;
365
+ const runCommand = dependencies.runCommand ?? runHostCommand;
366
+ const hostDefinition = HOST_DEFINITIONS.claude;
367
+ const executable = environment[hostDefinition.executableVariable] || hostDefinition.executable;
368
+ const removedIdentifiers = [];
369
+ const removedVersionPaths = [];
370
+ const failedRemovals = [];
371
+ if (!options.claudeRoot) return { removedIdentifiers, removedVersionPaths, failedRemovals };
372
+ for (const eachSpec of allRetiredSpecs) {
373
+ const versionsDirectory = hostDefinition.orphanedVersionsDirectory(options.claudeRoot, eachSpec);
374
+ if (!existsSync(versionsDirectory)) continue;
375
+ const outcome = runCommand(
376
+ executable,
377
+ ['plugin', hostDefinition.uninstallVerb, eachSpec.pluginIdentifier],
378
+ { environment: { [hostDefinition.homeVariable]: options.claudeRoot } },
379
+ );
380
+ if (namesAnAbsentCommand(outcome)) break;
381
+ if (outcome.status !== 0 || outcome.error) {
382
+ const detail = singleLineDetail(outcome.stderr) || outcome.error?.message || `exit ${outcome.status}`;
383
+ failedRemovals.push({ pluginIdentifier: eachSpec.pluginIdentifier, detail });
384
+ continue;
385
+ }
386
+ removedIdentifiers.push(eachSpec.pluginIdentifier);
387
+ removedVersionPaths.push(...removeOrphanedPluginVersions(versionsDirectory));
388
+ }
389
+ return { removedIdentifiers, removedVersionPaths, failedRemovals };
390
+ }
@@ -1,13 +1,13 @@
1
1
  import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import {
7
7
  PSTACK_MARKETPLACE_REPOSITORY,
8
8
  PSTACK_PLUGIN_HOSTS,
9
9
  PSTACK_PLUGIN_IDENTIFIER,
10
- DEEPEN_FIRST_PROMPT_PLUGIN_SPEC,
10
+ ALL_RETIRED_PLUGIN_SPECS,
11
11
  PSTACK_PLUGIN_SPEC,
12
12
  SUBAGENT_MODELS_PLUGIN_SPEC,
13
13
  USAGE_WRAPUP_PLUGIN_SPEC,
@@ -15,6 +15,7 @@ import {
15
15
  installMarketplacePlugins,
16
16
  marketplacePluginsPlan,
17
17
  removeOrphanedPluginVersions,
18
+ removeRetiredMarketplacePlugins,
18
19
  shouldInstallMarketplacePlugin,
19
20
  } from './install-pstack-plugin.mjs';
20
21
 
@@ -301,7 +302,6 @@ test('the usage-wrapup opt-out flag and variable turn off only usage-wrapup', ()
301
302
  for (const [spec, packageFolder] of [
302
303
  [USAGE_WRAPUP_PLUGIN_SPEC, './packages/usage-wrapup'],
303
304
  [SUBAGENT_MODELS_PLUGIN_SPEC, './packages/subagent-models'],
304
- [DEEPEN_FIRST_PROMPT_PLUGIN_SPEC, './packages/deepen-first-prompt'],
305
305
  ]) {
306
306
  test(`the repository marketplace lists ${spec.name} from its package folder in this repository`, () => {
307
307
  const marketplace = JSON.parse(readFileSync(
@@ -335,25 +335,58 @@ test('should remove and add the shared marketplace once when installing usage-wr
335
335
  ]);
336
336
  });
337
337
 
338
- test('should include deepen-first-prompt in one marketplace add with the other repository plugins', () => {
339
- const plan = marketplacePluginsPlan(
340
- [USAGE_WRAPUP_PLUGIN_SPEC, SUBAGENT_MODELS_PLUGIN_SPEC, DEEPEN_FIRST_PROMPT_PLUGIN_SPEC],
341
- 'claude',
342
- );
343
- assert.deepEqual(plan.commands[0], [
344
- 'plugin', 'marketplace', 'add', 'jl-cmd-projects/claude-dev-env',
345
- '--sparse', '.claude-plugin', 'packages/usage-wrapup', 'packages/subagent-models',
346
- 'packages/deepen-first-prompt',
338
+ test('the repository marketplace no longer lists a retired plugin', () => {
339
+ const marketplace = JSON.parse(readFileSync(
340
+ new URL('../../../.claude-plugin/marketplace.json', import.meta.url),
341
+ 'utf8',
342
+ ));
343
+ const allListedNames = marketplace.plugins.map(plugin => plugin.name);
344
+ for (const eachRetiredSpec of ALL_RETIRED_PLUGIN_SPECS) {
345
+ assert.equal(allListedNames.includes(eachRetiredSpec.name), false, `${eachRetiredSpec.name} is retired`);
346
+ }
347
+ assert.deepEqual(ALL_RETIRED_PLUGIN_SPECS.map(spec => spec.pluginIdentifier), ['deepen-first-prompt@claude-dev-env']);
348
+ });
349
+
350
+ test('a Claude home that holds a retired plugin gets it uninstalled and its orphaned versions deleted', (context) => {
351
+ const claudeRoot = mkdtempSync(join(tmpdir(), 'retired-plugin-'));
352
+ context.after(() => rmSync(claudeRoot, { recursive: true, force: true }));
353
+ const versionsDirectory = join(claudeRoot, 'plugins', 'cache', 'claude-dev-env', 'deepen-first-prompt');
354
+ const orphanedVersion = join(versionsDirectory, '0.1.0');
355
+ mkdirSync(orphanedVersion, { recursive: true });
356
+ const runner = recordingRunner(() => {
357
+ writeFileSync(join(orphanedVersion, '.orphaned_at'), '1');
358
+ return { status: 0, stderr: '' };
359
+ });
360
+ const outcome = removeRetiredMarketplacePlugins(ALL_RETIRED_PLUGIN_SPECS, { claudeRoot, environment: {} }, runner);
361
+ assert.deepEqual(runner.calls.map(call => [call.executable, ...call.commandArguments]), [
362
+ ['claude', 'plugin', 'uninstall', 'deepen-first-prompt@claude-dev-env'],
347
363
  ]);
348
- assert.equal(shouldInstallMarketplacePlugin(DEEPEN_FIRST_PROMPT_PLUGIN_SPEC, ['--no-deepen-first-prompt'], {}), false);
349
- assert.equal(
350
- shouldInstallMarketplacePlugin(
351
- DEEPEN_FIRST_PROMPT_PLUGIN_SPEC,
352
- [],
353
- { CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0' },
354
- ),
355
- false,
356
- );
364
+ assert.deepEqual(runner.calls[0].environment, { CLAUDE_CONFIG_DIR: claudeRoot });
365
+ assert.deepEqual(outcome.removedIdentifiers, ['deepen-first-prompt@claude-dev-env']);
366
+ assert.equal(existsSync(orphanedVersion), false);
367
+ });
368
+
369
+ test('a Claude home with no retired plugin runs no command', (context) => {
370
+ const claudeRoot = mkdtempSync(join(tmpdir(), 'retired-plugin-'));
371
+ context.after(() => rmSync(claudeRoot, { recursive: true, force: true }));
372
+ const runner = recordingRunner();
373
+ const outcome = removeRetiredMarketplacePlugins(ALL_RETIRED_PLUGIN_SPECS, { claudeRoot, environment: {} }, runner);
374
+ assert.deepEqual(runner.calls, []);
375
+ assert.deepEqual(outcome, { removedIdentifiers: [], removedVersionPaths: [], failedRemovals: [] });
376
+ });
377
+
378
+ test('a failed retired plugin uninstall is reported as failed and keeps its cached versions', (context) => {
379
+ const claudeRoot = mkdtempSync(join(tmpdir(), 'retired-plugin-'));
380
+ context.after(() => rmSync(claudeRoot, { recursive: true, force: true }));
381
+ const cachedVersion = join(claudeRoot, 'plugins', 'cache', 'claude-dev-env', 'deepen-first-prompt', '0.1.0');
382
+ mkdirSync(cachedVersion, { recursive: true });
383
+ const runner = recordingRunner(() => ({ status: 1, stderr: 'Plugin not found\n' }));
384
+ const outcome = removeRetiredMarketplacePlugins(ALL_RETIRED_PLUGIN_SPECS, { claudeRoot, environment: {} }, runner);
385
+ assert.deepEqual(outcome.removedIdentifiers, []);
386
+ assert.deepEqual(outcome.failedRemovals, [
387
+ { pluginIdentifier: 'deepen-first-prompt@claude-dev-env', detail: 'Plugin not found' },
388
+ ]);
389
+ assert.equal(existsSync(cachedVersion), true);
357
390
  });
358
391
 
359
392
  test('should refuse one plan for plugins from two marketplaces', () => {
@@ -56,7 +56,6 @@ function runInstaller(homeDirectory, extraArguments, environmentOverrides = {})
56
56
  CDE_INSTALL_DEEPSTACK: '0',
57
57
  CDE_INSTALL_USAGE_WRAPUP: '0',
58
58
  CDE_INSTALL_SUBAGENT_MODELS: '0',
59
- CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
60
59
  HOME: homeDirectory,
61
60
  USERPROFILE: homeDirectory,
62
61
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -62,7 +62,6 @@ function runInstaller(homeDirectory, extraArguments) {
62
62
  CDE_INSTALL_DEEPSTACK: '0',
63
63
  CDE_INSTALL_USAGE_WRAPUP: '0',
64
64
  CDE_INSTALL_SUBAGENT_MODELS: '0',
65
- CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
66
65
  HOME: homeDirectory,
67
66
  USERPROFILE: homeDirectory,
68
67
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -96,7 +95,6 @@ function scanTextWithProductionPiiScanner(scannedText) {
96
95
  CDE_INSTALL_DEEPSTACK: '0',
97
96
  CDE_INSTALL_USAGE_WRAPUP: '0',
98
97
  CDE_INSTALL_SUBAGENT_MODELS: '0',
99
- CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
100
98
  PYTHONPATH: BLOCKING_HOOKS_DIRECTORY,
101
99
  CLAUDE_LOCAL_IDENTITY_PATH: join(
102
100
  temporaryDirectory,