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.
@@ -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 keep the session hook and the first-task gate', () => {
36
- const merged = mergeDeepStackCursorHooks({ hooks: { PreToolUse: [] } }, {
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(Object.keys(merged.hooks).sort(), ['PreToolUse', 'SessionStart', 'UserPromptSubmit']);
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 cursor copy writes the skill and both hook commands', () => {
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
- const text = JSON.stringify(hooks);
67
- assert.match(text, /session-start\.sh/);
68
- assert.match(text, /deepen_first_prompt\.py/);
69
- assert.equal(hooks.hooks.SessionStart[0].hooks[0].command.endsWith(' cursor'), true);
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();
@@ -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
- const hookText = JSON.stringify(hooks);
3212
- assert.match(hookText, /session-start\.sh/);
3213
- assert.match(hookText, /SessionStart/);
3214
- assert.match(hookText, /deepen_first_prompt\.py/);
3215
- assert.match(hookText, /UserPromptSubmit/);
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())