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.
- package/bin/codex-trimmed-sol.mjs +37 -2
- package/bin/codex-trimmed-sol.test.mjs +39 -1
- package/bin/folder-lockers.mjs +142 -0
- package/bin/folder-lockers.test.mjs +55 -0
- package/bin/global-install-shadow.mjs +75 -0
- package/bin/global-install-shadow.test.mjs +56 -0
- package/bin/install-deepstack-plugin.mjs +45 -16
- package/bin/install-deepstack-plugin.test.mjs +65 -1
- package/bin/install-pstack-plugin.mjs +20 -4
- package/bin/install-pstack-plugin.test.mjs +53 -0
- package/bin/install.mjs +45 -6
- package/bin/install.test.mjs +54 -0
- package/bin/python-probe.mjs +20 -0
- package/bin/python-probe.test.mjs +37 -0
- package/docs/agents-guidance/asks.md +1 -0
- package/docs/codex-accounts.md +4 -1
- package/docs/decisions/deepstack-gate.md +6 -2
- package/hooks/features/session-context.md +2 -1
- package/hooks/hooks_constants/deepen_default_rewrite_constants.py +39 -0
- package/hooks/hooks_constants/deepen_first_prompt_constants.py +8 -0
- package/hooks/session/deepen_default_rewrite.py +128 -0
- package/hooks/session/deepen_first_prompt.py +15 -46
- package/hooks/session/deepen_turn_marker.py +75 -0
- package/hooks/session/test_deepen_default_rewrite.py +90 -0
- package/hooks/session/test_deepen_first_prompt.py +75 -1
- package/hooks/session/test_deepen_turn_marker.py +59 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
280
|
-
|
|
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
|
-
|
|
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)
|