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
@@ -18,6 +18,7 @@ This family injects reminders and task guidance when an agent starts, resumes, s
18
18
  - `session/skill_loaded_reminder.py` runs on `PreToolUse` matcher `Agent|Task` at `10` seconds, `UserPromptSubmit` matcher empty at `10` seconds, `SessionStart` matcher `compact` at `10` seconds, and `SubagentStart` matcher `workflow-subagent` at `10` seconds in `hooks.json`.
19
19
  - `session/task_tool_prompt.py`, `session/working_style_prompt.py`, `session/advisor_rules_prompt.py`, and `session/orchestrator_auto_starter.py` run on `SessionStart`, matcher empty, timeout `10` seconds each in `hooks.json`.
20
20
  - `session/deepen_first_prompt.py` runs on `UserPromptSubmit`, matcher empty, timeout `10` seconds in `hooks.json`.
21
+ - In Cursor, `session/deepen_first_prompt.py` also runs on `beforeSubmitPrompt`. Cursor passes that event name to the `UserPromptSubmit` copy it imports from the Claude Code settings too. When both copies run, `session/deepen_turn_marker.py` creates the turn marker only when it does not exist yet, so one copy injects. A later prompt skips when either the transcript marker or the session marker exists, or when the transcript already has a user line in Claude Code's or Cursor's shape.
21
22
  - `session/artifact_template_pointer.py` runs on `PreToolUse` matcher `Artifact|Skill` at `10` seconds in `hooks.json`. It speaks only on an `Artifact` call with action `quickstart` or a `Skill` call that loads `artifact-design`.
22
23
  - `advisory/auto_mode_denial_quick_fix.py` runs on `PermissionDenied`, matcher `*`, timeout `10` seconds in `hooks.json`.
23
24
 
@@ -32,7 +33,7 @@ Preconditions:
32
33
  - **Working style.** Input is a SessionStart event. Run `python -m pytest packages/claude-dev-env/hooks/session/test_working_style_prompt.py -q`. The adjacent test observes the fixed context text.
33
34
  - **Advisor guidance.** Input is SessionStart with `advisorModel` set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_advisor_rules_prompt.py -q`. The adjacent test observes guidance only when the advisor setting is enabled.
34
35
  - **Orchestrator start.** Input is SessionStart with its opt-in flag set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_orchestrator_auto_starter.py -q`. The adjacent test observes an orchestrator directive.
35
- - **First-task deepen.** Input is a first UserPromptSubmit task. Run `python -m pytest packages/claude-dev-env/hooks/session/test_deepen_first_prompt.py -q`. The adjacent test observes a skip table, a refusal of the paste path, and a rewrite that carries the SCOPE, EXAMPLES, and ZOOM-IN BLOCKER lines.
36
+ - **First-task deepen.** Input is a first UserPromptSubmit task. Run `python -m pytest packages/claude-dev-env/hooks/session/test_deepen_first_prompt.py -q`. The adjacent test observes a skip table, a refusal of the paste path, a rewrite that carries the SCOPE, EXAMPLES, and ZOOM-IN BLOCKER lines, and one injection when two copies see the same Cursor prompt. `python -m pytest packages/claude-dev-env/hooks/session/test_deepen_turn_marker.py -q` observes that only the first marker claim wins.
36
37
  - **Artifact template pointer.** Input is an `Artifact` quickstart call. Run `python -m pytest packages/claude-dev-env/hooks/session/test_artifact_template_pointer.py -q`. The adjacent test observes the status flowchart path first, the html-plan path ahead of the template path in `additionalContext`, and silence on a publish call or another skill.
