claude-dev-env 8.88.0 → 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.
package/bin/install.mjs CHANGED
@@ -126,6 +126,7 @@ import {
126
126
  resolveFaultPhaseFromEnvironment,
127
127
  runWithInstallTransaction,
128
128
  } from './install-transaction.mjs';
129
+ import { pythonExecutableProbeCommand } from './python-probe.mjs';
129
130
 
130
131
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
131
132
  const INSTALL_ROOT_RESOLUTION = resolveInstallRoot({
@@ -550,7 +551,9 @@ function quoteStandalonePluginRootHookPath(commandString, pluginRootForward) {
550
551
  * sys.executable and that path is baked in, so a later PATH change or Microsoft
551
552
  * Store update that re-points the `py`/`python` launcher cannot silently break
552
553
  * the hooks; candidates resolving to the non-spawnable WindowsApps stub are
553
- * skipped. Other platforms keep the bare command (e.g. `python3`).
554
+ * skipped. The path probe runs in isolated mode so the folder the installer
555
+ * runs from cannot slow or change it. Other platforms keep the bare command
556
+ * (e.g. `python3`).
554
557
  *
555
558
  * @returns {string|null} The interpreter command, or null when none is usable.
556
559
  */
@@ -561,7 +564,7 @@ export function detectPython() {
561
564
  const version = execSync(`${command} ${versionFlag}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: PYTHON_PROBE_TIMEOUT_MILLISECONDS }).trim();
562
565
  if (!version.includes('Python 3.')) continue;
563
566
  if (process.platform !== 'win32') return command;
564
- const executablePath = execSync(`${command} -c "import sys; print(sys.executable)"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: PYTHON_PROBE_TIMEOUT_MILLISECONDS }).trim();
567
+ const executablePath = execSync(pythonExecutableProbeCommand(command), { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: PYTHON_PROBE_TIMEOUT_MILLISECONDS }).trim();
565
568
  if (!executablePath || isWindowsStorePythonStub(executablePath)) continue;
566
569
  return interpreterCommandFromPath(executablePath);
567
570
  } catch { /* try next */ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Command line for the probe that asks a Python candidate for its absolute
3
+ * interpreter path.
4
+ *
5
+ * `python -c` puts the current directory first on `sys.path`, and Python
6
+ * lists that whole directory the first time it imports a module from disk.
7
+ * When the installer runs from a large folder, such as a temp folder with
8
+ * hundreds of thousands of entries, that listing can outlast the probe
9
+ * timeout, so every candidate fails and the install stops with "No usable
10
+ * Python 3 found". A module in that folder with a standard library name is
11
+ * imported in place of the real one. `-I` (isolated mode, Python 3.4+) keeps
12
+ * the current directory off `sys.path`, so the probe gives the same answer
13
+ * from any folder.
14
+ *
15
+ * @param {string} candidateCommand A Python launcher command, such as `py -3`.
16
+ * @returns {string} The shell command that prints the interpreter path.
17
+ */
18
+ export function pythonExecutableProbeCommand(candidateCommand) {
19
+ return `${candidateCommand} -I -c "import sys; print(sys.executable)"`;
20
+ }
@@ -0,0 +1,37 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { execSync } from 'node:child_process';
4
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { detectPython } from './install.mjs';
8
+ import { pythonExecutableProbeCommand } from './python-probe.mjs';
9
+
10
+ const SHADOWED_STANDARD_LIBRARY_MODULES = ['linecache.py', 'encodings.py', 'site.py', 'os.py'];
11
+
12
+ test('the interpreter path probe ignores modules in the current directory', () => {
13
+ const candidateCommand = detectPython();
14
+ assert.ok(candidateCommand, 'a Python 3 interpreter is needed for this test');
15
+ const shadowDirectory = mkdtempSync(join(tmpdir(), 'cde-python-probe-'));
16
+ try {
17
+ for (const moduleFileName of SHADOWED_STANDARD_LIBRARY_MODULES) {
18
+ writeFileSync(join(shadowDirectory, moduleFileName), 'raise SystemExit("shadow module imported")\n');
19
+ }
20
+ const printedPath = execSync(pythonExecutableProbeCommand(candidateCommand), {
21
+ cwd: shadowDirectory,
22
+ encoding: 'utf8',
23
+ stdio: ['pipe', 'pipe', 'pipe'],
24
+ }).trim();
25
+ assert.ok(printedPath.length > 0);
26
+ assert.doesNotMatch(printedPath, /shadow module imported/);
27
+ } finally {
28
+ rmSync(shadowDirectory, { recursive: true, force: true });
29
+ }
30
+ });
31
+
32
+ test('the interpreter path probe runs Python in isolated mode', () => {
33
+ assert.equal(
34
+ pythonExecutableProbeCommand('py -3'),
35
+ 'py -3 -I -c "import sys; print(sys.executable)"',
36
+ );
37
+ });
@@ -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
 
@@ -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"
@@ -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
@@ -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.88.0",
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": {