claude-dev-env 8.87.1 → 8.88.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/AGENTS.md +1 -0
  2. package/bin/cursor-agents-guidance.mjs +94 -0
  3. package/bin/cursor-agents-guidance.test.mjs +94 -0
  4. package/bin/cursor-native-hooks.mjs +128 -0
  5. package/bin/cursor-native-hooks.test.mjs +78 -0
  6. package/bin/install-constants.mjs +29 -0
  7. package/bin/install-deepstack-plugin.mjs +84 -39
  8. package/bin/install-deepstack-plugin.test.mjs +51 -11
  9. package/bin/install.cursor-rules.test.mjs +65 -2
  10. package/bin/install.mjs +40 -2
  11. package/bin/install.test.mjs +10 -5
  12. package/bin/python-probe.mjs +20 -0
  13. package/bin/python-probe.test.mjs +37 -0
  14. package/docs/agents-guidance/asks.md +49 -0
  15. package/hooks/blocking/open_tasks_stop_gate.py +210 -0
  16. package/hooks/blocking/open_tasks_transcript.py +270 -0
  17. package/hooks/blocking/test_open_tasks_stop_gate.py +297 -0
  18. package/hooks/blocking/test_open_tasks_transcript.py +54 -0
  19. package/hooks/features/conduct-gates.md +3 -0
  20. package/hooks/features/session-context.md +2 -1
  21. package/hooks/hooks.json +5 -0
  22. package/hooks/hooks_constants/cursor_agents_guidance_constants.py +6 -0
  23. package/hooks/hooks_constants/deepen_first_prompt_constants.py +8 -0
  24. package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +72 -0
  25. package/hooks/session/cursor_agents_guidance.py +54 -0
  26. package/hooks/session/deepen_first_prompt.py +12 -31
  27. package/hooks/session/deepen_turn_marker.py +75 -0
  28. package/hooks/session/test_cursor_agents_guidance.py +24 -0
  29. package/hooks/session/test_deepen_first_prompt.py +63 -0
  30. package/hooks/session/test_deepen_turn_marker.py +59 -0
  31. package/package.json +1 -1
  32. package/scripts/policy_lint/adapter_configuration.py +6 -0
  33. package/scripts/policy_lint/config/constants.py +1 -0
  34. package/scripts/tests/test_adapter_configuration.py +8 -0
package/AGENTS.md CHANGED
@@ -4,6 +4,7 @@ Every rule below is always on. A rule with a link keeps its full text in that fi
4
4
  - **Diffs.** Show the hunk. No prose stand-in.
5
5
  - **Verdicts.** One line. Read files first. Quote file:line. Code beats brief. No flip-flops.
6
6
  - **Evidence** ([`~/.claude/docs/agents-guidance/evidence.md`](docs/agents-guidance/evidence.md)). Act and state only on what you or your workers saw, ran, or tested. Settle an open fact with a tool before you answer. When no permitted tool reaches it, name the check and who can run it.
7
+ - **Asks** ([`~/.claude/docs/agents-guidance/asks.md`](docs/agents-guidance/asks.md)). Work every ask from the user now, without pause, until it is done with proof you saw or blocked on something only the user can give. No deadlines and no "later". Each ask is a task list item. A turn ends only when every item is completed, or the reply ends with `Blocked: <item> - <what the user must give>` or `Open: <item> - <the step you take next>` for it. The Stop hook `open_tasks_stop_gate.py` holds the turn until then.
7
8
  - **Writes.** Write/Edit only. LF. After: git diff --check, file <path>. ASCII unless the file already isn't.
8
9
  - **Edit gate.** Own worktree. Path overlap with another session → stop. Behind origin/main → rebase or report.
9
10
  - **Scope.** Min diff. No drive-by reformat. No blanket autofix without tests and revert on break.
