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 +5 -2
- package/bin/python-probe.mjs +20 -0
- package/bin/python-probe.test.mjs +37 -0
- package/hooks/features/session-context.md +2 -1
- package/hooks/hooks_constants/deepen_first_prompt_constants.py +8 -0
- package/hooks/session/deepen_first_prompt.py +12 -31
- package/hooks/session/deepen_turn_marker.py +75 -0
- package/hooks/session/test_deepen_first_prompt.py +63 -0
- package/hooks/session/test_deepen_turn_marker.py +59 -0
- package/package.json +1 -1
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.
|
|
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(
|
|
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,
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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)
|