claude-dev-env 8.88.0 → 8.88.2

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.
@@ -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,8 +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,
29
- ALL_FOUR_QUESTION_STEMS,
30
- QUESTION_LINE_SEPARATOR,
33
+ ALL_FIRST_PROMPT_EVENT_NAMES,
31
34
  ALL_HELPER_REPORT_MARKERS,
32
35
  ALL_LOOP_PROMPT_PREFIXES,
33
36
  ALL_NONINTERACTIVE_ENV_TRUE_VALUES,
@@ -45,7 +48,6 @@ from hooks_constants.deepen_first_prompt_constants import (
45
48
  DEEPEN_OUTPUT_REWRITE,
46
49
  DEEPEN_REWRITE_ENV_VAR,
47
50
  DEEPEN_SLASH_COMMAND,
48
- DEEPEN_TURN_MARKER_SUFFIX,
49
51
  HOOK_EVENT_NAME_KEY,
50
52
  HOOK_SPECIFIC_OUTPUT_KEY,
51
53
  JSONL_ROLE_KEY,
@@ -53,7 +55,6 @@ from hooks_constants.deepen_first_prompt_constants import (
53
55
  NONINTERACTIVE_CODEX_ENV_VAR,
54
56
  NONINTERACTIVE_PRINT_ENV_VAR,
55
57
  PROMPT_FIELD_NAME,
56
- SESSION_ID_FIELD_NAME,
57
58
  SKIP_DEEPEN_ENV_VAR,
58
59
  SKIP_DEEPEN_TRUE_VALUE,
59
60
  TRANSCRIPT_PATH_FIELD_NAME,
@@ -65,7 +66,9 @@ from hooks_constants.deepen_first_prompt_constants import (
65
66
  from hooks_constants.mod_handoff import is_mod_plugin_enabled
66
67
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
67
68
  from hooks_constants.subprocess_window import hidden_window_creation_flags
69
+ from deepen_default_rewrite import role_default_rewrite
68
70
  from deepen_role_gate import refusal_reason
71
+ from deepen_turn_marker import claim_turn_marker, turn_marker_exists
69
72
 
70
73
 
71
74
  class SkipReason(StrEnum):
@@ -146,6 +149,8 @@ def _is_user_entry(all_entry_fields: dict[str, object] | None) -> bool:
146
149
  return False
147
150
  if all_entry_fields.get(JSONL_TYPE_KEY) == USER_TYPE_NAME:
148
151
  return True
152
+ if all_entry_fields.get(JSONL_ROLE_KEY) == USER_ROLE_NAME:
153
+ return True
149
154
  message = all_entry_fields.get("message")
150
155
  return isinstance(message, dict) and message.get(JSONL_ROLE_KEY) == USER_ROLE_NAME
151
156
 
@@ -160,32 +165,6 @@ def _transcript_has_prior_user_turn(transcript_path: object) -> bool:
160
165
  return False
161
166
 
162
167
 
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
168
  def _environment_skip_reason() -> SkipReason | None:
190
169
  if os.environ.get(SKIP_DEEPEN_ENV_VAR, "").strip() == SKIP_DEEPEN_TRUE_VALUE:
191
170
  return SkipReason.SKIP_FLAG
@@ -224,13 +203,13 @@ def _shape_skip_reason(prompt: str) -> SkipReason | None:
224
203
  def skip_reason_for(all_hook_fields: dict[str, object]) -> SkipReason | None:
225
204
  """Return the first skip that matches, or None when the gate should deepen."""
226
205
  event_name = all_hook_fields.get("hook_event_name")
227
- if event_name not in (USER_PROMPT_SUBMIT_EVENT_NAME, None, ""):
206
+ if event_name not in ALL_FIRST_PROMPT_EVENT_NAMES and event_name not in (None, ""):
228
207
  return SkipReason.LATER_TURN
229
208
  prompt = submitted_prompt_text(all_hook_fields)
230
209
  reason = _environment_skip_reason() or _marker_skip_reason(prompt) or _shape_skip_reason(prompt)
231
210
  if reason is not None:
232
211
  return reason
233
- if _turn_marker_exists(all_hook_fields) or _transcript_has_prior_user_turn(
212
+ if turn_marker_exists(all_hook_fields) or _transcript_has_prior_user_turn(
234
213
  all_hook_fields.get(TRANSCRIPT_PATH_FIELD_NAME)
235
214
  ):
236
215
  return SkipReason.LATER_TURN
@@ -276,19 +255,8 @@ def _command_rewrite(task_text: str) -> str | None:
276
255
 
277
256
 
278
257
  def _default_rewrite(task_text: str) -> str:
279
- """Return the paste path: the four question stems and an order to do the job."""
280
- numbered_stems = QUESTION_LINE_SEPARATOR.join(
281
- f"{each_index}. {each_stem}"
282
- for each_index, each_stem in enumerate(ALL_FOUR_QUESTION_STEMS, start=1)
283
- )
284
- return (
285
- f"{DEEPEN_MARKER}\n\n"
286
- "Do this task as a leading practitioner in its field would.\n\n"
287
- f"{task_text}\n\n"
288
- "Cover four things the original prompt left out:\n"
289
- f"{numbered_stems}\n"
290
- "Then do exactly that job."
291
- )
258
+ """Return the role-shaped default rewrite the gate accepts."""
259
+ return role_default_rewrite(task_text)
292
260
 
293
261
 
294
262
  def expand_first_task(prompt: str) -> str:
@@ -388,7 +356,8 @@ def emit_decision(decision: DeepenDecision, all_hook_fields: dict[str, object])
388
356
  if output_mode == DEEPEN_OUTPUT_JSON:
389
357
  _print_refuse_json(decision.reason)
390
358
  return
391
- write_turn_marker(all_hook_fields)
359
+ if not claim_turn_marker(all_hook_fields):
360
+ return
392
361
  if output_mode == DEEPEN_OUTPUT_JSON:
393
362
  _print_deepen_json(decision)
394
363
  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,90 @@
1
+ """Tests for the role-shaped default rewrite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
9
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
10
+
11
+ from deepen_default_rewrite import role_default_rewrite
12
+ from deepen_role_gate import refusal_reason
13
+ from hooks_constants.deepen_first_prompt_constants import ALL_FOUR_QUESTION_STEMS, DEEPEN_MARKER
14
+
15
+
16
+ def _role_line(rewrite: str, label: str) -> str:
17
+ return next(each for each in rewrite.splitlines() if each.startswith(label))[len(label) :].strip()
18
+
19
+
20
+ def test_should_pass_the_gate_for_a_plain_task() -> None:
21
+ task = "Add dark mode to the settings page"
22
+ rewrite = role_default_rewrite(task)
23
+ assert rewrite.startswith(DEEPEN_MARKER)
24
+ assert refusal_reason(task, rewrite) is None
25
+ assert _role_line(rewrite, "SCOPE (role):") == task
26
+ assert _role_line(rewrite, "EXAMPLES (illustration only):") == "none"
27
+
28
+
29
+ def test_should_not_paste_the_four_question_stems() -> None:
30
+ rewrite = role_default_rewrite("Add dark mode to the settings page")
31
+ assert not any(each_stem in rewrite for each_stem in ALL_FOUR_QUESTION_STEMS)
32
+
33
+
34
+ def test_should_move_a_marked_example_out_of_the_scope() -> None:
35
+ task = "fix the flaky tests, e.g. test_auth and test_cache, and keep CI fast"
36
+ rewrite = role_default_rewrite(task)
37
+ assert refusal_reason(task, rewrite) is None
38
+ assert _role_line(rewrite, "SCOPE (role):") == "fix the flaky tests and keep CI fast"
39
+ assert _role_line(rewrite, "EXAMPLES (illustration only):") == "test_auth and test_cache"
40
+
41
+
42
+ def test_should_ask_before_narrowing_when_the_scope_names_the_example() -> None:
43
+ task = "Fix the login failure. For example, the login failure on Safari."
44
+ rewrite = role_default_rewrite(task)
45
+ assert refusal_reason(task, rewrite) is None
46
+ assert _role_line(rewrite, "ZOOM-IN BLOCKER:").startswith("the scope names something")
47
+
48
+
49
+ def test_should_keep_every_hard_constraint_line() -> None:
50
+ task = "Keep the build green. For example, the login failure.\n\nHARD CONSTRAINTS\n- Never open a pull request.\n"
51
+ rewrite = role_default_rewrite(task)
52
+ assert refusal_reason(task, rewrite) is None
53
+ assert "- Never open a pull request." in rewrite.splitlines()
54
+
55
+
56
+ def test_should_keep_every_task_sentence_in_scope_when_the_task_opens_with_context() -> None:
57
+ task = "We have a Django app. Add CSV export to the reports page, e.g. the monthly sales report, and keep the existing URLs."
58
+ rewrite = role_default_rewrite(task)
59
+ assert refusal_reason(task, rewrite) is None
60
+ assert _role_line(rewrite, "SCOPE (role):") == (
61
+ "We have a Django app. Add CSV export to the reports page and keep the existing URLs."
62
+ )
63
+ assert _role_line(rewrite, "EXAMPLES (illustration only):") == "the monthly sales report"
64
+
65
+
66
+ def test_should_clip_a_long_scope_and_point_at_the_full_task() -> None:
67
+ task = "Refactor the module. " * 40
68
+ rewrite = role_default_rewrite(task.strip())
69
+ scope = _role_line(rewrite, "SCOPE (role):")
70
+ assert scope.endswith("(the rest is in the task below)")
71
+ assert len(scope) < len(task)
72
+ assert refusal_reason(task.strip(), rewrite) is None
73
+
74
+
75
+ def test_should_keep_a_marker_word_inside_a_longer_word_in_the_scope() -> None:
76
+ task = "Search the codebase for examples of deprecated API calls and replace each one with the new client."
77
+ rewrite = role_default_rewrite(task)
78
+ assert refusal_reason(task, rewrite) is None
79
+ assert _role_line(rewrite, "SCOPE (role):") == task
80
+ assert _role_line(rewrite, "EXAMPLES (illustration only):") == "none"
81
+ assumption_task = "Avoid such assumptions in the parser and add input validation."
82
+ assert _role_line(role_default_rewrite(assumption_task), "SCOPE (role):") == assumption_task
83
+
84
+
85
+ def test_should_move_every_marked_example_in_a_sentence_out_of_the_scope() -> None:
86
+ task = "Speed up the slow pages such as the dashboard, and cache reads, e.g. the user lookup, and keep the API stable."
87
+ rewrite = role_default_rewrite(task)
88
+ assert refusal_reason(task, rewrite) is None
89
+ assert _role_line(rewrite, "SCOPE (role):") == "Speed up the slow pages and cache reads and keep the API stable."
90
+ assert _role_line(rewrite, "EXAMPLES (illustration only):") == "the dashboard; the user lookup"
@@ -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
@@ -30,8 +31,10 @@ from hooks_constants.deepen_first_prompt_constants import (
30
31
  REFUSAL_EXAMPLE_NOUN_IN_SCOPE,
31
32
  REFUSAL_MISSING_ROLE_LINES,
32
33
  REFUSAL_RESTATES_FOUR_QUESTIONS,
34
+ ALL_FOUR_QUESTION_STEMS,
33
35
  SKIP_DEEPEN_ENV_VAR,
34
36
  USER_PROMPT_SUBMIT_EVENT_NAME,
37
+ CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME,
35
38
  )
36
39
  from hooks_constants.skill_loaded_reminder_constants import CLAUDE_SUBAGENT_PROMPT_PREFIX
37
40
 
@@ -58,6 +61,10 @@ def _user_transcript_line() -> str:
58
61
  return json.dumps({"type": "user", "message": {"role": "user", "content": "earlier task"}})
59
62
 
60
63
 
64
+ def _cursor_transcript_line(role: str, text: str) -> str:
65
+ return json.dumps({"role": role, "message": {"content": [{"type": "text", "text": text}]}})
66
+
67
+
61
68
  def _role_rewrite(body: str) -> str:
62
69
  return (
63
70
  f"{DEEPEN_MARKER}\n"
@@ -157,6 +164,24 @@ class TestSkipReasonFor:
157
164
  def test_should_deepen_a_first_task(self) -> None:
158
165
  assert deepen.skip_reason_for(_payload("Add dark mode to the settings page")) is None
159
166
 
167
+ def test_should_deepen_a_first_task_from_the_cursor_prompt_event(self) -> None:
168
+ fields = _payload("Add dark mode to the settings page")
169
+ fields["hook_event_name"] = CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME
170
+ assert deepen.skip_reason_for(fields) is None
171
+
172
+ def test_should_skip_when_a_cursor_transcript_already_has_a_user_turn(self, tmp_path: Path) -> None:
173
+ cursor_transcript_path = tmp_path / "cursor.jsonl"
174
+ cursor_transcript_path.write_text(_cursor_transcript_line("user", "earlier task"), encoding="utf-8")
175
+ fields = _payload("Add dark mode to the settings page", str(cursor_transcript_path))
176
+ fields["hook_event_name"] = CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME
177
+ fields.pop("session_id")
178
+ assert deepen.skip_reason_for(fields) is deepen.SkipReason.LATER_TURN
179
+
180
+ def test_should_skip_any_other_event(self) -> None:
181
+ fields = _payload("Add dark mode to the settings page")
182
+ fields["hook_event_name"] = "stop"
183
+ assert deepen.skip_reason_for(fields) is deepen.SkipReason.LATER_TURN
184
+
160
185
 
161
186
  class TestDecideFirstPrompt:
162
187
  def test_should_emit_additional_context_with_the_four_answers_for_a_first_task(
@@ -193,6 +218,45 @@ class TestDecideFirstPrompt:
193
218
  second_stdout = _run_main(_payload("and also the billing page", str(transcript_path)))
194
219
  assert second_stdout == ""
195
220
 
221
+ def test_should_inject_once_when_two_copies_see_the_same_cursor_prompt(
222
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
223
+ ) -> None:
224
+ monkeypatch.setenv(DEEPEN_REWRITE_ENV_VAR, _role_rewrite("Deeper task body"))
225
+ monkeypatch.delenv("CDE_DEEPEN_OUTPUT", raising=False)
226
+ monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
227
+ cursor_fields = _payload("Add dark mode to the settings page")
228
+ cursor_fields["hook_event_name"] = CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME
229
+ cursor_fields["transcript_path"] = None
230
+ cursor_fields["session_id"] = "cursor-conversation"
231
+ all_outputs = [_run_main(cursor_fields), _run_main(cursor_fields)]
232
+ assert sum(DEEPEN_MARKER in each_output for each_output in all_outputs) == 1
233
+
234
+ def test_should_keep_cursor_turn_two_silent_once_the_transcript_appears(
235
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
236
+ ) -> None:
237
+ monkeypatch.setenv(DEEPEN_REWRITE_ENV_VAR, _role_rewrite("Deeper task body"))
238
+ monkeypatch.delenv("CDE_DEEPEN_OUTPUT", raising=False)
239
+ monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
240
+ first_fields = _payload("Add dark mode to the settings page")
241
+ first_fields["hook_event_name"] = CURSOR_BEFORE_SUBMIT_PROMPT_EVENT_NAME
242
+ first_fields["transcript_path"] = None
243
+ first_fields["session_id"] = "cursor-conversation"
244
+ assert DEEPEN_MARKER in _run_main(first_fields)
245
+ cursor_transcript_path = tmp_path / "cursor-conversation.jsonl"
246
+ cursor_transcript_path.write_text(
247
+ "\n".join(
248
+ [
249
+ _cursor_transcript_line("user", "Add dark mode to the settings page"),
250
+ _cursor_transcript_line("assistant", "Done."),
251
+ ]
252
+ ),
253
+ encoding="utf-8",
254
+ )
255
+ second_fields = dict(first_fields)
256
+ second_fields["prompt"] = "and also the billing page"
257
+ second_fields["transcript_path"] = str(cursor_transcript_path)
258
+ assert _run_main(second_fields) == ""
259
+
196
260
  def test_should_print_rewrite_text_for_the_claude_mod(
197
261
  self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
198
262
  ) -> None:
@@ -278,12 +342,20 @@ def test_should_strip_the_deepen_slash_before_rewriting() -> None:
278
342
  assert deepen.task_text_for_deepen("/deepen fix the login page") == "fix the login page"
279
343
 
280
344
 
281
- def test_should_refuse_the_paste_path_as_the_known_bad() -> None:
345
+ def test_should_refuse_the_paste_path_as_the_known_bad(monkeypatch: pytest.MonkeyPatch) -> None:
346
+ pasted_questions = "\n".join(ALL_FOUR_QUESTION_STEMS)
347
+ monkeypatch.setenv(DEEPEN_REWRITE_ENV_VAR, _role_rewrite(pasted_questions))
282
348
  decision = deepen.decide_first_prompt(_payload("Add dark mode to the settings page"))
283
349
  assert isinstance(decision, deepen.Refuse)
284
350
  assert decision.reason == REFUSAL_RESTATES_FOUR_QUESTIONS
285
351
 
286
352
 
353
+ def test_should_deepen_with_the_default_rewrite_when_nothing_is_configured() -> None:
354
+ decision = deepen.decide_first_prompt(_payload("Add dark mode to the settings page"))
355
+ assert isinstance(decision, deepen.Deepen)
356
+ assert "SCOPE (role): Add dark mode to the settings page" in decision.rewritten_prompt
357
+
358
+
287
359
  def test_should_refuse_the_filled_four_answer_stub_without_role_lines(
288
360
  monkeypatch: pytest.MonkeyPatch,
289
361
  ) -> None:
@@ -364,6 +436,7 @@ def test_should_print_a_json_refusal_for_the_paste_path(
364
436
  monkeypatch: pytest.MonkeyPatch,
365
437
  ) -> None:
366
438
  monkeypatch.setenv("CDE_DEEPEN_OUTPUT", DEEPEN_OUTPUT_JSON)
439
+ monkeypatch.setenv(DEEPEN_REWRITE_ENV_VAR, _role_rewrite("\n".join(ALL_FOUR_QUESTION_STEMS)))
367
440
  printed = _run_main(_payload("Add dark mode to the settings page"))
368
441
  assert json.loads(printed) == {
369
442
  "decision": "refuse",
@@ -375,4 +448,5 @@ def test_should_stay_silent_in_rewrite_mode_when_the_gate_refuses(
375
448
  monkeypatch: pytest.MonkeyPatch,
376
449
  ) -> None:
377
450
  monkeypatch.setenv("CDE_DEEPEN_OUTPUT", DEEPEN_OUTPUT_REWRITE)
451
+ monkeypatch.setenv(DEEPEN_REWRITE_ENV_VAR, _FILLED_FOUR_ANSWERS)
378
452
  assert _run_main(_payload("Add dark mode to the settings page")) == ""
@@ -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.2",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {