claude-dev-env 8.87.1 → 8.88.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.
- package/AGENTS.md +1 -0
- package/bin/cursor-agents-guidance.mjs +94 -0
- package/bin/cursor-agents-guidance.test.mjs +94 -0
- package/bin/cursor-native-hooks.mjs +128 -0
- package/bin/cursor-native-hooks.test.mjs +78 -0
- package/bin/install-constants.mjs +29 -0
- package/bin/install-deepstack-plugin.mjs +84 -39
- package/bin/install-deepstack-plugin.test.mjs +51 -11
- package/bin/install.cursor-rules.test.mjs +65 -2
- package/bin/install.mjs +35 -0
- package/bin/install.test.mjs +10 -5
- package/docs/agents-guidance/asks.md +49 -0
- package/hooks/blocking/open_tasks_stop_gate.py +210 -0
- package/hooks/blocking/open_tasks_transcript.py +270 -0
- package/hooks/blocking/test_open_tasks_stop_gate.py +297 -0
- package/hooks/blocking/test_open_tasks_transcript.py +54 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/cursor_agents_guidance_constants.py +6 -0
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +72 -0
- package/hooks/session/cursor_agents_guidance.py +54 -0
- package/hooks/session/test_cursor_agents_guidance.py +24 -0
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +6 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
DEEPSTACK_PLUGIN_SPEC,
|
|
10
10
|
deepStackCloneUrl,
|
|
11
11
|
deepStackInstallPlan,
|
|
12
|
+
deepStackCursorSessionCommand,
|
|
12
13
|
installDeepStackCursor,
|
|
13
14
|
installDeepStackForHosts,
|
|
14
15
|
mergeDeepStackCursorHooks,
|
|
16
|
+
removeDeepStackCursorHooks,
|
|
15
17
|
} from './install-deepstack-plugin.mjs';
|
|
16
18
|
import { marketplacePluginsPlan, shouldInstallMarketplacePlugin } from './install-pstack-plugin.mjs';
|
|
17
19
|
|
|
@@ -32,18 +34,47 @@ test('Claude and Codex plans name jl-cmd-projects/DeepStack', () => {
|
|
|
32
34
|
assert.throws(() => marketplacePluginsPlan([DEEPSTACK_PLUGIN_SPEC], 'cursor'), /cursor/);
|
|
33
35
|
});
|
|
34
36
|
|
|
35
|
-
test('cursor hooks
|
|
36
|
-
const
|
|
37
|
+
test('cursor hooks are one native sessionStart entry and one native beforeSubmitPrompt entry', () => {
|
|
38
|
+
const legacySessionGroup = {
|
|
39
|
+
matcher: 'startup|resume|clear|compact',
|
|
40
|
+
hooks: [{ type: 'command', command: "'C:\\Users\\dev\\.cursor\\plugins\\deepstack\\hooks\\session-start.sh' cursor" }],
|
|
41
|
+
};
|
|
42
|
+
const commands = {
|
|
37
43
|
sessionCommand: "'/cursor/plugins/deepstack/hooks/session-start.sh' cursor",
|
|
38
44
|
firstTaskCommand: "python3 '/claude/hooks/session/deepen_first_prompt.py'",
|
|
45
|
+
};
|
|
46
|
+
const merged = mergeDeepStackCursorHooks({
|
|
47
|
+
hooks: {
|
|
48
|
+
SessionStart: [legacySessionGroup, legacySessionGroup, legacySessionGroup],
|
|
49
|
+
UserPromptSubmit: [{ matcher: '', hooks: [{ type: 'command', command: 'python3 /old/deepen_first_prompt.py', timeout: 10 }] }],
|
|
50
|
+
preToolUse: [],
|
|
51
|
+
},
|
|
52
|
+
}, commands);
|
|
53
|
+
assert.deepEqual(merged, {
|
|
54
|
+
version: 1,
|
|
55
|
+
hooks: {
|
|
56
|
+
sessionStart: [{ command: commands.sessionCommand, timeout: 10 }],
|
|
57
|
+
beforeSubmitPrompt: [{ command: commands.firstTaskCommand, timeout: 10 }],
|
|
58
|
+
preToolUse: [],
|
|
59
|
+
},
|
|
39
60
|
});
|
|
40
|
-
assert.deepEqual(
|
|
41
|
-
const text = JSON.stringify(merged);
|
|
42
|
-
assert.match(text, /session-start\.sh/);
|
|
43
|
-
assert.match(text, /deepen_first_prompt\.py/);
|
|
61
|
+
assert.deepEqual(mergeDeepStackCursorHooks(merged, commands), merged);
|
|
44
62
|
});
|
|
45
63
|
|
|
46
|
-
test('the
|
|
64
|
+
test('the Windows session command runs the PowerShell script, other platforms the shell script', () => {
|
|
65
|
+
const root = mkdtempSync(join(tmpdir(), 'deepstack-cursor-'));
|
|
66
|
+
try {
|
|
67
|
+
mkdirSync(join(root, 'hooks'), { recursive: true });
|
|
68
|
+
writeFileSync(join(root, 'hooks', 'session-start.ps1'), 'param()\n');
|
|
69
|
+
const forwardSlashed = pathValue => `"${pathValue.replace(/\\/g, '/')}"`;
|
|
70
|
+
assert.match(deepStackCursorSessionCommand(root, forwardSlashed, 'win32'), /^powershell -NoProfile -ExecutionPolicy Bypass -File ".*hooks\/session-start\.ps1" cursor$/);
|
|
71
|
+
assert.match(deepStackCursorSessionCommand(root, forwardSlashed, 'linux'), /hooks\/session-start\.sh" cursor$/);
|
|
72
|
+
} finally {
|
|
73
|
+
rmSync(root, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('the cursor copy writes the skill and both native hook commands, and removal takes out only those', () => {
|
|
47
78
|
const root = mkdtempSync(join(tmpdir(), 'deepstack-cursor-'));
|
|
48
79
|
try {
|
|
49
80
|
const source = join(root, 'source');
|
|
@@ -52,10 +83,14 @@ test('the cursor copy writes the skill and both hook commands', () => {
|
|
|
52
83
|
writeFileSync(join(source, 'skills', 'deepstack-mode', 'SKILL.md'), 'DeepStack intake\n');
|
|
53
84
|
writeFileSync(join(source, 'hooks', 'session-start.sh'), '#!/bin/sh\n');
|
|
54
85
|
const cursorDirectory = join(root, '.cursor');
|
|
86
|
+
mkdirSync(cursorDirectory, { recursive: true });
|
|
87
|
+
writeFileSync(join(cursorDirectory, 'hooks.json'), JSON.stringify({ version: 1, hooks: { stop: [{ command: './user-stop.sh' }] } }));
|
|
55
88
|
const outcome = installDeepStackCursor({
|
|
56
89
|
cursorDirectory,
|
|
57
90
|
pluginSourceDirectory: source,
|
|
58
91
|
firstTaskHookPath: join(root, '.agents', 'hooks', 'session', 'deepen_first_prompt.py'),
|
|
92
|
+
pythonCommand: '/usr/bin/python3.12',
|
|
93
|
+
platform: 'linux',
|
|
59
94
|
});
|
|
60
95
|
assert.equal(outcome.status, 'installed');
|
|
61
96
|
assert.equal(
|
|
@@ -63,10 +98,15 @@ test('the cursor copy writes the skill and both hook commands', () => {
|
|
|
63
98
|
'DeepStack intake\n',
|
|
64
99
|
);
|
|
65
100
|
const hooks = JSON.parse(readFileSync(join(cursorDirectory, 'hooks.json'), 'utf8'));
|
|
66
|
-
|
|
67
|
-
assert.
|
|
68
|
-
assert.match(
|
|
69
|
-
assert.
|
|
101
|
+
assert.equal(hooks.version, 1);
|
|
102
|
+
assert.equal(hooks.hooks.sessionStart.length, 1);
|
|
103
|
+
assert.match(hooks.hooks.sessionStart[0].command, /session-start\.sh' cursor$/);
|
|
104
|
+
assert.match(hooks.hooks.beforeSubmitPrompt[0].command, /^\/usr\/bin\/python3\.12 '.*deepen_first_prompt\.py'$/);
|
|
105
|
+
removeDeepStackCursorHooks(cursorDirectory);
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
JSON.parse(readFileSync(join(cursorDirectory, 'hooks.json'), 'utf8')),
|
|
108
|
+
{ version: 1, hooks: { stop: [{ command: './user-stop.sh' }] } },
|
|
109
|
+
);
|
|
70
110
|
} finally {
|
|
71
111
|
rmSync(root, { recursive: true, force: true });
|
|
72
112
|
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { tmpdir } from 'node:os';
|
|
13
13
|
import { dirname, join } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
16
16
|
import {
|
|
17
17
|
resolveInstallRoot,
|
|
18
18
|
} from './resolve-install-root.mjs';
|
|
@@ -135,7 +135,10 @@ test('seeds one editable policy and one native Codex routing hook', () => {
|
|
|
135
135
|
);
|
|
136
136
|
assert.equal(firstSpawnPromptGroups.length, 1);
|
|
137
137
|
assert.match(firstSpawnPromptGroups[0].hooks[0].command, /skill_loaded_reminder\.py/);
|
|
138
|
-
assert.deepEqual(Object.keys(firstCodexHooks.hooks), ['PreToolUse', 'UserPromptSubmit']);
|
|
138
|
+
assert.deepEqual(Object.keys(firstCodexHooks.hooks), ['PreToolUse', 'UserPromptSubmit', 'Stop']);
|
|
139
|
+
const allCodexStopCommands = firstCodexHooks.hooks.Stop.flatMap(group => group.hooks.map(hook => hook.command));
|
|
140
|
+
assert.equal(allCodexStopCommands.length, 1, 'Codex gets the open tasks Stop gate and no other Stop hook');
|
|
141
|
+
assert.match(allCodexStopCommands[0], /open_tasks_stop_gate\.py/);
|
|
139
142
|
assert.ok(
|
|
140
143
|
firstCodexHooks.hooks.UserPromptSubmit.some(group =>
|
|
141
144
|
group.hooks.some(hook => String(hook.command).includes('deepen_first_prompt.py')),
|
|
@@ -303,6 +306,10 @@ test('Codex reinstall removes omitted package hooks and preserves user configura
|
|
|
303
306
|
.map(eachHook => eachHook.command);
|
|
304
307
|
assert.ok(allPackagePromptCommands.some(eachCommand => eachCommand.includes('deepen_first_prompt.py')));
|
|
305
308
|
delete migratedHooks.hooks.UserPromptSubmit;
|
|
309
|
+
const allPackageStopGroups = migratedHooks.hooks.Stop.filter(eachGroup =>
|
|
310
|
+
eachGroup.hooks.some(eachHook => eachHook.command.includes('open_tasks_stop_gate.py')));
|
|
311
|
+
assert.equal(allPackageStopGroups.length, 1);
|
|
312
|
+
migratedHooks.hooks.Stop = migratedHooks.hooks.Stop.filter(eachGroup => !allPackageStopGroups.includes(eachGroup));
|
|
306
313
|
assert.deepEqual(migratedHooks, { enabled: false, metadata: 'keep-file', hooks: expectedUserHooks });
|
|
307
314
|
assert.equal(readFileSync(configPath, 'utf8'), disabledConfig);
|
|
308
315
|
const firstInstall = readFileSync(codexHooksPath, 'utf8');
|
|
@@ -518,3 +525,59 @@ test('a blocked shared rule destination fails the install and keeps the existing
|
|
|
518
525
|
}
|
|
519
526
|
});
|
|
520
527
|
|
|
528
|
+
|
|
529
|
+
test('a full install writes only native Cursor hooks: AGENTS.md on sessionStart and the open tasks gate on stop; uninstall removes exactly those', () => {
|
|
530
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-cursor-guidance-'));
|
|
531
|
+
try {
|
|
532
|
+
const cursorHooksPath = join(homeDirectory, DEFAULT_CURSOR_DIRECTORY_NAME, 'hooks.json');
|
|
533
|
+
mkdirSync(dirname(cursorHooksPath), { recursive: true });
|
|
534
|
+
const userStopEntry = { command: './user-stop.sh' };
|
|
535
|
+
writeFileSync(cursorHooksPath, JSON.stringify({
|
|
536
|
+
hooks: {
|
|
537
|
+
SessionStart: [{
|
|
538
|
+
matcher: 'startup|resume|clear|compact',
|
|
539
|
+
hooks: [{ type: 'command', command: "python3 '/old/hooks/session/cursor_agents_guidance.py' AGENTS.md" }],
|
|
540
|
+
}, {
|
|
541
|
+
matcher: 'startup|resume|clear|compact',
|
|
542
|
+
hooks: [{ type: 'command', command: "'/old/.cursor/plugins/deepstack/hooks/session-start.sh' cursor" }],
|
|
543
|
+
}],
|
|
544
|
+
UserPromptSubmit: [{
|
|
545
|
+
matcher: '',
|
|
546
|
+
hooks: [{ type: 'command', command: "python3 '/old/hooks/session/deepen_first_prompt.py'", timeout: 10 }],
|
|
547
|
+
}],
|
|
548
|
+
stop: [userStopEntry],
|
|
549
|
+
},
|
|
550
|
+
}));
|
|
551
|
+
runInstaller(homeDirectory, []);
|
|
552
|
+
const cursorHooks = JSON.parse(readFileSync(cursorHooksPath, 'utf8'));
|
|
553
|
+
assert.equal(cursorHooks.version, 1);
|
|
554
|
+
for (const [eventName, allEntries] of Object.entries(cursorHooks.hooks)) {
|
|
555
|
+
assert.match(eventName, /^[a-z]/, `${eventName} is a Cursor event name`);
|
|
556
|
+
for (const eachEntry of allEntries) assert.equal(typeof eachEntry.command, 'string', 'every entry is a flat native entry');
|
|
557
|
+
}
|
|
558
|
+
const guidanceCommands = cursorHooks.hooks.sessionStart
|
|
559
|
+
.map(entry => entry.command)
|
|
560
|
+
.filter(command => command.includes('cursor_agents_guidance.py'));
|
|
561
|
+
assert.equal(guidanceCommands.length, 1);
|
|
562
|
+
const guidancePath = join(homeDirectory, '.agents', 'AGENTS.md');
|
|
563
|
+
assert.ok(guidanceCommands[0].replace(/\\/g, '/').includes(guidancePath.replace(/\\/g, '/')));
|
|
564
|
+
assert.match(readFileSync(guidancePath, 'utf8'), /\*\*Asks\*\*/);
|
|
565
|
+
const sessionOutput = execSync(guidanceCommands[0], { input: '{}', encoding: 'utf8' });
|
|
566
|
+
assert.equal(JSON.parse(sessionOutput).additional_context, readFileSync(guidancePath, 'utf8'));
|
|
567
|
+
const gateCommands = cursorHooks.hooks.stop.map(entry => entry.command).filter(command => command.includes('open_tasks_stop_gate.py'));
|
|
568
|
+
assert.equal(gateCommands.length, 1);
|
|
569
|
+
assert.deepEqual(cursorHooks.hooks.stop.filter(entry => entry.command === userStopEntry.command), [userStopEntry]);
|
|
570
|
+
assert.equal(execSync(gateCommands[0], { input: '{"conversation_id": "c-1", "status": "completed"}', encoding: 'utf8' }), '');
|
|
571
|
+
assert.deepEqual(cursorHooks.hooks.beforeSubmitPrompt, [{ command: "python3 '/old/hooks/session/deepen_first_prompt.py'", timeout: 10 }]);
|
|
572
|
+
|
|
573
|
+
runInstaller(homeDirectory, ['--uninstall']);
|
|
574
|
+
const remainingHooks = JSON.parse(readFileSync(cursorHooksPath, 'utf8'));
|
|
575
|
+
assert.equal(JSON.stringify(remainingHooks).includes('cursor_agents_guidance.py'), false);
|
|
576
|
+
assert.equal(JSON.stringify(remainingHooks).includes('open_tasks_stop_gate.py'), false);
|
|
577
|
+
assert.equal(JSON.stringify(remainingHooks).includes('deepen_first_prompt.py'), false);
|
|
578
|
+
assert.equal(JSON.stringify(remainingHooks).includes('plugins/deepstack/hooks/session-start'), false);
|
|
579
|
+
assert.deepEqual(remainingHooks.hooks.stop, [userStopEntry]);
|
|
580
|
+
} finally {
|
|
581
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
582
|
+
}
|
|
583
|
+
});
|
package/bin/install.mjs
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
WINDOWS_PYTHON_LAUNCHER_COMMAND,
|
|
52
52
|
PYTHON_PROBE_TIMEOUT_MILLISECONDS,
|
|
53
53
|
ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS,
|
|
54
|
+
ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES,
|
|
54
55
|
} from './install-constants.mjs';
|
|
55
56
|
import {
|
|
56
57
|
PSTACK_PLUGIN_SPEC,
|
|
@@ -63,8 +64,13 @@ import {
|
|
|
63
64
|
import {
|
|
64
65
|
DEEPSTACK_PLUGIN_SPEC,
|
|
65
66
|
installDeepStackForHosts,
|
|
67
|
+
removeDeepStackCursorHooks,
|
|
66
68
|
} from './install-deepstack-plugin.mjs';
|
|
67
69
|
import { seedCodexPstackModels } from './seed-codex-pstack-models.mjs';
|
|
70
|
+
import {
|
|
71
|
+
removeCursorAgentsGuidanceHooks,
|
|
72
|
+
writeCursorAgentsGuidanceHooks,
|
|
73
|
+
} from './cursor-agents-guidance.mjs';
|
|
68
74
|
import {
|
|
69
75
|
codexSolSettingSnapshotPaths,
|
|
70
76
|
removeCodexSolSetting,
|
|
@@ -2214,6 +2220,18 @@ function mergeHooksAtPath(
|
|
|
2214
2220
|
return groupCount;
|
|
2215
2221
|
}
|
|
2216
2222
|
|
|
2223
|
+
function isCodexNativeStopHook(hook) {
|
|
2224
|
+
const scriptName = String(hook?.command ?? '').replace(/\\/g, '/').split('/').pop();
|
|
2225
|
+
return ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES.has(scriptName);
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
function selectCodexNativeStopGroups(stopGroups) {
|
|
2229
|
+
if (!Array.isArray(stopGroups)) return [];
|
|
2230
|
+
return stopGroups
|
|
2231
|
+
.map(group => ({ ...group, hooks: (group?.hooks ?? []).filter(isCodexNativeStopHook) }))
|
|
2232
|
+
.filter(group => group.hooks.length > 0);
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2217
2235
|
function selectCodexNativeHooksConfig(hooksConfig) {
|
|
2218
2236
|
const preToolUseGroups = hooksConfig.hooks?.PreToolUse;
|
|
2219
2237
|
const userPromptSubmitGroups = hooksConfig.hooks?.UserPromptSubmit;
|
|
@@ -2224,6 +2242,7 @@ function selectCodexNativeHooksConfig(hooksConfig) {
|
|
|
2224
2242
|
? preToolUseGroups.filter(group => ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS.has(group?.matcher))
|
|
2225
2243
|
: [],
|
|
2226
2244
|
UserPromptSubmit: Array.isArray(userPromptSubmitGroups) ? userPromptSubmitGroups : [],
|
|
2245
|
+
Stop: selectCodexNativeStopGroups(hooksConfig.hooks?.Stop),
|
|
2227
2246
|
},
|
|
2228
2247
|
};
|
|
2229
2248
|
}
|
|
@@ -2919,6 +2938,18 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
2919
2938
|
}
|
|
2920
2939
|
const packageGuidanceText = selectedGroups ? '' : readFileSync(join(PACKAGE_ROOT, 'AGENTS.md'), 'utf8');
|
|
2921
2940
|
const allPackageGuidancePaths = [join(CLAUDE_HOME, 'AGENTS.md'), join(AGENTS_HOME, 'AGENTS.md')];
|
|
2941
|
+
if (!selectedGroups) {
|
|
2942
|
+
const cursorGuidanceHooksPath = writeCursorAgentsGuidanceHooks({
|
|
2943
|
+
cursorDirectory: INSTALL_ROOT_RESOLUTION.cursorInstallDirectory,
|
|
2944
|
+
hooksDirectory: INSTALL_ROOT_RESOLUTION.hooksInstallDirectory,
|
|
2945
|
+
guidancePath: join(AGENTS_HOME, 'AGENTS.md'),
|
|
2946
|
+
pythonCommand,
|
|
2947
|
+
formatPathArgument: commandArgumentFromPath,
|
|
2948
|
+
});
|
|
2949
|
+
if (cursorGuidanceHooksPath) {
|
|
2950
|
+
console.log(` \u2713 ${cursorGuidanceHooksPath} (Cursor agents guidance and open tasks hooks)`);
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2922
2953
|
if (!selectedGroups && shouldInstallMarketplacePlugin(PSTACK_PLUGIN_SPEC)) {
|
|
2923
2954
|
const pstackPlugin = installPstackPluginForHosts();
|
|
2924
2955
|
summary.pstackPlugin = pstackPlugin;
|
|
@@ -2953,6 +2984,8 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
2953
2984
|
'session',
|
|
2954
2985
|
'deepen_first_prompt.py',
|
|
2955
2986
|
),
|
|
2987
|
+
pythonCommand,
|
|
2988
|
+
formatPathArgument: commandArgumentFromPath,
|
|
2956
2989
|
});
|
|
2957
2990
|
summary.deepStackPlugin = deepStackPlugin;
|
|
2958
2991
|
printPluginHostOutcomes([DEEPSTACK_PLUGIN_SPEC], deepStackPlugin);
|
|
@@ -3399,6 +3432,8 @@ function executeUninstallPlan(plan, helpers = {}) {
|
|
|
3399
3432
|
pruneManagedHooksFromHostConfiguration(CODEX_HOOKS_CONFIGURATION_PATH, 2);
|
|
3400
3433
|
}
|
|
3401
3434
|
removeRetiredSessionContinuityHooks(['codex', 'cursor']);
|
|
3435
|
+
removeCursorAgentsGuidanceHooks(INSTALL_ROOT_RESOLUTION.cursorInstallDirectory);
|
|
3436
|
+
removeDeepStackCursorHooks(INSTALL_ROOT_RESOLUTION.cursorInstallDirectory);
|
|
3402
3437
|
throwIfFault(FAULT_PHASES.AFTER_SETTINGS_WRITE);
|
|
3403
3438
|
|
|
3404
3439
|
unsetGlobalGitHooksPathIfOurs();
|
package/bin/install.test.mjs
CHANGED
|
@@ -3208,11 +3208,16 @@ test('a full install adds DeepStack for Claude, Codex, and Cursor', t => {
|
|
|
3208
3208
|
'DeepStack intake\n',
|
|
3209
3209
|
);
|
|
3210
3210
|
const hooks = JSON.parse(readFileSync(join(sandbox.homeDirectory, '.cursor', 'hooks.json'), 'utf8'));
|
|
3211
|
-
|
|
3212
|
-
assert.
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3211
|
+
assert.equal(hooks.version, 1);
|
|
3212
|
+
assert.deepEqual(
|
|
3213
|
+
hooks.hooks.sessionStart.filter(entry => entry.command.includes('session-start.sh')).length,
|
|
3214
|
+
1,
|
|
3215
|
+
);
|
|
3216
|
+
assert.equal(
|
|
3217
|
+
hooks.hooks.beforeSubmitPrompt.filter(entry => entry.command.includes('deepen_first_prompt.py')).length,
|
|
3218
|
+
1,
|
|
3219
|
+
);
|
|
3220
|
+
assert.equal(JSON.stringify(hooks).includes('"hooks":[{'), false, 'no Claude-style group entries');
|
|
3216
3221
|
const sheet = readFileSync(join(sandbox.homeDirectory, '.codex', 'deepstack-models.md'), 'utf8');
|
|
3217
3222
|
assert.match(sheet, /^session hook: on$/m);
|
|
3218
3223
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Asks
|
|
2
|
+
|
|
3
|
+
**When this applies:** Every ask from the user, in every session and every host (Claude Code, Codex, Cursor).
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
- Work every ask as soon as it arrives, and keep working it until it is done with proof you saw. Asks have no due dates and no deadlines. Every ask is worked now, continuously.
|
|
8
|
+
- Stop working an ask only when it is done with proof, or when it is blocked on something only the user can give: a decision, an approval, an account, or a credential. Put a blocked ask in front of the user in the same reply.
|
|
9
|
+
- "Later", "next time", "follow-up", and "parked" are not outcomes.
|
|
10
|
+
- Record each ask as one item in the session tracker: `TaskCreate`, `TodoWrite`, or the Codex `update_plan` tool. Mark an item completed only after you saw the proof.
|
|
11
|
+
- A turn ends only when every item is completed, or the reply ends with one line per item that is not:
|
|
12
|
+
- `Blocked: <item> - <what the user must give>`
|
|
13
|
+
- `Open: <item> - <the step you take next>`
|
|
14
|
+
- An `Open:` line is a report. Take its next step at once.
|
|
15
|
+
|
|
16
|
+
## Asks ledger
|
|
17
|
+
|
|
18
|
+
When the host keeps an asks ledger with `ledger.py` and `asks_check.py`, record each ask in it too:
|
|
19
|
+
|
|
20
|
+
- Record work as it happens with `ledger.py update --id <id> --progress --note "<what was done>"`. A status, owner, or note edit without `--progress` is not progress.
|
|
21
|
+
- Close an ask only with `ledger.py close --id <id> --proof <proof>`.
|
|
22
|
+
- Before you report or end a turn, run `asks_check.py --agent <your agent name> --self <your agent name>`. Act on every flagged row now, or put it in front of the user in this reply.
|
|
23
|
+
|
|
24
|
+
## Enforcement
|
|
25
|
+
|
|
26
|
+
The installer registers `open_tasks_stop_gate.py` as a Stop hook for each host:
|
|
27
|
+
|
|
28
|
+
- Claude Code: `hooks.json`. A block returns the open items as the reason, and the session keeps working.
|
|
29
|
+
- Codex: `$CODEX_HOME/hooks.json`. Codex turns the reason into a continuation prompt.
|
|
30
|
+
- Cursor: a native `stop` entry in `~/.cursor/hooks.json`. The reason comes back as `followup_message`, the next user message. A stop with status `aborted` or `error` passes, so a turn the user cancels stays silent.
|
|
31
|
+
|
|
32
|
+
The gate reads the task list from the task store, the transcript TaskCreate, TaskUpdate, and TodoWrite calls, or the Codex `update_plan` calls. It ends the hold when each open item is named on a `Blocked:` or `Open:` line of the closing reply.
|
|
33
|
+
|
|
34
|
+
## Cursor
|
|
35
|
+
|
|
36
|
+
Cursor keeps user rules in its app settings. The installer adds a native `sessionStart` entry, `cursor_agents_guidance.py`, that feeds the installed `AGENTS.md` to each Cursor session as `additional_context`.
|
|
37
|
+
|
|
38
|
+
What has been seen to work, in an interactive `cursor-agent` session (version 2026.10.16, Windows):
|
|
39
|
+
|
|
40
|
+
- The `sessionStart` entry runs, and the session can quote the Asks line. Without it, the session cannot.
|
|
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
|
+
- A turn cancelled with Esc sends `stop` with status `aborted`, then `error`. The gate stays silent for both.
|
|
43
|
+
|
|
44
|
+
Limits:
|
|
45
|
+
|
|
46
|
+
- `cursor-agent -p` (print mode) does not send `stop` or `beforeSubmitPrompt`, so the gate never runs there.
|
|
47
|
+
- The Cursor editor has not been tested.
|
|
48
|
+
- Cursor also runs the hooks in `~/.claude/settings.json` by default. When Claude Code hooks from this package are installed in the same home, the gate runs twice at each Cursor stop. A stop hook that comes from that file has no follow-up limit in Cursor, so the gate passes once `loop_count` reaches 5.
|
|
49
|
+
- One entry in the Claude Code format inside `~/.cursor/hooks.json` makes Cursor skip every hook in that file. The installer writes only native entries and converts older ones.
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stop hook that keeps a session working while its task list has unreported open items.
|
|
3
|
+
|
|
4
|
+
The session's task list comes from the first of these that exists::
|
|
5
|
+
|
|
6
|
+
~/.claude/tasks/<task list id>/*.json (TaskCreate / TaskUpdate store)
|
|
7
|
+
transcript TaskCreate / TaskUpdate calls (replayed in order)
|
|
8
|
+
transcript TodoWrite input.todos (last call wins)
|
|
9
|
+
transcript Codex update_plan arguments (last call wins)
|
|
10
|
+
|
|
11
|
+
An item with status ``pending`` or ``in_progress`` is open. The turn may end
|
|
12
|
+
only when the closing reply names every open item on a report line::
|
|
13
|
+
|
|
14
|
+
allow: Blocked: Rotate the deploy key - the owner must approve the org secret
|
|
15
|
+
allow: - Open: #3 - waits on the release checks
|
|
16
|
+
block: (reply never mentions "Rotate the deploy key")
|
|
17
|
+
|
|
18
|
+
A block sends the open items back as the reason, so the model keeps working or
|
|
19
|
+
reports them. Claude Code ends the turn after eight blocks in a row. Codex
|
|
20
|
+
turns the reason into a continuation prompt. A Cursor payload (it carries
|
|
21
|
+
``conversation_id``) also gets the reason as ``followup_message``, which
|
|
22
|
+
Cursor submits as the next user message; Cursor sends no closing reply, so
|
|
23
|
+
the gate reads the last assistant text from the transcript. A Cursor payload
|
|
24
|
+
whose ``status`` is ``aborted`` or ``error`` is allowed, because Cursor would
|
|
25
|
+
submit the follow-up after the user stopped the run, and so is one whose
|
|
26
|
+
``loop_count`` shows five follow-ups already sent. A TodoWrite call with
|
|
27
|
+
``merge`` set updates the earlier items by id, and a Cursor ``CallDynamicTool``
|
|
28
|
+
call that wraps TodoWrite counts as that TodoWrite call.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
40
|
+
if hooks_root_directory not in sys.path:
|
|
41
|
+
sys.path.insert(0, hooks_root_directory)
|
|
42
|
+
blocking_directory = str(Path(__file__).resolve().parent)
|
|
43
|
+
if blocking_directory not in sys.path:
|
|
44
|
+
sys.path.insert(0, blocking_directory)
|
|
45
|
+
|
|
46
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
47
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
48
|
+
from open_tasks_transcript import TaskItem, parsed_object, replayed_transcript
|
|
49
|
+
from hooks_constants.open_tasks_stop_gate_constants import (
|
|
50
|
+
ALLOW_EXIT_CODE,
|
|
51
|
+
BLOCK_DECISION,
|
|
52
|
+
BLOCK_REASON_PREFIX,
|
|
53
|
+
BLOCK_REASON_SUFFIX,
|
|
54
|
+
CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE,
|
|
55
|
+
CLAUDE_HOME_DIRECTORY_NAME,
|
|
56
|
+
CURSOR_COMPLETED_STATUS,
|
|
57
|
+
CURSOR_CONVERSATION_ID_KEY,
|
|
58
|
+
CURSOR_FOLLOWUP_LIMIT,
|
|
59
|
+
CURSOR_FOLLOWUP_MESSAGE_KEY,
|
|
60
|
+
CURSOR_LOOP_COUNT_KEY,
|
|
61
|
+
CURSOR_STATUS_KEY,
|
|
62
|
+
DECISION_KEY,
|
|
63
|
+
HOOK_EVENT_NAME,
|
|
64
|
+
INCOMPLETE_STATUSES,
|
|
65
|
+
ITEM_LINE_TEMPLATE,
|
|
66
|
+
LAST_ASSISTANT_MESSAGE_KEY,
|
|
67
|
+
REASON_KEY,
|
|
68
|
+
REPORT_LINE_PATTERN,
|
|
69
|
+
REPORTED_TEXT_GROUP_INDEX,
|
|
70
|
+
SESSION_ID_KEY,
|
|
71
|
+
TASK_FILE_GLOB,
|
|
72
|
+
TASK_ID_KEY,
|
|
73
|
+
TASK_LIST_ID_ENVIRONMENT_VARIABLE,
|
|
74
|
+
TASK_NUMBER_REFERENCE_TEMPLATE,
|
|
75
|
+
TASK_STATUS_KEY,
|
|
76
|
+
TASK_SUBJECT_KEY,
|
|
77
|
+
TASKS_DIRECTORY_NAME,
|
|
78
|
+
TRANSCRIPT_PATH_KEY,
|
|
79
|
+
UTF8_ENCODING,
|
|
80
|
+
WORD_BOUNDARY_PATTERN,
|
|
81
|
+
WORD_SEPARATOR,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _claude_home() -> Path:
|
|
86
|
+
configured_directory = os.environ.get(CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE)
|
|
87
|
+
if configured_directory:
|
|
88
|
+
return Path(configured_directory)
|
|
89
|
+
return Path.home() / CLAUDE_HOME_DIRECTORY_NAME
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _task_file_item(task_file: Path) -> TaskItem | None:
|
|
93
|
+
try:
|
|
94
|
+
task_text = task_file.read_text(encoding=UTF8_ENCODING)
|
|
95
|
+
except OSError:
|
|
96
|
+
return None
|
|
97
|
+
all_task_fields = parsed_object(task_text)
|
|
98
|
+
if all_task_fields is None:
|
|
99
|
+
return None
|
|
100
|
+
return TaskItem(
|
|
101
|
+
str(all_task_fields.get(TASK_ID_KEY, task_file.stem)),
|
|
102
|
+
str(all_task_fields.get(TASK_SUBJECT_KEY, "")),
|
|
103
|
+
str(all_task_fields.get(TASK_STATUS_KEY, "")),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def task_store_items(session_id: object) -> list[TaskItem] | None:
|
|
108
|
+
"""Return the task store items for the session, or None when no store exists."""
|
|
109
|
+
task_list_id = os.environ.get(TASK_LIST_ID_ENVIRONMENT_VARIABLE) or session_id
|
|
110
|
+
if not isinstance(task_list_id, str) or not task_list_id:
|
|
111
|
+
return None
|
|
112
|
+
task_directory = _claude_home() / TASKS_DIRECTORY_NAME / task_list_id
|
|
113
|
+
if not task_directory.is_dir():
|
|
114
|
+
return None
|
|
115
|
+
all_items = (_task_file_item(each_file) for each_file in sorted(task_directory.glob(TASK_FILE_GLOB)))
|
|
116
|
+
return [each_item for each_item in all_items if each_item is not None]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _normalized(text: str) -> str:
|
|
120
|
+
return WORD_SEPARATOR.join(text.lower().split())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _reported_texts(reply_text: str) -> list[str]:
|
|
124
|
+
all_matches = (re.match(REPORT_LINE_PATTERN, each_line, re.IGNORECASE) for each_line in reply_text.splitlines())
|
|
125
|
+
return [_normalized(each_match.group(REPORTED_TEXT_GROUP_INDEX)) for each_match in all_matches if each_match]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_reported(task_item: TaskItem, reply_text: str) -> bool:
|
|
129
|
+
"""Return True when a Blocked: or Open: line of the reply names the item."""
|
|
130
|
+
subject_text = _normalized(task_item.subject)
|
|
131
|
+
number_pattern = re.escape(TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=task_item.item_id)) + WORD_BOUNDARY_PATTERN
|
|
132
|
+
return any(
|
|
133
|
+
(subject_text and subject_text in each_text) or re.search(number_pattern, each_text)
|
|
134
|
+
for each_text in _reported_texts(reply_text)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def unreported_open_items(all_hook_input_fields: dict[str, object]) -> list[TaskItem]:
|
|
139
|
+
"""Return the open task items the closing reply does not report."""
|
|
140
|
+
replay = replayed_transcript(all_hook_input_fields.get(TRANSCRIPT_PATH_KEY))
|
|
141
|
+
all_items = task_store_items(all_hook_input_fields.get(SESSION_ID_KEY))
|
|
142
|
+
if all_items is None:
|
|
143
|
+
all_items = replay.items() or []
|
|
144
|
+
reply_value = all_hook_input_fields.get(LAST_ASSISTANT_MESSAGE_KEY)
|
|
145
|
+
reply_text = reply_value if isinstance(reply_value, str) else replay.last_assistant_text
|
|
146
|
+
return [
|
|
147
|
+
each_item
|
|
148
|
+
for each_item in all_items
|
|
149
|
+
if each_item.status in INCOMPLETE_STATUSES and not is_reported(each_item, reply_text)
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def block_reason_for(all_open_items: list[TaskItem]) -> str:
|
|
154
|
+
"""Return the block reason that lists each open item."""
|
|
155
|
+
all_item_lines = (
|
|
156
|
+
ITEM_LINE_TEMPLATE.format(subject=each_item.subject or TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=each_item.item_id))
|
|
157
|
+
for each_item in all_open_items
|
|
158
|
+
)
|
|
159
|
+
return BLOCK_REASON_PREFIX + "".join(all_item_lines) + BLOCK_REASON_SUFFIX
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def block_output_for(all_hook_input_fields: dict[str, object], block_reason: str) -> dict[str, str]:
|
|
163
|
+
"""Return the block output; a Cursor payload also gets the reason as ``followup_message``."""
|
|
164
|
+
block_output = {DECISION_KEY: BLOCK_DECISION, REASON_KEY: block_reason}
|
|
165
|
+
if all_hook_input_fields.get(CURSOR_CONVERSATION_ID_KEY):
|
|
166
|
+
block_output[CURSOR_FOLLOWUP_MESSAGE_KEY] = block_reason
|
|
167
|
+
return block_output
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def is_unfinished_cursor_stop(all_hook_input_fields: dict[str, object]) -> bool:
|
|
171
|
+
"""Return True for a Cursor stop whose run was aborted or ended in an error."""
|
|
172
|
+
if not all_hook_input_fields.get(CURSOR_CONVERSATION_ID_KEY):
|
|
173
|
+
return False
|
|
174
|
+
stop_status = all_hook_input_fields.get(CURSOR_STATUS_KEY)
|
|
175
|
+
return stop_status is not None and stop_status != CURSOR_COMPLETED_STATUS
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def has_spent_cursor_followups(all_hook_input_fields: dict[str, object]) -> bool:
|
|
179
|
+
"""Return True for a Cursor stop that already sent the follow-up limit.
|
|
180
|
+
|
|
181
|
+
Cursor caps native stop follow-ups at five per script, but a copy it imports
|
|
182
|
+
from ``~/.claude/settings.json`` has no cap, so the gate stops at five itself.
|
|
183
|
+
"""
|
|
184
|
+
if not all_hook_input_fields.get(CURSOR_CONVERSATION_ID_KEY):
|
|
185
|
+
return False
|
|
186
|
+
loop_count = all_hook_input_fields.get(CURSOR_LOOP_COUNT_KEY)
|
|
187
|
+
return isinstance(loop_count, int) and loop_count >= CURSOR_FOLLOWUP_LIMIT
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def main() -> int:
|
|
191
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
192
|
+
if hook_input is None or is_unfinished_cursor_stop(hook_input) or has_spent_cursor_followups(hook_input):
|
|
193
|
+
return ALLOW_EXIT_CODE
|
|
194
|
+
all_open_items = unreported_open_items(hook_input)
|
|
195
|
+
if not all_open_items:
|
|
196
|
+
return ALLOW_EXIT_CODE
|
|
197
|
+
block_reason = block_reason_for(all_open_items)
|
|
198
|
+
reply_value = hook_input.get(LAST_ASSISTANT_MESSAGE_KEY)
|
|
199
|
+
log_hook_block(
|
|
200
|
+
Path(__file__).name,
|
|
201
|
+
HOOK_EVENT_NAME,
|
|
202
|
+
block_reason,
|
|
203
|
+
offending_input_preview=reply_value if isinstance(reply_value, str) else None,
|
|
204
|
+
)
|
|
205
|
+
sys.stdout.write(json.dumps(block_output_for(hook_input, block_reason)))
|
|
206
|
+
return ALLOW_EXIT_CODE
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
sys.exit(main())
|