37
38
  - **Denial quick fix.** Input is a `PermissionDenied` event with a bracketed rule label and a classifier verdict. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_auto_mode_denial_quick_fix.py -q`. The adjacent test observes the named rule, the allow entry, and the PowerShell block; a denial with no verdict gets a note and no block.
38
39
 
package/hooks/hooks.json CHANGED
@@ -379,6 +379,11 @@
379
379
  "type": "command",
380
380
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/final_reply_gate.py",
381
381
  "timeout": 10
382
+ },
383
+ {
384
+ "type": "command",
385
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/open_tasks_stop_gate.py",
386
+ "timeout": 10
382
387
  }
383
388
  ]
384
389
  }
@@ -0,0 +1,6 @@
1
+ """Constants for cursor_agents_guidance.py, the Cursor SessionStart hook."""
2
+
3
+ GUIDANCE_PATH_ARGUMENT_INDEX = 1
4
+ CURSOR_CONTEXT_KEY = "additional_context"
5
+ UTF8_ENCODING = "utf-8"
6
+ EMPTY_OUTPUT = "{}"
@@ -45,6 +45,9 @@ __all__ = [
45
45
  "DEEPEN_SLASH_COMMAND",
46
46
  "DEEPEN_TIMEOUT_SECONDS",
47
47
  "DEEPEN_TURN_MARKER_SUFFIX",
48
+ "DEEPEN_SESSION_MARKER_PREFIX",
49
+ "ALL_FIRST_PROMPT_EVENT_NAMES",
50
+ "CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME",
48
51
  "HOOK_EVENT_NAME_KEY",
49
52
  "HOOK_SPECIFIC_OUTPUT_KEY",
50
53
  "JSONL_ROLE_KEY",
@@ -66,10 +69,15 @@ DEEPEN_MARKER: str = "CDE_DEEPEN:v1"
66
69
  DEEPEN_AGENT_MARKER: str = "CDE_DEEPEN_AGENT"
67
70
  DEEPEN_SLASH_COMMAND: str = "/deepen"
68
71
  DEEPEN_TURN_MARKER_SUFFIX: str = ".cde-deepen-turn1"
72
+ DEEPEN_SESSION_MARKER_PREFIX: str = "cde-deepen-"
69
73
  DEEPEN_TIMEOUT_SECONDS: int = 10
70
74
  DEEPEN_COMMAND_TIMEOUT_MARGIN_SECONDS: int = 2
71
75
  DEEPEN_COMMAND_TIMEOUT_SECONDS: int = DEEPEN_TIMEOUT_SECONDS - DEEPEN_COMMAND_TIMEOUT_MARGIN_SECONDS
72
76
  USER_PROMPT_SUBMIT_EVENT_NAME: str = "UserPromptSubmit"
77
+ CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME: str = "beforeSubmitPrompt"
78
+ ALL_FIRST_PROMPT_EVENT_NAMES: frozenset[str] = frozenset(
79
+ {USER_PROMPT_SUBMIT_EVENT_NAME, CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME}
80
+ )
73
81
  PROMPT_FIELD_NAME: str = "prompt"
74
82
  TRANSCRIPT_PATH_FIELD_NAME: str = "transcript_path"
75
83
  SESSION_ID_FIELD_NAME: str = "session_id"
@@ -0,0 +1,72 @@
1
+ """Constants for the open tasks Stop gate."""
2
+
3
+ HOOK_EVENT_NAME = "Stop"
4
+ SESSION_ID_KEY = "session_id"
5
+ TRANSCRIPT_PATH_KEY = "transcript_path"
6
+ LAST_ASSISTANT_MESSAGE_KEY = "last_assistant_message"
7
+ DECISION_KEY = "decision"
8
+ BLOCK_DECISION = "block"
9
+ REASON_KEY = "reason"
10
+ ALLOW_EXIT_CODE = 0
11
+ UTF8_ENCODING = "utf-8"
12
+ WORD_SEPARATOR = " "
13
+ TASK_LIST_ID_ENVIRONMENT_VARIABLE = "CLAUDE_CODE_TASK_LIST_ID"
14
+ CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE = "CLAUDE_CONFIG_DIR"
15
+ CLAUDE_HOME_DIRECTORY_NAME = ".claude"
16
+ TASKS_DIRECTORY_NAME = "tasks"
17
+ TASK_FILE_GLOB = "*.json"
18
+ TASK_ID_KEY = "id"
19
+ TASK_SUBJECT_KEY = "subject"
20
+ TASK_STATUS_KEY = "status"
21
+ PENDING_STATUS = "pending"
22
+ INCOMPLETE_STATUSES = frozenset({PENDING_STATUS, "in_progress"})
23
+ RECORD_TYPE_KEY = "type"
24
+ MESSAGE_KEY = "message"
25
+ CONTENT_KEY = "content"
26
+ TEXT_KEY = "text"
27
+ TOOL_NAME_KEY = "name"
28
+ TOOL_INPUT_KEY = "input"
29
+ TOOL_USE_ID_KEY = "tool_use_id"
30
+ TODOS_KEY = "todos"
31
+ TODO_CONTENT_KEY = "content"
32
+ TODO_ID_KEY = "id"
33
+ TODO_MERGE_KEY = "merge"
34
+ ROLE_KEY = "role"
35
+ ASSISTANT_ROLE = "assistant"
36
+ CURSOR_CONVERSATION_ID_KEY = "conversation_id"
37
+ CURSOR_FOLLOWUP_MESSAGE_KEY = "followup_message"
38
+ CURSOR_STATUS_KEY = "status"
39
+ CURSOR_COMPLETED_STATUS = "completed"
40
+ CURSOR_LOOP_COUNT_KEY = "loop_count"
41
+ CURSOR_FOLLOWUP_LIMIT = 5
42
+ REPLY_TEXT_SEPARATOR = "\n"
43
+ TASK_UPDATE_ID_KEY = "taskId"
44
+ TODO_WRITE_TOOL_NAME = "TodoWrite"
45
+ DYNAMIC_TOOL_CALL_NAME = "CallDynamicTool"
46
+ DYNAMIC_TOOL_NAME_KEY = "toolName"
47
+ DYNAMIC_TOOL_ARGUMENTS_KEY = "arguments"
48
+ TASK_CREATE_TOOL_NAME = "TaskCreate"
49
+ TASK_UPDATE_TOOL_NAME = "TaskUpdate"
50
+ TOOL_USE_BLOCK_TYPE = "tool_use"
51
+ TOOL_RESULT_BLOCK_TYPE = "tool_result"
52
+ CODEX_RESPONSE_ITEM_TYPE = "response_item"
53
+ CODEX_FUNCTION_CALL_TYPE = "function_call"
54
+ CODEX_PAYLOAD_KEY = "payload"
55
+ CODEX_ARGUMENTS_KEY = "arguments"
56
+ CODEX_PLAN_TOOL_NAME = "update_plan"
57
+ CODEX_PLAN_KEY = "plan"
58
+ CODEX_STEP_KEY = "step"
59
+ FIRST_ITEM_NUMBER = 1
60
+ TASK_CREATED_PATTERN = r"Task #(\d+) created"
61
+ CREATED_TASK_ID_GROUP_INDEX = 1
62
+ REPORT_LINE_PATTERN = r"^\s*(?:[-*]\s*)?(open|blocked)\b\s*[:\-]?\s*(.*)$"
63
+ REPORTED_TEXT_GROUP_INDEX = 2
64
+ WORD_BOUNDARY_PATTERN = r"\b"
65
+ TASK_NUMBER_REFERENCE_TEMPLATE = "#{task_id}"
66
+ ITEM_LINE_TEMPLATE = "\n- {subject}"
67
+ BLOCK_REASON_PREFIX = "The task list still has incomplete items that this reply does not report:"
68
+ BLOCK_REASON_SUFFIX = (
69
+ "\nKeep working each one now. End the reply with one line per item you cannot finish this turn:"
70
+ " 'Blocked: <item> - <what the user must give>' when only the user can unblock it, or"
71
+ " 'Open: <item> - <the step you take next>' when you are still working it."
72
+ )
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env python3
2
+ """Cursor sessionStart hook that puts the installed agent guidance into every session.
3
+
4
+ Cursor keeps user rules in its app settings, and it reads ``~/.cursor/rules``
5
+ only for a workspace inside the home directory, so no file under ``~/.cursor``
6
+ carries global guidance. The installer registers this hook as a native
7
+ ``sessionStart`` entry in ``~/.cursor/hooks.json`` with the installed
8
+ ``AGENTS.md`` path as its one argument. The hook prints that file as Cursor's
9
+ ``additional_context``, which Cursor adds to the session's context::
10
+
11
+ python3 cursor_agents_guidance.py ~/.agents/AGENTS.md
12
+ -> {"additional_context": "# Agent guidance ..."}
13
+
14
+ A missing or unreadable file prints ``{}`` so the session starts without it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
24
+ if hooks_root_directory not in sys.path:
25
+ sys.path.insert(0, hooks_root_directory)
26
+
27
+ from hooks_constants.cursor_agents_guidance_constants import (
28
+ CURSOR_CONTEXT_KEY,
29
+ EMPTY_OUTPUT,
30
+ GUIDANCE_PATH_ARGUMENT_INDEX,
31
+ UTF8_ENCODING,
32
+ )
33
+
34
+
35
+ def guidance_output(all_arguments: list[str]) -> str:
36
+ """Return the SessionStart output for the guidance file named in the arguments."""
37
+ if len(all_arguments) <= GUIDANCE_PATH_ARGUMENT_INDEX:
38
+ return EMPTY_OUTPUT
39
+ try:
40
+ guidance_text = Path(all_arguments[GUIDANCE_PATH_ARGUMENT_INDEX]).read_text(encoding=UTF8_ENCODING)
41
+ except OSError:
42
+ return EMPTY_OUTPUT
43
+ if not guidance_text.strip():
44
+ return EMPTY_OUTPUT
45
+ return json.dumps({CURSOR_CONTEXT_KEY: guidance_text})
46
+
47
+
48
+ def main() -> None:
49
+ sys.stdin.read()
50
+ sys.stdout.write(guidance_output(sys.argv))
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env python3
2
2
  """UserPromptSubmit hook: deepen the first interactive task before work starts.
3
3
 
4
+ Cursor runs it on ``beforeSubmitPrompt`` too, and passes that event name to
5
+ the copy it imports from the Claude Code settings, so both names count as a
6
+ first prompt. When both copies run, the turn marker lets one inject.
7
+
4
8
  Skip checks stay local. One bounded deepen call runs only for a first task.
5
9
  Claude settings and Codex cannot replace the prompt string, so this hook emits
6
10
  additionalContext. Claude desktop and CLI also ship a prompt.submit mod that
@@ -26,6 +30,7 @@ if _hooks_dir not in sys.path:
26
30
  from hooks_constants.cloud_session import is_cloud_session
27
31
  from hooks_constants.deepen_first_prompt_constants import (
28
32
  ADDITIONAL_CONTEXT_KEY,
33
+ ALL_FIRST_PROMPT_EVENT_NAMES,
29
34
  ALL_FOUR_QUESTION_STEMS,
30
35
  QUESTION_LINE_SEPARATOR,
31
36
  ALL_HELPER_REPORT_MARKERS,
@@ -45,7 +50,6 @@ from hooks_constants.deepen_first_prompt_constants import (
45
50
  DEEPEN_OUTPUT_REWRITE,
46
51
  DEEPEN_REWRITE_ENV_VAR,
47
52
  DEEPEN_SLASH_COMMAND,
48
- DEEPEN_TURN_MARKER_SUFFIX,
49
53
  HOOK_EVENT_NAME_KEY,
50
54
  HOOK_SPECIFIC_OUTPUT_KEY,
51
55
  JSONL_ROLE_KEY,
@@ -53,7 +57,6 @@ from hooks_constants.deepen_first_prompt_constants import (
53
57
  NONINTERACTIVE_CODEX_ENV_VAR,
54
58
  NONINTERACTIVE_PRINT_ENV_VAR,
55
59
  PROMPT_FIELD_NAME,
56
- SESSION_ID_FIELD_NAME,
57
60
  SKIP_DEEPEN_ENV_VAR,
58
61
  SKIP_DEEPEN_TRUE_VALUE,
59
62
  TRANSCRIPT_PATH_FIELD_NAME,
@@ -66,6 +69,7 @@ from hooks_constants.mod_handoff import is_mod_plugin_enabled
66
69
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
67
70
  from hooks_constants.subprocess_window import hidden_window_creation_flags
68
71
  from deepen_role_gate import refusal_reason
72
+ from deepen_turn_marker import claim_turn_marker, turn_marker_exists
69
73
 
70
74
 
71
75
  class SkipReason(StrEnum):
@@ -146,6 +150,8 @@ def _is_user_entry(all_entry_fields: dict[str, object] | None) -> bool:
146
150
  return False
147
151
  if all_entry_fields.get(JSONL_TYPE_KEY) == USER_TYPE_NAME:
148
152
  return True
153
+ if all_entry_fields.get(JSONL_ROLE_KEY) == USER_ROLE_NAME:
154
+ return True
149
155
  message = all_entry_fields.get("message")
150
156
  return isinstance(message, dict) and message.get(JSONL_ROLE_KEY) == USER_ROLE_NAME
151
157
 
@@ -160,32 +166,6 @@ def _transcript_has_prior_user_turn(transcript_path: object) -> bool:
160
166
  return False
161
167
 
162
168
 
163
- def _turn_marker_path(all_hook_fields: dict[str, object]) -> Path | None:
164
- transcript_path = all_hook_fields.get(TRANSCRIPT_PATH_FIELD_NAME)
165
- if isinstance(transcript_path, str) and transcript_path:
166
- return Path(transcript_path + DEEPEN_TURN_MARKER_SUFFIX)
167
- session_id = all_hook_fields.get(SESSION_ID_FIELD_NAME)
168
- if isinstance(session_id, str) and session_id:
169
- return Path(os.environ.get("TMPDIR", "/tmp")) / f"cde-deepen-{session_id}"
170
- return None
171
-
172
-
173
- def _turn_marker_exists(all_hook_fields: dict[str, object]) -> bool:
174
- marker_path = _turn_marker_path(all_hook_fields)
175
- return marker_path is not None and marker_path.is_file()
176
-
177
-
178
- def write_turn_marker(all_hook_fields: dict[str, object]) -> None:
179
- """Record that this session already deepened turn one."""
180
- marker_path = _turn_marker_path(all_hook_fields)
181
- if marker_path is None:
182
- return
183
- try:
184
- marker_path.write_text(DEEPEN_MARKER, encoding=UTF8_ENCODING)
185
- except OSError:
186
- return
187
-
188
-
189
169
  def _environment_skip_reason() -> SkipReason | None:
190
170
  if os.environ.get(SKIP_DEEPEN_ENV_VAR, "").strip() == SKIP_DEEPEN_TRUE_VALUE:
191
171
  return SkipReason.SKIP_FLAG
@@ -224,13 +204,13 @@ def _shape_skip_reason(prompt: str) -> SkipReason | None:
224
204
  def skip_reason_for(all_hook_fields: dict[str, object]) -> SkipReason | None:
225
205
  """Return the first skip that matches, or None when the gate should deepen."""
226
206
  event_name = all_hook_fields.get("hook_event_name")
227
- if event_name not in (USER_PROMPT_SUBMIT_EVENT_NAME, None, ""):
207
+ if event_name not in ALL_FIRST_PROMPT_EVENT_NAMES and event_name not in (None, ""):
228
208
  return SkipReason.LATER_TURN
229
209
  prompt = submitted_prompt_text(all_hook_fields)
230
210
  reason = _environment_skip_reason() or _marker_skip_reason(prompt) or _shape_skip_reason(prompt)
231
211
  if reason is not None:
232
212
  return reason
233
- if _turn_marker_exists(all_hook_fields) or _transcript_has_prior_user_turn(
213
+ if turn_marker_exists(all_hook_fields) or _transcript_has_prior_user_turn(
234
214
  all_hook_fields.get(TRANSCRIPT_PATH_FIELD_NAME)
235
215
  ):
236
216
  return SkipReason.LATER_TURN
@@ -388,7 +368,8 @@ def emit_decision(decision: DeepenDecision, all_hook_fields: dict[str, object])
388
368
  if output_mode == DEEPEN_OUTPUT_JSON:
389
369
  _print_refuse_json(decision.reason)
390
370
  return
391
- write_turn_marker(all_hook_fields)
371
+ if not claim_turn_marker(all_hook_fields):
372
+ return
392
373
  if output_mode == DEEPEN_OUTPUT_JSON:
393
374
  _print_deepen_json(decision)
394
375
  return
@@ -0,0 +1,75 @@
1
+ """Turn-one marker for the first-task deepen hook.
2
+
3
+ The marker records that a session already deepened its first task, so later
4
+ prompts stay untouched. A host can run two copies of the hook for one prompt:
5
+ Cursor runs its own ``beforeSubmitPrompt`` entry and the ``UserPromptSubmit``
6
+ entry it imports from the Claude Code settings, side by side. Each copy claims
7
+ the marker by creating the file only when it does not exist yet, so exactly
8
+ one copy wins and injects.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+ from hooks_constants.deepen_first_prompt_constants import (
18
+ DEEPEN_MARKER,
19
+ DEEPEN_SESSION_MARKER_PREFIX,
20
+ DEEPEN_TURN_MARKER_SUFFIX,
21
+ SESSION_ID_FIELD_NAME,
22
+ TRANSCRIPT_PATH_FIELD_NAME,
23
+ UTF8_ENCODING,
24
+ )
25
+
26
+
27
+ def _all_turn_marker_paths(all_hook_fields: dict[str, object]) -> list[Path]:
28
+ all_marker_paths: list[Path] = []
29
+ transcript_path = all_hook_fields.get(TRANSCRIPT_PATH_FIELD_NAME)
30
+ if isinstance(transcript_path, str) and transcript_path:
31
+ all_marker_paths.append(Path(transcript_path + DEEPEN_TURN_MARKER_SUFFIX))
32
+ session_id = all_hook_fields.get(SESSION_ID_FIELD_NAME)
33
+ if isinstance(session_id, str) and session_id:
34
+ all_marker_paths.append(Path(tempfile.gettempdir()) / f"{DEEPEN_SESSION_MARKER_PREFIX}{session_id}")
35
+ return all_marker_paths
36
+
37
+
38
+ def turn_marker_path(all_hook_fields: dict[str, object]) -> Path | None:
39
+ """Return the marker path for this session, or None when the payload names none.
40
+
41
+ The transcript path wins when the payload has one. Cursor sends no transcript
42
+ path before the first reply, so the session id names a file in the system
43
+ temp folder instead.
44
+ """
45
+ all_marker_paths = _all_turn_marker_paths(all_hook_fields)
46
+ return all_marker_paths[0] if all_marker_paths else None
47
+
48
+
49
+ def turn_marker_exists(all_hook_fields: dict[str, object]) -> bool:
50
+ """Return True when this session already deepened a task.
51
+
52
+ Both marker locations count. Cursor's first prompt claims the session marker
53
+ because it has no transcript path yet, and a later prompt carries one.
54
+ """
55
+ return any(each_marker_path.is_file() for each_marker_path in _all_turn_marker_paths(all_hook_fields))
56
+
57
+
58
+ def claim_turn_marker(all_hook_fields: dict[str, object]) -> bool:
59
+ """Create the marker, and return False when another copy created it first.
60
+
61
+ A payload with no transcript path or session id, or a marker that cannot be
62
+ written, returns True: the hook then deepens without a guard, as before.
63
+ """
64
+ marker_path = turn_marker_path(all_hook_fields)
65
+ if marker_path is None:
66
+ return True
67
+ try:
68
+ marker_descriptor = os.open(marker_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
69
+ except FileExistsError:
70
+ return False
71
+ except OSError:
72
+ return True
73
+ with os.fdopen(marker_descriptor, "w", encoding=UTF8_ENCODING) as marker_file:
74
+ marker_file.write(DEEPEN_MARKER)
75
+ return True
@@ -0,0 +1,24 @@
1
+ import json
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ SESSION_DIRECTORY = Path(__file__).resolve().parent
6
+ if str(SESSION_DIRECTORY) not in sys.path:
7
+ sys.path.insert(0, str(SESSION_DIRECTORY))
8
+
9
+ import cursor_agents_guidance
10
+
11
+
12
+ def test_should_emit_the_guidance_file_as_cursor_additional_context(tmp_path: Path) -> None:
13
+ guidance_path = tmp_path / "AGENTS.md"
14
+ guidance_path.write_text("# Agent guidance\n- **Asks.** Work every ask now.\n", encoding="utf-8")
15
+ printed_output = cursor_agents_guidance.guidance_output(["hook", str(guidance_path)])
16
+ assert json.loads(printed_output) == {"additional_context": guidance_path.read_text(encoding="utf-8")}
17
+
18
+
19
+ def test_should_emit_an_empty_object_when_the_file_is_missing(tmp_path: Path) -> None:
20
+ assert cursor_agents_guidance.guidance_output(["hook", str(tmp_path / "absent.md")]) == "{}"
21
+
22
+
23
+ def test_should_emit_an_empty_object_without_a_path_argument() -> None:
24
+ assert cursor_agents_guidance.guidance_output(["hook"]) == "{}"
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import sys
8
+ import tempfile
8
9
  from io import StringIO
9
10
  from pathlib import Path
10
11
  from unittest.mock import patch
@@ -32,6 +33,7 @@ from hooks_constants.deepen_first_prompt_constants import (
32
33
  REFUSAL_RESTATES_FOUR_QUESTIONS,
33
34
  SKIP_DEEPEN_ENV_VAR,
34
35
  USER_PROMPT_SUBMIT_EVENT_NAME,
36
+ CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME,
35
37
  )
36
38
  from hooks_constants.skill_loaded_reminder_constants import CLAUDE_SUBAGENT_PROMPT_PREFIX
37
39
 
@@ -58,6 +60,10 @@ def _user_transcript_line() -> str:
58
60
  return json.dumps({"type": "user", "message": {"role": "user", "content": "earlier task"}})
59
61
 
60
62
 
63
+ def _cursor_transcript_line(role: str, text: str) -> str:
64
+ return json.dumps({"role": role, "message": {"content": [{"type": "text", "text": text}]}})
65
+
66
+
61
67
  def _role_rewrite(body: str) -> str:
62
68
  return (
63
69
  f"{DEEPEN_MARKER}\n"
@@ -157,6 +163,24 @@ class TestSkipReasonFor:
157
163
  def test_should_deepen_a_first_task(self) -> None:
158
164
  assert deepen.skip_reason_for(_payload("Add dark mode to the settings page")) is None
159
165
 
166
+ def test_should_deepen_a_first_task_from_the_cursor_prompt_event(self) -> None:
167
+ fields = _payload("Add dark mode to the settings page")
168
+ fields["hook_event_name"] = CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME
169
+ assert deepen.skip_reason_for(fields) is None
170
+
171
+ def test_should_skip_when_a_cursor_transcript_already_has_a_user_turn(self, tmp_path: Path) -> None:
172
+ cursor_transcript_path = tmp_path / "cursor.jsonl"
173
+ cursor_transcript_path.write_text(_cursor_transcript_line("user", "earlier task"), encoding="utf-8")
174
+ fields = _payload("Add dark mode to the settings page", str(cursor_transcript_path))
175
+ fields["hook_event_name"] = CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME
176
+ fields.pop("session_id")
177
+ assert deepen.skip_reason_for(fields) is deepen.SkipReason.LATER_TURN
178
+
179
+ def test_should_skip_any_other_event(self) -> None:
180
+ fields = _payload("Add dark mode to the settings page")
181
+ fields["hook_event_name"] = "stop"
182
+ assert deepen.skip_reason_for(fields) is deepen.SkipReason.LATER_TURN
183
+
160
184
 
161
185
  class TestDecideFirstPrompt:
162
186
  def test_should_emit_additional_context_with_the_four_answers_for_a_first_task(
@@ -193,6 +217,45 @@ class TestDecideFirstPrompt:
193
217
  second_stdout = _run_main(_payload("and also the billing page", str(transcript_path)))
194
218
  assert second_stdout == ""
195
219
 
220
+ def test_should_inject_once_when_two_copies_see_the_same_cursor_prompt(
221
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
222
+ ) -> None:
223
+ monkeypatch.setenv(DEEPEN_REWRITE_ENV_VAR, _role_rewrite("Deeper task body"))
224
+ monkeypatch.delenv("CDE_DEEPEN_OUTPUT", raising=False)
225
+ monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
226
+ cursor_fields = _payload("Add dark mode to the settings page")
227
+ cursor_fields["hook_event_name"] = CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME
228
+ cursor_fields["transcript_path"] = None
229
+ cursor_fields["session_id"] = "cursor-conversation"
230
+ all_outputs = [_run_main(cursor_fields), _run_main(cursor_fields)]
231
+ assert sum(DEEPEN_MARKER in each_output for each_output in all_outputs) == 1
232
+
233
+ def test_should_keep_cursor_turn_two_silent_once_the_transcript_appears(
234
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
235
+ ) -> None:
236
+ monkeypatch.setenv(DEEPEN_REWRITE_ENV_VAR, _role_rewrite("Deeper task body"))
237
+ monkeypatch.delenv("CDE_DEEPEN_OUTPUT", raising=False)
238
+ monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
239
+ first_fields = _payload("Add dark mode to the settings page")
240
+ first_fields["hook_event_name"] = CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME
241
+ first_fields["transcript_path"] = None
242
+ first_fields["session_id"] = "cursor-conversation"
243
+ assert DEEPEN_MARKER in _run_main(first_fields)
244
+ cursor_transcript_path = tmp_path / "cursor-conversation.jsonl"
245
+ cursor_transcript_path.write_text(
246
+ "\n".join(
247
+ [
248
+ _cursor_transcript_line("user", "Add dark mode to the settings page"),
249
+ _cursor_transcript_line("assistant", "Done."),
250
+ ]
251
+ ),
252
+ encoding="utf-8",
253
+ )
254
+ second_fields = dict(first_fields)
255
+ second_fields["prompt"] = "and also the billing page"
256
+ second_fields["transcript_path"] = str(cursor_transcript_path)
257
+ assert _run_main(second_fields) == ""
258
+
196
259
  def test_should_print_rewrite_text_for_the_claude_mod(
197
260
  self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
198
261
  ) -> None:
@@ -0,0 +1,59 @@
1
+ """Tests for the deepen turn-one marker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
13
+
14
+ import deepen_turn_marker as marker
15
+ from hooks_constants.deepen_first_prompt_constants import (
16
+ DEEPEN_SESSION_MARKER_PREFIX,
17
+ DEEPEN_TURN_MARKER_SUFFIX,
18
+ )
19
+
20
+
21
+ def test_should_name_the_marker_after_the_transcript_when_there_is_one(tmp_path: Path) -> None:
22
+ transcript_path = str(tmp_path / "session.jsonl")
23
+ assert marker.turn_marker_path({"transcript_path": transcript_path, "session_id": "s"}) == Path(
24
+ transcript_path + DEEPEN_TURN_MARKER_SUFFIX
25
+ )
26
+
27
+
28
+ def test_should_name_the_marker_after_the_session_in_the_temp_folder(
29
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
30
+ ) -> None:
31
+ monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
32
+ assert marker.turn_marker_path({"transcript_path": None, "session_id": "abc"}) == (
33
+ tmp_path / f"{DEEPEN_SESSION_MARKER_PREFIX}abc"
34
+ )
35
+
36
+
37
+ def test_should_let_only_the_first_claim_win(tmp_path: Path) -> None:
38
+ fields = {"transcript_path": str(tmp_path / "session.jsonl")}
39
+ assert marker.claim_turn_marker(fields) is True
40
+ assert marker.claim_turn_marker(fields) is False
41
+ assert marker.turn_marker_exists(fields)
42
+
43
+
44
+ def test_should_allow_a_claim_when_the_payload_names_no_session() -> None:
45
+ assert marker.claim_turn_marker({}) is True
46
+
47
+
48
+ def test_should_allow_a_claim_when_the_marker_cannot_be_written(tmp_path: Path) -> None:
49
+ fields = {"transcript_path": str(tmp_path / "missing-folder" / "session.jsonl")}
50
+ assert marker.claim_turn_marker(fields) is True
51
+
52
+
53
+ def test_should_see_the_session_marker_once_a_transcript_path_appears(
54
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
55
+ ) -> None:
56
+ monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
57
+ assert marker.claim_turn_marker({"transcript_path": None, "session_id": "abc"}) is True
58
+ later_fields = {"transcript_path": str(tmp_path / "cursor.jsonl"), "session_id": "abc"}
59
+ assert marker.turn_marker_exists(later_fields)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.87.1",
3
+ "version": "8.88.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -139,6 +139,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
139
139
  hooks/blocking/build_eval_before_eval_write_gate.py -> exempt
140
140
  hooks/blocking/merge_over_review_offer_gate.py -> exempt
141
141
  hooks/blocking/final_reply_gate.py -> exempt
142
+ hooks/blocking/open_tasks_stop_gate.py -> exempt
142
143
  hooks/blocking/some_new_blocker.py -> flagged
143
144
 
144
145
  The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
@@ -179,6 +180,11 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
179
180
  names open review findings. It reads only the wording of a chat card and
180
181
  decides no code or safety policy; the merge review gate holds the merge.
181
182
 
183
+ The open tasks Stop gate holds the end of a turn while the session's task
184
+ list has items the closing reply neither completes nor reports as
185
+ ``Blocked:`` or ``Open:``. It decides when a turn may end and no code or
186
+ safety policy.
187
+
182
188
  Args:
183
189
  registered_string: One command, path, script, or entrypoint string.
184
190
  """
@@ -78,6 +78,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
78
78
  "blocking/build_eval_before_eval_write_gate.py",
79
79
  "blocking/merge_over_review_offer_gate.py",
80
80
  "blocking/final_reply_gate.py",
81
+ "blocking/open_tasks_stop_gate.py",
81
82
  )
82
83
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
83
84
  ALL_TEST_FILE_PREFIXES = ("test_",)
@@ -74,6 +74,14 @@ def test_new_final_reply_gate_registration_is_clean(tmp_path: Path) -> None:
74
74
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
75
75
 
76
76
 
77
+ def test_new_open_tasks_stop_gate_registration_is_clean(tmp_path: Path) -> None:
78
+ current_document = _hook_document(
79
+ ["hooks/blocking/open_tasks_stop_gate.py"],
80
+ '{"hooks": {}}',
81
+ )
82
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
83
+
84
+
77
85
  def test_new_step_note_gate_registration_is_clean(tmp_path: Path) -> None:
78
86
  current_document = _hook_document(
79
87
  ["hooks/blocking/step_note_gate.py"],