@@ -0,0 +1,94 @@
1
+ import { join } from 'node:path';
2
+
3
+ import {
4
+ CURSOR_GUIDANCE_HOOK_RELATIVE_PATH,
5
+ CURSOR_HOOK_TIMEOUT_SECONDS,
6
+ CURSOR_OPEN_TASKS_HOOK_RELATIVE_PATH,
7
+ CURSOR_SESSION_START_EVENT,
8
+ CURSOR_STOP_EVENT,
9
+ } from './install-constants.mjs';
10
+ import { updateCursorHooksFile, withOwnedCursorHooks, withoutOwnedCursorHooks } from './cursor-native-hooks.mjs';
11
+
12
+ const DEFAULT_PYTHON_COMMAND = 'python3';
13
+ const OWNED_SCRIPT_NAMES = [
14
+ CURSOR_GUIDANCE_HOOK_RELATIVE_PATH.at(-1),
15
+ CURSOR_OPEN_TASKS_HOOK_RELATIVE_PATH.at(-1),
16
+ ];
17
+
18
+ function quotedPath(pathValue) {
19
+ return `'${pathValue.replaceAll("'", "'\\''")}'`;
20
+ }
21
+
22
+ /**
23
+ * True for a Cursor hook command this module owns.
24
+ *
25
+ * @param {string} forwardSlashedCommand A hook command with forward slashes.
26
+ * @returns {boolean} True when the command runs the guidance hook or the open tasks gate.
27
+ */
28
+ export function isAgentsGuidanceCommand(forwardSlashedCommand) {
29
+ return OWNED_SCRIPT_NAMES.some(eachName => forwardSlashedCommand.includes(eachName));
30
+ }
31
+
32
+ /**
33
+ * Return the Cursor hooks configuration, in native format, without the agents guidance hooks.
34
+ *
35
+ * @param {object} configuration The parsed ~/.cursor/hooks.json.
36
+ * @returns {object} The configuration with both owned entries removed.
37
+ */
38
+ export function withoutCursorAgentsGuidanceHooks(configuration) {
39
+ return withoutOwnedCursorHooks(configuration, isAgentsGuidanceCommand);
40
+ }
41
+
42
+ /**
43
+ * Return the Cursor hooks configuration with one guidance `sessionStart` entry and one open tasks `stop` entry.
44
+ *
45
+ * The `sessionStart` hook returns the installed AGENTS.md as `additional_context`,
46
+ * which Cursor adds to the session's context. The `stop` hook runs the open
47
+ * tasks gate, whose `followup_message` Cursor submits as the next user message.
48
+ *
49
+ * @param {object} configuration The parsed ~/.cursor/hooks.json.
50
+ * @param {{guidanceCommand: string, openTasksCommand: string}} commands The two hook commands.
51
+ * @returns {object} The merged native configuration.
52
+ */
53
+ export function mergeCursorAgentsGuidanceHooks(configuration, commands) {
54
+ return withOwnedCursorHooks(configuration, isAgentsGuidanceCommand, {
55
+ [CURSOR_SESSION_START_EVENT]: [{ command: commands.guidanceCommand, timeout: CURSOR_HOOK_TIMEOUT_SECONDS }],
56
+ [CURSOR_STOP_EVENT]: [{ command: commands.openTasksCommand, timeout: CURSOR_HOOK_TIMEOUT_SECONDS }],
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Register the agents guidance and open tasks hooks in the Cursor home.
62
+ *
63
+ * The commands use the interpreter the installer detected, the same one the
64
+ * Claude and Codex hooks use, so a Windows install runs the resolved
65
+ * `python.exe` path and not the Microsoft Store `python3` stub.
66
+ *
67
+ * @param {{cursorDirectory: string, hooksDirectory: string, guidancePath: string, pythonCommand?: string, formatPathArgument?: (pathValue: string) => string}} options Install paths, the detected interpreter, and the platform path quoting.
68
+ * @returns {string|null} The hooks.json path when this run changed it.
69
+ */
70
+ export function writeCursorAgentsGuidanceHooks(options) {
71
+ const pythonCommand = options.pythonCommand ?? DEFAULT_PYTHON_COMMAND;
72
+ const formatPathArgument = options.formatPathArgument ?? quotedPath;
73
+ const guidanceScript = join(options.hooksDirectory, ...CURSOR_GUIDANCE_HOOK_RELATIVE_PATH);
74
+ const openTasksScript = join(options.hooksDirectory, ...CURSOR_OPEN_TASKS_HOOK_RELATIVE_PATH);
75
+ const commands = {
76
+ guidanceCommand: `${pythonCommand} ${formatPathArgument(guidanceScript)} ${formatPathArgument(options.guidancePath)}`,
77
+ openTasksCommand: `${pythonCommand} ${formatPathArgument(openTasksScript)}`,
78
+ };
79
+ return updateCursorHooksFile(
80
+ options.cursorDirectory,
81
+ configuration => mergeCursorAgentsGuidanceHooks(configuration, commands),
82
+ { createWhenMissing: true },
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Remove the agents guidance and open tasks hooks from the Cursor home.
88
+ *
89
+ * @param {string} cursorDirectory The Cursor home.
90
+ * @returns {string|null} The hooks.json path when this run changed it.
91
+ */
92
+ export function removeCursorAgentsGuidanceHooks(cursorDirectory) {
93
+ return updateCursorHooksFile(cursorDirectory, withoutCursorAgentsGuidanceHooks);
94
+ }
@@ -0,0 +1,94 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import {
8
+ mergeCursorAgentsGuidanceHooks,
9
+ removeCursorAgentsGuidanceHooks,
10
+ writeCursorAgentsGuidanceHooks,
11
+ } from './cursor-agents-guidance.mjs';
12
+
13
+ const USER_SESSION_ENTRY = { command: './user-session.sh' };
14
+ const LEGACY_GUIDANCE_GROUP = {
15
+ matcher: 'startup|resume|clear|compact',
16
+ hooks: [{ type: 'command', command: "python3 '/home/dev/.agents/hooks/session/cursor_agents_guidance.py' AGENTS.md", timeout: 10 }],
17
+ };
18
+ const LEGACY_GATE_GROUP = {
19
+ matcher: '',
20
+ hooks: [{ type: 'command', command: "python3 '/home/dev/.agents/hooks/blocking/open_tasks_stop_gate.py'", timeout: 10 }],
21
+ };
22
+
23
+ test('merge adds one native sessionStart entry and one native stop entry and keeps user entries', () => {
24
+ const commands = { guidanceCommand: 'python3 cursor_agents_guidance.py AGENTS.md', openTasksCommand: 'python3 open_tasks_stop_gate.py' };
25
+ const once = mergeCursorAgentsGuidanceHooks({ hooks: { sessionStart: [USER_SESSION_ENTRY] } }, commands);
26
+ assert.deepEqual(mergeCursorAgentsGuidanceHooks(once, commands), once);
27
+ assert.deepEqual(once, {
28
+ version: 1,
29
+ hooks: {
30
+ sessionStart: [USER_SESSION_ENTRY, { command: commands.guidanceCommand, timeout: 10 }],
31
+ stop: [{ command: commands.openTasksCommand, timeout: 10 }],
32
+ },
33
+ });
34
+ });
35
+
36
+ test('merge replaces the Claude-style groups an earlier build of this module wrote', () => {
37
+ const commands = { guidanceCommand: 'py cursor_agents_guidance.py AGENTS.md', openTasksCommand: 'py open_tasks_stop_gate.py' };
38
+ const merged = mergeCursorAgentsGuidanceHooks(
39
+ { hooks: { SessionStart: [LEGACY_GUIDANCE_GROUP], Stop: [LEGACY_GATE_GROUP] } },
40
+ commands,
41
+ );
42
+ assert.deepEqual(Object.keys(merged.hooks), ['sessionStart', 'stop']);
43
+ assert.equal(merged.hooks.sessionStart.length, 1);
44
+ assert.equal(merged.hooks.stop.length, 1);
45
+ assert.equal(merged.hooks.stop[0].command, commands.openTasksCommand);
46
+ });
47
+
48
+ test('write registers both hooks with the installed paths and remove takes out exactly those', () => {
49
+ const cursorDirectory = mkdtempSync(join(tmpdir(), 'cdev-cursor-guidance-'));
50
+ try {
51
+ const hooksPath = join(cursorDirectory, 'hooks.json');
52
+ writeFileSync(hooksPath, JSON.stringify({ version: 1, hooks: { sessionStart: [USER_SESSION_ENTRY] } }));
53
+ const installOptions = {
54
+ cursorDirectory,
55
+ hooksDirectory: '/home/dev/.agents/hooks',
56
+ guidancePath: '/home/dev/.agents/AGENTS.md',
57
+ };
58
+ assert.equal(writeCursorAgentsGuidanceHooks(installOptions), hooksPath);
59
+ const written = JSON.parse(readFileSync(hooksPath, 'utf8'));
60
+ const guidanceScript = join(installOptions.hooksDirectory, 'session', 'cursor_agents_guidance.py');
61
+ const openTasksScript = join(installOptions.hooksDirectory, 'blocking', 'open_tasks_stop_gate.py');
62
+ assert.equal(
63
+ written.hooks.sessionStart[1].command,
64
+ `python3 '${guidanceScript}' '/home/dev/.agents/AGENTS.md'`,
65
+ );
66
+ assert.equal(written.hooks.stop[0].command, `python3 '${openTasksScript}'`);
67
+ assert.equal(writeCursorAgentsGuidanceHooks(installOptions), null);
68
+ removeCursorAgentsGuidanceHooks(cursorDirectory);
69
+ assert.deepEqual(JSON.parse(readFileSync(hooksPath, 'utf8')), { version: 1, hooks: { sessionStart: [USER_SESSION_ENTRY] } });
70
+ } finally {
71
+ rmSync(cursorDirectory, { recursive: true, force: true });
72
+ }
73
+ });
74
+
75
+ test('write uses the detected interpreter and platform path quoting and sets the schema version', () => {
76
+ const cursorDirectory = mkdtempSync(join(tmpdir(), 'cdev-cursor-guidance-'));
77
+ try {
78
+ const hooksDirectory = 'C:/Users/dev/.agents/hooks';
79
+ writeCursorAgentsGuidanceHooks({
80
+ cursorDirectory,
81
+ hooksDirectory,
82
+ guidancePath: 'C:/Users/dev/.agents/AGENTS.md',
83
+ pythonCommand: 'C:/Python312/python.exe',
84
+ formatPathArgument: pathValue => `"${pathValue}"`,
85
+ });
86
+ const written = JSON.parse(readFileSync(join(cursorDirectory, 'hooks.json'), 'utf8'));
87
+ assert.equal(written.version, 1);
88
+ const openTasksScript = join(hooksDirectory, 'blocking', 'open_tasks_stop_gate.py');
89
+ assert.equal(written.hooks.stop[0].command, `C:/Python312/python.exe "${openTasksScript}"`);
90
+ assert.match(written.hooks.sessionStart[0].command, /^C:\/Python312\/python\.exe "/);
91
+ } finally {
92
+ rmSync(cursorDirectory, { recursive: true, force: true });
93
+ }
94
+ });
@@ -0,0 +1,128 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import {
5
+ ALL_CURSOR_LIFECYCLE_EVENT_NAMES,
6
+ CURSOR_HOOKS_FILE_NAME,
7
+ CURSOR_HOOKS_SCHEMA_VERSION,
8
+ CURSOR_NATIVE_EVENT_NAME_BY_LEGACY_NAME,
9
+ } from './install-constants.mjs';
10
+
11
+ /**
12
+ * Cursor reads ~/.cursor/hooks.json only in its own format: `version: 1` and a
13
+ * `hooks` map from a camelCase event (`sessionStart`, `beforeSubmitPrompt`,
14
+ * `stop`) to flat `{command, timeout}` entries. A Claude-style group entry
15
+ * (`{"matcher": ..., "hooks": [...]}`) never runs, and while one is in the
16
+ * file Cursor runs none of the file's hooks. This module turns such groups
17
+ * into native entries and lets each package module replace only the entries
18
+ * it owns.
19
+ */
20
+
21
+ function isLegacyGroupEntry(entry) {
22
+ return Boolean(entry) && typeof entry === 'object' && Array.isArray(entry.hooks) && typeof entry.command !== 'string';
23
+ }
24
+
25
+ function nativeEntriesFromLegacyGroup(group, nativeEventName) {
26
+ const keepsMatcher = typeof group.matcher === 'string'
27
+ && group.matcher !== ''
28
+ && group.matcher !== '*'
29
+ && !ALL_CURSOR_LIFECYCLE_EVENT_NAMES.has(nativeEventName);
30
+ return group.hooks
31
+ .filter(eachHook => eachHook && typeof eachHook.command === 'string')
32
+ .map(eachHook => ({
33
+ command: eachHook.command,
34
+ ...(typeof eachHook.timeout === 'number' ? { timeout: eachHook.timeout } : {}),
35
+ ...(keepsMatcher ? { matcher: group.matcher } : {}),
36
+ }));
37
+ }
38
+
39
+ /**
40
+ * Return the configuration in Cursor's native format.
41
+ *
42
+ * Every Claude-style group becomes one flat entry per inner command, under the
43
+ * native event name (`SessionStart` -> `sessionStart`, `UserPromptSubmit` ->
44
+ * `beforeSubmitPrompt`, `Stop` -> `stop`). Native entries are kept as they are.
45
+ *
46
+ * @param {object} configuration A parsed Cursor hooks file.
47
+ * @returns {object} The configuration with `version` set and only native entries.
48
+ */
49
+ export function nativeCursorHooksConfiguration(configuration) {
50
+ const { version, hooks, ...otherFields } = configuration && typeof configuration === 'object' ? configuration : {};
51
+ const allHooksByEvent = hooks && typeof hooks === 'object' ? hooks : {};
52
+ const nativeHooks = {};
53
+ for (const [eventName, allEntries] of Object.entries(allHooksByEvent)) {
54
+ const nativeEventName = CURSOR_NATIVE_EVENT_NAME_BY_LEGACY_NAME[eventName] ?? eventName;
55
+ if (!Array.isArray(allEntries)) {
56
+ nativeHooks[nativeEventName] = allEntries;
57
+ continue;
58
+ }
59
+ const nativeEntries = allEntries.flatMap(eachEntry => (
60
+ isLegacyGroupEntry(eachEntry) ? nativeEntriesFromLegacyGroup(eachEntry, nativeEventName) : [eachEntry]
61
+ ));
62
+ nativeHooks[nativeEventName] = [...(nativeHooks[nativeEventName] ?? []), ...nativeEntries];
63
+ }
64
+ return { version: version ?? CURSOR_HOOKS_SCHEMA_VERSION, ...otherFields, hooks: nativeHooks };
65
+ }
66
+
67
+ function commandOf(entry) {
68
+ return entry && typeof entry.command === 'string' ? entry.command.replace(/\\/g, '/') : '';
69
+ }
70
+
71
+ /**
72
+ * Return the native configuration without the entries one package module owns.
73
+ *
74
+ * @param {object} configuration A parsed Cursor hooks file.
75
+ * @param {(forwardSlashedCommand: string) => boolean} isOwnedCommand True for a command the caller owns.
76
+ * @returns {object} The native configuration; an event left empty by the removal is dropped.
77
+ */
78
+ export function withoutOwnedCursorHooks(configuration, isOwnedCommand) {
79
+ const nativeConfiguration = nativeCursorHooksConfiguration(configuration);
80
+ const keptHooks = {};
81
+ for (const [eventName, allEntries] of Object.entries(nativeConfiguration.hooks)) {
82
+ if (!Array.isArray(allEntries)) {
83
+ keptHooks[eventName] = allEntries;
84
+ continue;
85
+ }
86
+ const keptEntries = allEntries.filter(eachEntry => !isOwnedCommand(commandOf(eachEntry)));
87
+ if (keptEntries.length > 0 || allEntries.length === 0) keptHooks[eventName] = keptEntries;
88
+ }
89
+ return { ...nativeConfiguration, hooks: keptHooks };
90
+ }
91
+
92
+ /**
93
+ * Return the native configuration with exactly one copy of the caller's entries.
94
+ *
95
+ * @param {object} configuration A parsed Cursor hooks file.
96
+ * @param {(forwardSlashedCommand: string) => boolean} isOwnedCommand True for a command the caller owns.
97
+ * @param {Record<string, object[]>} allEntriesByEvent The caller's entries per native event.
98
+ * @returns {object} The merged native configuration.
99
+ */
100
+ export function withOwnedCursorHooks(configuration, isOwnedCommand, allEntriesByEvent) {
101
+ const cleaned = withoutOwnedCursorHooks(configuration, isOwnedCommand);
102
+ const mergedHooks = { ...cleaned.hooks };
103
+ for (const [eventName, allEntries] of Object.entries(allEntriesByEvent)) {
104
+ mergedHooks[eventName] = [...(Array.isArray(mergedHooks[eventName]) ? mergedHooks[eventName] : []), ...allEntries];
105
+ }
106
+ return { ...cleaned, hooks: mergedHooks };
107
+ }
108
+
109
+ /**
110
+ * Apply one transform to `<cursorDirectory>/hooks.json` and write it when the text changes.
111
+ *
112
+ * @param {string} cursorDirectory The Cursor home.
113
+ * @param {(configuration: object) => object} transform Maps the parsed file to the new one.
114
+ * @param {{createWhenMissing?: boolean}} [options] Whether a missing file is created.
115
+ * @returns {string|null} The hooks.json path when this call changed it.
116
+ */
117
+ export function updateCursorHooksFile(cursorDirectory, transform, options = {}) {
118
+ const hooksPath = join(cursorDirectory, CURSOR_HOOKS_FILE_NAME);
119
+ const fileExists = existsSync(hooksPath);
120
+ if (!fileExists && !options.createWhenMissing) return null;
121
+ const currentText = fileExists ? readFileSync(hooksPath, 'utf8') : null;
122
+ const current = currentText === null ? {} : JSON.parse(currentText.replace(/^\uFEFF/, ''));
123
+ const updatedText = `${JSON.stringify(transform(current), null, 2)}\n`;
124
+ if (updatedText === currentText) return null;
125
+ mkdirSync(cursorDirectory, { recursive: true });
126
+ writeFileSync(hooksPath, updatedText);
127
+ return hooksPath;
128
+ }
@@ -0,0 +1,78 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import {
8
+ nativeCursorHooksConfiguration,
9
+ updateCursorHooksFile,
10
+ withOwnedCursorHooks,
11
+ withoutOwnedCursorHooks,
12
+ } from './cursor-native-hooks.mjs';
13
+
14
+ const LEGACY_SESSION_GROUP = {
15
+ matcher: 'startup|resume|clear|compact',
16
+ hooks: [{ type: 'command', command: "'C:\\Users\\dev\\.cursor\\plugins\\deepstack\\hooks\\session-start.sh' cursor" }],
17
+ };
18
+ const LEGACY_PROMPT_GROUP = {
19
+ matcher: '',
20
+ hooks: [{ type: 'command', command: 'python3 /home/dev/.agents/hooks/session/deepen_first_prompt.py', timeout: 10 }],
21
+ };
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');
24
+
25
+ test('Claude-style groups become flat native entries under the Cursor event names', () => {
26
+ const native = nativeCursorHooksConfiguration({
27
+ hooks: { SessionStart: [LEGACY_SESSION_GROUP], UserPromptSubmit: [LEGACY_PROMPT_GROUP], PreToolUse: [USER_TOOL_GROUP] },
28
+ });
29
+ assert.deepEqual(native, {
30
+ version: 1,
31
+ hooks: {
32
+ sessionStart: [{ command: LEGACY_SESSION_GROUP.hooks[0].command }],
33
+ beforeSubmitPrompt: [{ command: LEGACY_PROMPT_GROUP.hooks[0].command, timeout: 10 }],
34
+ preToolUse: [{ command: './audit.sh', matcher: 'Shell' }],
35
+ },
36
+ });
37
+ assert.equal(JSON.stringify(native).includes('"hooks":[{"type"'), false);
38
+ });
39
+
40
+ test('native entries and other fields stay, and the result is stable', () => {
41
+ const configuration = { version: 1, note: 'keep', hooks: { stop: [{ command: './audit.sh', loop_limit: 3 }] } };
42
+ assert.deepEqual(nativeCursorHooksConfiguration(configuration), configuration);
43
+ assert.deepEqual(nativeCursorHooksConfiguration(nativeCursorHooksConfiguration(configuration)), configuration);
44
+ });
45
+
46
+ test('owned entries collapse to one copy and removal drops only owned entries', () => {
47
+ const legacyFile = {
48
+ version: 1,
49
+ hooks: {
50
+ SessionStart: [LEGACY_SESSION_GROUP, LEGACY_SESSION_GROUP, LEGACY_SESSION_GROUP],
51
+ UserPromptSubmit: [LEGACY_PROMPT_GROUP],
52
+ stop: [{ command: './user-stop.sh' }],
53
+ },
54
+ };
55
+ const ownedEntries = {
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 }],
58
+ };
59
+ const merged = withOwnedCursorHooks(legacyFile, isOwned, ownedEntries);
60
+ assert.deepEqual(merged.hooks, { ...ownedEntries, stop: [{ command: './user-stop.sh' }] });
61
+ assert.deepEqual(withOwnedCursorHooks(merged, isOwned, ownedEntries), merged);
62
+ assert.deepEqual(withoutOwnedCursorHooks(merged, isOwned), { version: 1, hooks: { stop: [{ command: './user-stop.sh' }] } });
63
+ });
64
+
65
+ test('the file update writes only on change, strips a BOM, and creates a file only when asked', () => {
66
+ const cursorDirectory = mkdtempSync(join(tmpdir(), 'cdev-cursor-native-'));
67
+ try {
68
+ assert.equal(updateCursorHooksFile(cursorDirectory, configuration => configuration), null);
69
+ const hooksPath = updateCursorHooksFile(cursorDirectory, nativeCursorHooksConfiguration, { createWhenMissing: true });
70
+ assert.deepEqual(JSON.parse(readFileSync(hooksPath, 'utf8')), { version: 1, hooks: {} });
71
+ writeFileSync(hooksPath, `\uFEFF${JSON.stringify({ hooks: { Stop: [LEGACY_PROMPT_GROUP] } })}`);
72
+ updateCursorHooksFile(cursorDirectory, nativeCursorHooksConfiguration);
73
+ assert.equal(JSON.parse(readFileSync(hooksPath, 'utf8')).hooks.stop.length, 1);
74
+ assert.equal(updateCursorHooksFile(cursorDirectory, nativeCursorHooksConfiguration), null);
75
+ } finally {
76
+ rmSync(cursorDirectory, { recursive: true, force: true });
77
+ }
78
+ });
@@ -179,3 +179,32 @@ export const ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS = new Set([
179
179
  CODEX_NATIVE_ROUTING_MATCHER,
180
180
  CODEX_SUBAGENT_PROMPT_MATCHER,
181
181
  ]);
182
+ export const ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES = new Set([
183
+ 'open_tasks_stop_gate.py',
184
+ ]);
185
+ export const CURSOR_HOOKS_FILE_NAME = 'hooks.json';
186
+ export const CURSOR_HOOKS_SCHEMA_VERSION = 1;
187
+ export const CURSOR_HOOK_TIMEOUT_SECONDS = 10;
188
+ export const CURSOR_GUIDANCE_HOOK_RELATIVE_PATH = Object.freeze(['session', 'cursor_agents_guidance.py']);
189
+ export const CURSOR_OPEN_TASKS_HOOK_RELATIVE_PATH = Object.freeze(['blocking', 'open_tasks_stop_gate.py']);
190
+ export const CURSOR_SESSION_START_EVENT = 'sessionStart';
191
+ export const CURSOR_BEFORE_SUBMIT_PROMPT_EVENT = 'beforeSubmitPrompt';
192
+ export const CURSOR_STOP_EVENT = 'stop';
193
+ export const CURSOR_NATIVE_EVENT_NAME_BY_LEGACY_NAME = Object.freeze({
194
+ SessionStart: CURSOR_SESSION_START_EVENT,
195
+ SessionEnd: 'sessionEnd',
196
+ UserPromptSubmit: CURSOR_BEFORE_SUBMIT_PROMPT_EVENT,
197
+ Stop: CURSOR_STOP_EVENT,
198
+ SubagentStop: 'subagentStop',
199
+ PreToolUse: 'preToolUse',
200
+ PostToolUse: 'postToolUse',
201
+ PreCompact: 'preCompact',
202
+ });
203
+ export const ALL_CURSOR_LIFECYCLE_EVENT_NAMES = new Set([
204
+ CURSOR_SESSION_START_EVENT,
205
+ 'sessionEnd',
206
+ CURSOR_BEFORE_SUBMIT_PROMPT_EVENT,
207
+ CURSOR_STOP_EVENT,
208
+ 'subagentStop',
209
+ 'preCompact',
210
+ ]);
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import {
@@ -7,6 +7,12 @@ import {
7
7
  marketplacePluginsPlan,
8
8
  shouldInstallMarketplacePlugin,
9
9
  } from './install-pstack-plugin.mjs';
10
+ import {
11
+ CURSOR_BEFORE_SUBMIT_PROMPT_EVENT,
12
+ CURSOR_HOOK_TIMEOUT_SECONDS,
13
+ CURSOR_SESSION_START_EVENT,
14
+ } from './install-constants.mjs';
15
+ import { updateCursorHooksFile, withOwnedCursorHooks, withoutOwnedCursorHooks } from './cursor-native-hooks.mjs';
10
16
 
11
17
  export const DEEPSTACK_MARKETPLACE_REPOSITORY = 'jl-cmd-projects/DeepStack';
12
18
  export const DEEPSTACK_MARKETPLACE_NAME = 'DeepStack';
@@ -19,7 +25,11 @@ export const DEEPSTACK_SOURCE_VARIABLE = 'CDE_DEEPSTACK_SOURCE';
19
25
  export const DEEPSTACK_CURSOR_DIRECTORY_NAME = 'plugins/deepstack';
20
26
  export const DEEPSTACK_SKILL_RELATIVE_PATH = 'skills/deepstack-mode/SKILL.md';
21
27
  export const DEEPSTACK_SESSION_SCRIPT_RELATIVE_PATH = 'hooks/session-start.sh';
28
+ export const DEEPSTACK_SESSION_POWERSHELL_RELATIVE_PATH = 'hooks/session-start.ps1';
29
+ export const DEEPSTACK_CURSOR_SESSION_COMMAND_MARKER = 'plugins/deepstack/hooks/session-start.';
22
30
  export const DEEPSTACK_SESSION_RUNTIME = 'cursor';
31
+ const WINDOWS_POWERSHELL_FILE_PREFIX = 'powershell -NoProfile -ExecutionPolicy Bypass -File';
32
+ const DEFAULT_PYTHON_COMMAND = 'python3';
23
33
  export const DEEPSTACK_FIRST_TASK_HOOK_NAME = 'deepen_first_prompt.py';
24
34
  export const DEEPSTACK_CODEX_MODEL_SHEET_NAME = 'deepstack-models.md';
25
35
 
@@ -49,7 +59,7 @@ function quoteCommandPath(pathValue) {
49
59
  * Read the Claude, Codex, and Cursor actions one DeepStack install performs.
50
60
  *
51
61
  * Claude and Codex use the marketplace verbs. Cursor has no plugin command, so
52
- * its action is a copy into the Cursor home plus two hook registrations.
62
+ * its action is a copy into the Cursor home plus two native hook registrations.
53
63
  *
54
64
  * @returns {{claude: object, codex: object, cursor: {
55
65
  * pluginDirectoryName: string,
@@ -127,48 +137,76 @@ export function resolveDeepStackPluginSource(options = {}) {
127
137
  }
128
138
 
129
139
  /**
130
- * Add the SessionStart and first-task hook groups when they are absent.
140
+ * True for a Cursor hook command the DeepStack install owns.
141
+ *
142
+ * @param {string} forwardSlashedCommand A hook command with forward slashes.
143
+ * @returns {boolean} True for the DeepStack session hook or the first-task hook.
144
+ */
145
+ export function isDeepStackCursorCommand(forwardSlashedCommand) {
146
+ return forwardSlashedCommand.includes(DEEPSTACK_CURSOR_SESSION_COMMAND_MARKER)
147
+ || forwardSlashedCommand.includes(DEEPSTACK_FIRST_TASK_HOOK_NAME);
148
+ }
149
+
150
+ /**
151
+ * Return the native Cursor configuration with one DeepStack session entry and one first-task entry.
152
+ *
153
+ * Earlier installs wrote these as Claude-style groups, which Cursor never ran;
154
+ * every owned copy, old or new, is replaced by one native entry each.
131
155
  *
132
156
  * @param {object} configuration The parsed Cursor hooks file.
133
157
  * @param {{sessionCommand: string, firstTaskCommand: string}} commands The two commands.
134
- * @returns {object} The configuration with both groups present.
158
+ * @returns {object} The configuration with both native entries present once.
135
159
  */
136
160
  export function mergeDeepStackCursorHooks(configuration, commands) {
137
- const hooks = configuration.hooks && typeof configuration.hooks === 'object'
138
- ? configuration.hooks
139
- : {};
140
- const sessionGroups = Array.isArray(hooks.SessionStart) ? [...hooks.SessionStart] : [];
141
- const promptGroups = Array.isArray(hooks.UserPromptSubmit) ? [...hooks.UserPromptSubmit] : [];
142
- const listed = (groups, needle) => JSON.stringify(groups).includes(needle);
143
- if (!listed(sessionGroups, DEEPSTACK_SESSION_SCRIPT_RELATIVE_PATH)) {
144
- sessionGroups.push({
145
- matcher: 'startup|resume|clear|compact',
146
- hooks: [{ type: 'command', command: commands.sessionCommand }],
147
- });
148
- }
149
- if (!listed(promptGroups, DEEPSTACK_FIRST_TASK_HOOK_NAME)) {
150
- promptGroups.push({
151
- matcher: '',
152
- hooks: [{ type: 'command', command: commands.firstTaskCommand, timeout: 10 }],
153
- });
161
+ return withOwnedCursorHooks(configuration, isDeepStackCursorCommand, {
162
+ [CURSOR_SESSION_START_EVENT]: [{ command: commands.sessionCommand, timeout: CURSOR_HOOK_TIMEOUT_SECONDS }],
163
+ [CURSOR_BEFORE_SUBMIT_PROMPT_EVENT]: [{ command: commands.firstTaskCommand, timeout: CURSOR_HOOK_TIMEOUT_SECONDS }],
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Remove the DeepStack session and first-task entries from the Cursor hooks file.
169
+ *
170
+ * @param {string} cursorDirectory The Cursor home.
171
+ * @returns {string|null} The hooks.json path when this call changed it.
172
+ */
173
+ export function removeDeepStackCursorHooks(cursorDirectory) {
174
+ return updateCursorHooksFile(
175
+ cursorDirectory,
176
+ configuration => withoutOwnedCursorHooks(configuration, isDeepStackCursorCommand),
177
+ );
178
+ }
179
+
180
+ /**
181
+ * Build the Cursor command for the DeepStack session script.
182
+ *
183
+ * Cursor runs hook commands through PowerShell on Windows, where a `.sh` path
184
+ * does not run, so Windows uses the plugin's `session-start.ps1`.
185
+ *
186
+ * @param {string} pluginDirectory The plugin copy in the Cursor home.
187
+ * @param {(pathValue: string) => string} formatPathArgument The platform path quoting.
188
+ * @param {string} platform The Node platform name.
189
+ * @returns {string} The session command.
190
+ */
191
+ export function deepStackCursorSessionCommand(pluginDirectory, formatPathArgument, platform) {
192
+ const plan = deepStackInstallPlan().cursor;
193
+ const powerShellScript = join(pluginDirectory, DEEPSTACK_SESSION_POWERSHELL_RELATIVE_PATH);
194
+ if (platform === 'win32' && existsSync(powerShellScript)) {
195
+ return `${WINDOWS_POWERSHELL_FILE_PREFIX} ${formatPathArgument(powerShellScript)} ${plan.sessionRuntimeArgument}`;
154
196
  }
155
- return {
156
- ...configuration,
157
- hooks: {
158
- ...hooks,
159
- SessionStart: sessionGroups,
160
- UserPromptSubmit: promptGroups,
161
- },
162
- };
197
+ return `${formatPathArgument(join(pluginDirectory, plan.sessionScriptRelativePath))} ${plan.sessionRuntimeArgument}`;
163
198
  }
164
199
 
165
200
  /**
166
- * Copy the plugin into the Cursor home and register both hooks.
201
+ * Copy the plugin into the Cursor home and register both hooks in Cursor's native format.
167
202
  *
168
203
  * @param {object} options Copy inputs.
169
204
  * @param {string} options.cursorDirectory The Cursor home, usually ~/.cursor.
170
205
  * @param {string|null} options.pluginSourceDirectory The plugin tree to copy.
171
206
  * @param {string} options.firstTaskHookPath Absolute path of deepen_first_prompt.py.
207
+ * @param {string} [options.pythonCommand] The interpreter the installer detected.
208
+ * @param {(pathValue: string) => string} [options.formatPathArgument] The platform path quoting.
209
+ * @param {string} [options.platform] The Node platform name.
172
210
  * @returns {{status: string, pluginDirectory: string|null, hooksPath: string|null, warning: string|null}} The outcome.
173
211
  */
174
212
  export function installDeepStackCursor(options) {
@@ -187,15 +225,18 @@ export function installDeepStackCursor(options) {
187
225
  const pluginDirectory = join(options.cursorDirectory, plan.pluginDirectoryName);
188
226
  mkdirSync(options.cursorDirectory, { recursive: true });
189
227
  cpSync(options.pluginSourceDirectory, pluginDirectory, { recursive: true, force: true });
190
- const sessionCommand = `${quoteCommandPath(join(pluginDirectory, plan.sessionScriptRelativePath))} ${plan.sessionRuntimeArgument}`;
191
- const firstTaskCommand = `python3 ${quoteCommandPath(options.firstTaskHookPath)}`;
192
- const hooksPath = join(options.cursorDirectory, 'hooks.json');
193
- const current = existsSync(hooksPath)
194
- ? JSON.parse(readFileSync(hooksPath, 'utf8'))
195
- : { hooks: {} };
196
- const merged = mergeDeepStackCursorHooks(current, { sessionCommand, firstTaskCommand });
197
- writeFileSync(hooksPath, `${JSON.stringify(merged, null, 2)}\n`);
198
- return { status: 'installed', pluginDirectory, hooksPath, warning: null };
228
+ const formatPathArgument = options.formatPathArgument ?? quoteCommandPath;
229
+ const pythonCommand = options.pythonCommand ?? DEFAULT_PYTHON_COMMAND;
230
+ const commands = {
231
+ sessionCommand: deepStackCursorSessionCommand(pluginDirectory, formatPathArgument, options.platform ?? process.platform),
232
+ firstTaskCommand: `${pythonCommand} ${formatPathArgument(options.firstTaskHookPath)}`,
233
+ };
234
+ updateCursorHooksFile(
235
+ options.cursorDirectory,
236
+ configuration => mergeDeepStackCursorHooks(configuration, commands),
237
+ { createWhenMissing: true },
238
+ );
239
+ return { status: 'installed', pluginDirectory, hooksPath: join(options.cursorDirectory, 'hooks.json'), warning: null };
199
240
  }
200
241
 
201
242
  /**
@@ -220,6 +261,8 @@ export function seedCodexDeepStackModels(codexHome) {
220
261
  * @param {string} [options.codexHome] The Codex home.
221
262
  * @param {string} [options.cursorDirectory] The Cursor home.
222
263
  * @param {string} [options.firstTaskHookPath] Absolute path of the hook the first-task command runs.
264
+ * @param {string} [options.pythonCommand] The interpreter the installer detected.
265
+ * @param {(pathValue: string) => string} [options.formatPathArgument] The platform path quoting.
223
266
  * @param {string} [options.sourceDirectory] A local plugin tree for the Cursor copy.
224
267
  * @param {string[]} [options.argumentList] Command-line arguments, for the opt-out flag.
225
268
  * @param {Record<string, string|undefined>} [options.environment] The process environment.
@@ -254,6 +297,8 @@ export function installDeepStackForHosts(options = {}, dependencies = {}) {
254
297
  cursorDirectory: options.cursorDirectory,
255
298
  pluginSourceDirectory: sourceDirectory,
256
299
  firstTaskHookPath: options.firstTaskHookPath,
300
+ pythonCommand: options.pythonCommand,
301
+ formatPathArgument: options.formatPathArgument,
257
302
  });
258
303
  if (marketplace.hosts.some(host => host.host === 'codex' && host.status === 'installed') && options.codexHome) {
259
304
  seedCodexDeepStackModels(options.codexHome);