claude-dev-env 8.82.1 → 8.83.0
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/.agents/skills/deepen/SKILL.md +42 -0
- package/bin/codex-skill-load-block.mjs +32 -0
- package/bin/codex-skill-load-block.test.mjs +27 -0
- package/bin/codex-trimmed-sol.test.mjs +1 -0
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install-pstack-plugin.mjs +12 -0
- package/bin/install-pstack-plugin.test.mjs +23 -0
- package/bin/install.agents-home.test.mjs +1 -0
- package/bin/install.codex-rules.test.mjs +2 -0
- package/bin/install.cursor-rules.test.mjs +38 -1
- package/bin/install.mjs +34 -2
- package/bin/install.prompt-context.test.mjs +3 -1
- package/bin/install.prune.test.mjs +1 -0
- package/bin/install.question-presentation.test.mjs +4 -0
- package/bin/install.settings-defaults.test.mjs +1 -0
- package/bin/install.shared-settings.test.mjs +1 -0
- package/bin/install.test.mjs +34 -1
- package/bin/install.transaction.test.mjs +8 -0
- package/bin/install.uninstall-transaction.test.mjs +2 -0
- package/bin/install.update-keeps-hooks.test.mjs +1 -0
- package/commands/deepen.md +19 -0
- package/hooks/features/lifecycle-cleanup.md +1 -1
- package/hooks/features/session-context.md +3 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/deepen_first_prompt_constants.py +119 -0
- package/hooks/hooks_constants/mod_handoff_constants.py +2 -0
- package/hooks/hooks_constants/nested_project_hooks_constants.py +2 -0
- package/hooks/lifecycle/nested_project_hooks.py +17 -8
- package/hooks/lifecycle/test_nested_project_hooks.py +50 -0
- package/hooks/session/deepen_first_prompt.py +390 -0
- package/hooks/session/test_deepen_first_prompt.py +274 -0
- package/package.json +1 -1
- package/scripts/ci/install-playtest.mjs +329 -2
- package/scripts/ci/install-playtest.test.mjs +6 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""UserPromptSubmit hook: deepen the first interactive task before work starts.
|
|
3
|
+
|
|
4
|
+
Skip checks stay local. One bounded deepen call runs only for a first task.
|
|
5
|
+
Claude settings and Codex cannot replace the prompt string, so this hook emits
|
|
6
|
+
additionalContext. Claude desktop and CLI also ship a prompt.submit mod that
|
|
7
|
+
rewrites the visible text; this hook stays silent when that mod is on outside
|
|
8
|
+
a cloud session.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shlex
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from enum import StrEnum
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
23
|
+
if _hooks_dir not in sys.path:
|
|
24
|
+
sys.path.insert(0, _hooks_dir)
|
|
25
|
+
|
|
26
|
+
from hooks_constants.cloud_session import is_cloud_session
|
|
27
|
+
from hooks_constants.deepen_first_prompt_constants import (
|
|
28
|
+
ADDITIONAL_CONTEXT_KEY,
|
|
29
|
+
ALL_HELPER_REPORT_MARKERS,
|
|
30
|
+
ALL_LOOP_PROMPT_PREFIXES,
|
|
31
|
+
ALL_NONINTERACTIVE_ENV_TRUE_VALUES,
|
|
32
|
+
ALL_SPAWN_REWRITE_PREFIXES,
|
|
33
|
+
ALL_TRIVIAL_PROMPTS,
|
|
34
|
+
CLAUDE_HOST_ENV_VAR,
|
|
35
|
+
CLAUDE_HOST_TRUE_VALUE,
|
|
36
|
+
DEEPEN_AGENT_MARKER,
|
|
37
|
+
DEEPEN_COMMAND_ENV_VAR,
|
|
38
|
+
DEEPEN_COMMAND_TIMEOUT_SECONDS,
|
|
39
|
+
DEEPEN_FIRST_PROMPT_PLUGIN_NAME,
|
|
40
|
+
DEEPEN_MARKER,
|
|
41
|
+
DEEPEN_OUTPUT_ENV_VAR,
|
|
42
|
+
DEEPEN_OUTPUT_JSON,
|
|
43
|
+
DEEPEN_OUTPUT_REWRITE,
|
|
44
|
+
DEEPEN_REWRITE_ENV_VAR,
|
|
45
|
+
DEEPEN_SLASH_COMMAND,
|
|
46
|
+
DEEPEN_TURN_MARKER_SUFFIX,
|
|
47
|
+
HOOK_EVENT_NAME_KEY,
|
|
48
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
49
|
+
JSONL_ROLE_KEY,
|
|
50
|
+
JSONL_TYPE_KEY,
|
|
51
|
+
NONINTERACTIVE_CODEX_ENV_VAR,
|
|
52
|
+
NONINTERACTIVE_PRINT_ENV_VAR,
|
|
53
|
+
PROMPT_FIELD_NAME,
|
|
54
|
+
SESSION_ID_FIELD_NAME,
|
|
55
|
+
SKIP_DEEPEN_ENV_VAR,
|
|
56
|
+
SKIP_DEEPEN_TRUE_VALUE,
|
|
57
|
+
TRANSCRIPT_PATH_FIELD_NAME,
|
|
58
|
+
USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
59
|
+
USER_ROLE_NAME,
|
|
60
|
+
USER_TYPE_NAME,
|
|
61
|
+
UTF8_ENCODING,
|
|
62
|
+
)
|
|
63
|
+
from hooks_constants.mod_handoff import is_mod_plugin_enabled
|
|
64
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
65
|
+
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SkipReason(StrEnum):
|
|
69
|
+
"""Why the first-task gate left a prompt unchanged."""
|
|
70
|
+
|
|
71
|
+
LATER_TURN = "later-turn"
|
|
72
|
+
MARKER_PRESENT = "marker-present"
|
|
73
|
+
SPAWN_REWRITE = "spawn-rewrite"
|
|
74
|
+
SKIP_FLAG = "skip-flag"
|
|
75
|
+
NONINTERACTIVE = "noninteractive"
|
|
76
|
+
TRIVIAL = "trivial"
|
|
77
|
+
SLASH_COMMAND = "slash-command"
|
|
78
|
+
DEEPEN_AGENT = "deepen-agent"
|
|
79
|
+
LOOP_OR_HELPER = "loop-or-helper"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Skip:
|
|
84
|
+
"""Leave the submitted prompt text unchanged."""
|
|
85
|
+
|
|
86
|
+
reason: SkipReason
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Deepen:
|
|
91
|
+
"""Replace the first task with a deeper rewrite."""
|
|
92
|
+
|
|
93
|
+
rewritten_prompt: str
|
|
94
|
+
additional_context: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
DeepenDecision = Skip | Deepen
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def submitted_prompt_text(all_hook_fields: dict[str, object]) -> str:
|
|
101
|
+
"""Return the submitted prompt text, or empty when the payload has none."""
|
|
102
|
+
prompt = all_hook_fields.get(PROMPT_FIELD_NAME)
|
|
103
|
+
if isinstance(prompt, str):
|
|
104
|
+
return prompt
|
|
105
|
+
return ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _env_flag_is_true(variable_name: str) -> bool:
|
|
109
|
+
return os.environ.get(variable_name, "").strip().lower() in ALL_NONINTERACTIVE_ENV_TRUE_VALUES
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _normalized_prompt(prompt: str) -> str:
|
|
113
|
+
return prompt.strip().lower()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _first_token(prompt: str) -> str:
|
|
117
|
+
stripped = prompt.strip()
|
|
118
|
+
if not stripped:
|
|
119
|
+
return ""
|
|
120
|
+
return stripped.split(None, 1)[0]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _parsed_transcript_entry(transcript_line: str) -> dict[str, object] | None:
|
|
124
|
+
stripped_line = transcript_line.strip()
|
|
125
|
+
if not stripped_line:
|
|
126
|
+
return None
|
|
127
|
+
try:
|
|
128
|
+
parsed_entry = json.loads(stripped_line)
|
|
129
|
+
except json.JSONDecodeError:
|
|
130
|
+
return None
|
|
131
|
+
return parsed_entry if isinstance(parsed_entry, dict) else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _is_user_entry(all_entry_fields: dict[str, object] | None) -> bool:
|
|
135
|
+
if all_entry_fields is None:
|
|
136
|
+
return False
|
|
137
|
+
if all_entry_fields.get(JSONL_TYPE_KEY) == USER_TYPE_NAME:
|
|
138
|
+
return True
|
|
139
|
+
message = all_entry_fields.get("message")
|
|
140
|
+
return isinstance(message, dict) and message.get(JSONL_ROLE_KEY) == USER_ROLE_NAME
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _transcript_has_prior_user_turn(transcript_path: object) -> bool:
|
|
144
|
+
if not isinstance(transcript_path, str) or not transcript_path:
|
|
145
|
+
return False
|
|
146
|
+
try:
|
|
147
|
+
with Path(transcript_path).open(encoding=UTF8_ENCODING) as transcript:
|
|
148
|
+
return any(_is_user_entry(_parsed_transcript_entry(each_line)) for each_line in transcript)
|
|
149
|
+
except OSError:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _turn_marker_path(all_hook_fields: dict[str, object]) -> Path | None:
|
|
154
|
+
transcript_path = all_hook_fields.get(TRANSCRIPT_PATH_FIELD_NAME)
|
|
155
|
+
if isinstance(transcript_path, str) and transcript_path:
|
|
156
|
+
return Path(transcript_path + DEEPEN_TURN_MARKER_SUFFIX)
|
|
157
|
+
session_id = all_hook_fields.get(SESSION_ID_FIELD_NAME)
|
|
158
|
+
if isinstance(session_id, str) and session_id:
|
|
159
|
+
return Path(os.environ.get("TMPDIR", "/tmp")) / f"cde-deepen-{session_id}"
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _turn_marker_exists(all_hook_fields: dict[str, object]) -> bool:
|
|
164
|
+
marker_path = _turn_marker_path(all_hook_fields)
|
|
165
|
+
return marker_path is not None and marker_path.is_file()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def write_turn_marker(all_hook_fields: dict[str, object]) -> None:
|
|
169
|
+
"""Record that this session already deepened turn one."""
|
|
170
|
+
marker_path = _turn_marker_path(all_hook_fields)
|
|
171
|
+
if marker_path is None:
|
|
172
|
+
return
|
|
173
|
+
try:
|
|
174
|
+
marker_path.write_text(DEEPEN_MARKER, encoding=UTF8_ENCODING)
|
|
175
|
+
except OSError:
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _environment_skip_reason() -> SkipReason | None:
|
|
180
|
+
if os.environ.get(SKIP_DEEPEN_ENV_VAR, "").strip() == SKIP_DEEPEN_TRUE_VALUE:
|
|
181
|
+
return SkipReason.SKIP_FLAG
|
|
182
|
+
if _env_flag_is_true(NONINTERACTIVE_PRINT_ENV_VAR) or _env_flag_is_true(
|
|
183
|
+
NONINTERACTIVE_CODEX_ENV_VAR
|
|
184
|
+
):
|
|
185
|
+
return SkipReason.NONINTERACTIVE
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _marker_skip_reason(prompt: str) -> SkipReason | None:
|
|
190
|
+
if DEEPEN_MARKER in prompt:
|
|
191
|
+
return SkipReason.MARKER_PRESENT
|
|
192
|
+
if DEEPEN_AGENT_MARKER in prompt:
|
|
193
|
+
return SkipReason.DEEPEN_AGENT
|
|
194
|
+
if any(prompt.startswith(each_prefix) for each_prefix in ALL_SPAWN_REWRITE_PREFIXES):
|
|
195
|
+
return SkipReason.SPAWN_REWRITE
|
|
196
|
+
if any(prompt.lstrip().lower().startswith(each_prefix) for each_prefix in ALL_LOOP_PROMPT_PREFIXES):
|
|
197
|
+
return SkipReason.LOOP_OR_HELPER
|
|
198
|
+
if any(each_marker in prompt.lower() for each_marker in ALL_HELPER_REPORT_MARKERS):
|
|
199
|
+
return SkipReason.LOOP_OR_HELPER
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _shape_skip_reason(prompt: str) -> SkipReason | None:
|
|
204
|
+
first_token = _first_token(prompt)
|
|
205
|
+
if first_token.startswith("/") and first_token.lower() != DEEPEN_SLASH_COMMAND:
|
|
206
|
+
return SkipReason.SLASH_COMMAND
|
|
207
|
+
if first_token.lower() == DEEPEN_SLASH_COMMAND and prompt.strip().lower() == DEEPEN_SLASH_COMMAND:
|
|
208
|
+
return SkipReason.SLASH_COMMAND
|
|
209
|
+
if _normalized_prompt(prompt) in ALL_TRIVIAL_PROMPTS or not prompt.strip():
|
|
210
|
+
return SkipReason.TRIVIAL
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def skip_reason_for(all_hook_fields: dict[str, object]) -> SkipReason | None:
|
|
215
|
+
"""Return the first skip that matches, or None when the gate should deepen."""
|
|
216
|
+
event_name = all_hook_fields.get("hook_event_name")
|
|
217
|
+
if event_name not in (USER_PROMPT_SUBMIT_EVENT_NAME, None, ""):
|
|
218
|
+
return SkipReason.LATER_TURN
|
|
219
|
+
prompt = submitted_prompt_text(all_hook_fields)
|
|
220
|
+
reason = _environment_skip_reason() or _marker_skip_reason(prompt) or _shape_skip_reason(prompt)
|
|
221
|
+
if reason is not None:
|
|
222
|
+
return reason
|
|
223
|
+
if _turn_marker_exists(all_hook_fields) or _transcript_has_prior_user_turn(
|
|
224
|
+
all_hook_fields.get(TRANSCRIPT_PATH_FIELD_NAME)
|
|
225
|
+
):
|
|
226
|
+
return SkipReason.LATER_TURN
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def task_text_for_deepen(prompt: str) -> str:
|
|
231
|
+
"""Return the task body, stripping a leading /deepen command when present."""
|
|
232
|
+
stripped = prompt.strip()
|
|
233
|
+
first_token = _first_token(stripped)
|
|
234
|
+
if first_token.lower() != DEEPEN_SLASH_COMMAND:
|
|
235
|
+
return stripped
|
|
236
|
+
remainder = stripped[len(first_token) :].strip()
|
|
237
|
+
return remainder or stripped
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _with_deepen_marker(rewritten_body: str) -> str:
|
|
241
|
+
if DEEPEN_MARKER in rewritten_body:
|
|
242
|
+
return rewritten_body
|
|
243
|
+
return f"{DEEPEN_MARKER}\n\n{rewritten_body}"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _command_rewrite(task_text: str) -> str | None:
|
|
247
|
+
command = os.environ.get(DEEPEN_COMMAND_ENV_VAR, "").strip()
|
|
248
|
+
if not command:
|
|
249
|
+
return None
|
|
250
|
+
try:
|
|
251
|
+
completed = subprocess.run(
|
|
252
|
+
shlex.split(command),
|
|
253
|
+
input=task_text,
|
|
254
|
+
capture_output=True,
|
|
255
|
+
text=True,
|
|
256
|
+
timeout=DEEPEN_COMMAND_TIMEOUT_SECONDS,
|
|
257
|
+
check=False,
|
|
258
|
+
creationflags=hidden_window_creation_flags(),
|
|
259
|
+
)
|
|
260
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
261
|
+
return None
|
|
262
|
+
command_text = completed.stdout.strip()
|
|
263
|
+
if completed.returncode != 0 or not command_text:
|
|
264
|
+
return None
|
|
265
|
+
return _with_deepen_marker(command_text)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _default_rewrite(task_text: str) -> str:
|
|
269
|
+
return (
|
|
270
|
+
f"{DEEPEN_MARKER}\n\n"
|
|
271
|
+
"Do this task as a leading practitioner in its field would.\n\n"
|
|
272
|
+
f"{task_text}\n\n"
|
|
273
|
+
"Cover four things the original prompt left out:\n"
|
|
274
|
+
"1. What a top person in that field would do that this prompt omitted.\n"
|
|
275
|
+
"2. The raw source to start from.\n"
|
|
276
|
+
"3. The methods and tools they would use.\n"
|
|
277
|
+
"4. What besides the agent or Jon would show the result is right.\n"
|
|
278
|
+
"Then do exactly that job."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def expand_first_task(prompt: str) -> str:
|
|
283
|
+
"""Rewrite a first task so the worker does the omitted expert job."""
|
|
284
|
+
stub_rewrite = os.environ.get(DEEPEN_REWRITE_ENV_VAR, "").strip()
|
|
285
|
+
task_text = task_text_for_deepen(prompt)
|
|
286
|
+
if stub_rewrite:
|
|
287
|
+
return _with_deepen_marker(stub_rewrite.replace("{prompt}", task_text))
|
|
288
|
+
command_rewrite = _command_rewrite(task_text)
|
|
289
|
+
if command_rewrite is not None:
|
|
290
|
+
return command_rewrite
|
|
291
|
+
return _default_rewrite(task_text)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def additional_context_for(rewritten_prompt: str) -> str:
|
|
295
|
+
"""Return the context a command hook can inject beside the original prompt."""
|
|
296
|
+
return (
|
|
297
|
+
f"{DEEPEN_MARKER}\n"
|
|
298
|
+
"Treat the submitted prompt as this rewritten task and do only that job.\n\n"
|
|
299
|
+
f"{rewritten_prompt}"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def decide_first_prompt(all_hook_fields: dict[str, object]) -> DeepenDecision:
|
|
304
|
+
"""Skip or deepen one UserPromptSubmit payload."""
|
|
305
|
+
reason = skip_reason_for(all_hook_fields)
|
|
306
|
+
if reason is not None:
|
|
307
|
+
return Skip(reason)
|
|
308
|
+
rewritten_prompt = expand_first_task(submitted_prompt_text(all_hook_fields))
|
|
309
|
+
return Deepen(
|
|
310
|
+
rewritten_prompt=rewritten_prompt,
|
|
311
|
+
additional_context=additional_context_for(rewritten_prompt),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _print_skip_json(reason: SkipReason) -> None:
|
|
316
|
+
sys.stdout.write(json.dumps({"decision": "skip", "reason": reason.value}) + "\n")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _print_deepen_json(deepen: Deepen) -> None:
|
|
320
|
+
sys.stdout.write(
|
|
321
|
+
json.dumps(
|
|
322
|
+
{
|
|
323
|
+
"decision": "deepen",
|
|
324
|
+
"rewrittenPrompt": deepen.rewritten_prompt,
|
|
325
|
+
"additionalContext": deepen.additional_context,
|
|
326
|
+
}
|
|
327
|
+
)
|
|
328
|
+
+ "\n"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _print_additional_context(deepen: Deepen) -> None:
|
|
333
|
+
sys.stdout.write(
|
|
334
|
+
json.dumps(
|
|
335
|
+
{
|
|
336
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
337
|
+
HOOK_EVENT_NAME_KEY: USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
338
|
+
ADDITIONAL_CONTEXT_KEY: deepen.additional_context,
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
)
|
|
342
|
+
+ "\n"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def should_leave_rewrite_to_mod() -> bool:
|
|
347
|
+
"""Return True when the Claude prompt.submit mod will rewrite the visible text.
|
|
348
|
+
|
|
349
|
+
Claude Code sets ``CLAUDECODE=1`` for every command hook. Codex reads the same
|
|
350
|
+
Claude settings file but runs no Claude mod, so a Codex run keeps the envelope.
|
|
351
|
+
"""
|
|
352
|
+
is_claude_host = os.environ.get(CLAUDE_HOST_ENV_VAR, "").strip() == CLAUDE_HOST_TRUE_VALUE
|
|
353
|
+
return (
|
|
354
|
+
is_claude_host
|
|
355
|
+
and is_mod_plugin_enabled(DEEPEN_FIRST_PROMPT_PLUGIN_NAME)
|
|
356
|
+
and not is_cloud_session()
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def emit_decision(decision: DeepenDecision, all_hook_fields: dict[str, object]) -> None:
|
|
361
|
+
"""Write the host encoding for one decision."""
|
|
362
|
+
output_mode = os.environ.get(DEEPEN_OUTPUT_ENV_VAR, "").strip().lower()
|
|
363
|
+
if isinstance(decision, Skip):
|
|
364
|
+
if output_mode == DEEPEN_OUTPUT_JSON:
|
|
365
|
+
_print_skip_json(decision.reason)
|
|
366
|
+
return
|
|
367
|
+
write_turn_marker(all_hook_fields)
|
|
368
|
+
if output_mode == DEEPEN_OUTPUT_JSON:
|
|
369
|
+
_print_deepen_json(decision)
|
|
370
|
+
return
|
|
371
|
+
if output_mode == DEEPEN_OUTPUT_REWRITE:
|
|
372
|
+
sys.stdout.write(decision.rewritten_prompt)
|
|
373
|
+
if not decision.rewritten_prompt.endswith("\n"):
|
|
374
|
+
sys.stdout.write("\n")
|
|
375
|
+
return
|
|
376
|
+
if should_leave_rewrite_to_mod():
|
|
377
|
+
return
|
|
378
|
+
_print_additional_context(decision)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def main() -> None:
|
|
382
|
+
"""Read one UserPromptSubmit payload and emit a skip or a deepened rewrite."""
|
|
383
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
384
|
+
if hook_payload is None:
|
|
385
|
+
return
|
|
386
|
+
emit_decision(decide_first_prompt(hook_payload), hook_payload)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
if __name__ == "__main__":
|
|
390
|
+
main()
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Behavior tests for first-task deepening."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from io import StringIO
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest.mock import patch
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
15
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
16
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
17
|
+
if each_sys_path_entry not in sys.path:
|
|
18
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
19
|
+
|
|
20
|
+
import deepen_first_prompt as deepen
|
|
21
|
+
|
|
22
|
+
from hooks_constants.deepen_first_prompt_constants import (
|
|
23
|
+
DEEPEN_AGENT_MARKER,
|
|
24
|
+
DEEPEN_MARKER,
|
|
25
|
+
DEEPEN_OUTPUT_JSON,
|
|
26
|
+
DEEPEN_OUTPUT_REWRITE,
|
|
27
|
+
DEEPEN_REWRITE_ENV_VAR,
|
|
28
|
+
DEEPEN_TURN_MARKER_SUFFIX,
|
|
29
|
+
SKIP_DEEPEN_ENV_VAR,
|
|
30
|
+
USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
31
|
+
)
|
|
32
|
+
from hooks_constants.skill_loaded_reminder_constants import CLAUDE_SUBAGENT_PROMPT_PREFIX
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _payload(prompt: str, transcript_path: str | None = None) -> dict[str, object]:
|
|
36
|
+
fields: dict[str, object] = {
|
|
37
|
+
"hook_event_name": USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
38
|
+
"prompt": prompt,
|
|
39
|
+
"session_id": "session-one",
|
|
40
|
+
}
|
|
41
|
+
if transcript_path is not None:
|
|
42
|
+
fields["transcript_path"] = transcript_path
|
|
43
|
+
return fields
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _run_main(payload: dict[str, object]) -> str:
|
|
47
|
+
captured_stdout = StringIO()
|
|
48
|
+
with patch("sys.stdin", StringIO(json.dumps(payload))), patch("sys.stdout", captured_stdout):
|
|
49
|
+
deepen.main()
|
|
50
|
+
return captured_stdout.getvalue()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _user_transcript_line() -> str:
|
|
54
|
+
return json.dumps({"type": "user", "message": {"role": "user", "content": "earlier task"}})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestSkipReasonFor:
|
|
58
|
+
def test_should_skip_a_thanks_prompt(self) -> None:
|
|
59
|
+
assert deepen.skip_reason_for(_payload("thanks")) is deepen.SkipReason.TRIVIAL
|
|
60
|
+
|
|
61
|
+
def test_should_skip_a_yes_prompt(self) -> None:
|
|
62
|
+
assert deepen.skip_reason_for(_payload("Yes")) is deepen.SkipReason.TRIVIAL
|
|
63
|
+
|
|
64
|
+
def test_should_skip_status(self) -> None:
|
|
65
|
+
assert deepen.skip_reason_for(_payload("status")) is deepen.SkipReason.TRIVIAL
|
|
66
|
+
|
|
67
|
+
def test_should_skip_a_slash_command_other_than_deepen(self) -> None:
|
|
68
|
+
assert deepen.skip_reason_for(_payload("/sr-loop")) is deepen.SkipReason.SLASH_COMMAND
|
|
69
|
+
|
|
70
|
+
def test_should_skip_bare_deepen_with_no_task(self) -> None:
|
|
71
|
+
assert deepen.skip_reason_for(_payload("/deepen")) is deepen.SkipReason.SLASH_COMMAND
|
|
72
|
+
|
|
73
|
+
def test_should_allow_deepen_with_a_task(self) -> None:
|
|
74
|
+
assert deepen.skip_reason_for(_payload("/deepen fix the login page")) is None
|
|
75
|
+
|
|
76
|
+
def test_should_skip_a_loop_iteration(self) -> None:
|
|
77
|
+
assert (
|
|
78
|
+
deepen.skip_reason_for(_payload("/loop until the tests pass"))
|
|
79
|
+
is deepen.SkipReason.LOOP_OR_HELPER
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def test_should_skip_a_helper_report(self) -> None:
|
|
83
|
+
assert (
|
|
84
|
+
deepen.skip_reason_for(_payload("<task-notification> helper finished"))
|
|
85
|
+
is deepen.SkipReason.LOOP_OR_HELPER
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def test_should_skip_a_spawn_rewrite_prefix(self) -> None:
|
|
89
|
+
prompt = CLAUDE_SUBAGENT_PROMPT_PREFIX + "\n\nReply leaf."
|
|
90
|
+
assert deepen.skip_reason_for(_payload(prompt)) is deepen.SkipReason.SPAWN_REWRITE
|
|
91
|
+
|
|
92
|
+
def test_should_skip_when_the_prompt_already_carries_the_marker(self) -> None:
|
|
93
|
+
assert (
|
|
94
|
+
deepen.skip_reason_for(_payload(f"{DEEPEN_MARKER}\n\nDo the job."))
|
|
95
|
+
is deepen.SkipReason.MARKER_PRESENT
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def test_should_skip_the_deepen_agent_prompt(self) -> None:
|
|
99
|
+
prompt = f"{DEEPEN_AGENT_MARKER}\nAnswer the four deepening questions."
|
|
100
|
+
assert deepen.skip_reason_for(_payload(prompt)) is deepen.SkipReason.DEEPEN_AGENT
|
|
101
|
+
|
|
102
|
+
def test_should_skip_when_the_skip_flag_is_set(
|
|
103
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
104
|
+
) -> None:
|
|
105
|
+
monkeypatch.setenv(SKIP_DEEPEN_ENV_VAR, "1")
|
|
106
|
+
assert (
|
|
107
|
+
deepen.skip_reason_for(_payload("Add dark mode to settings"))
|
|
108
|
+
is deepen.SkipReason.SKIP_FLAG
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def test_should_skip_a_print_mode_session(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
112
|
+
monkeypatch.setenv("CLAUDE_CODE_NONINTERACTIVE", "true")
|
|
113
|
+
assert (
|
|
114
|
+
deepen.skip_reason_for(_payload("Add dark mode to settings"))
|
|
115
|
+
is deepen.SkipReason.NONINTERACTIVE
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def test_should_skip_codex_exec(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
119
|
+
monkeypatch.setenv("CODEX_EXEC", "1")
|
|
120
|
+
assert (
|
|
121
|
+
deepen.skip_reason_for(_payload("Add dark mode to settings"))
|
|
122
|
+
is deepen.SkipReason.NONINTERACTIVE
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def test_should_skip_turn_two_when_the_transcript_has_a_user_line(
|
|
126
|
+
self, tmp_path: Path
|
|
127
|
+
) -> None:
|
|
128
|
+
transcript_path = tmp_path / "session.jsonl"
|
|
129
|
+
transcript_path.write_text(_user_transcript_line() + "\n", encoding="utf-8")
|
|
130
|
+
assert (
|
|
131
|
+
deepen.skip_reason_for(_payload("Add dark mode", str(transcript_path)))
|
|
132
|
+
is deepen.SkipReason.LATER_TURN
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def test_should_deepen_a_first_task(self) -> None:
|
|
136
|
+
assert deepen.skip_reason_for(_payload("Add dark mode to the settings page")) is None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestDecideFirstPrompt:
|
|
140
|
+
def test_should_emit_additional_context_with_the_four_answers_for_a_first_task(
|
|
141
|
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
142
|
+
) -> None:
|
|
143
|
+
monkeypatch.setenv(
|
|
144
|
+
DEEPEN_REWRITE_ENV_VAR,
|
|
145
|
+
(
|
|
146
|
+
"Omitted: ship contrast tokens with the theme.\n"
|
|
147
|
+
"Source: src/settings/theme.ts\n"
|
|
148
|
+
"Methods: existing token file and a visual check.\n"
|
|
149
|
+
"Proof: screenshot of the settings page in dark mode."
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
transcript_path = tmp_path / "fresh.jsonl"
|
|
153
|
+
decision = deepen.decide_first_prompt(
|
|
154
|
+
_payload("Add dark mode to the settings page", str(transcript_path))
|
|
155
|
+
)
|
|
156
|
+
assert isinstance(decision, deepen.Deepen)
|
|
157
|
+
assert DEEPEN_MARKER in decision.rewritten_prompt
|
|
158
|
+
assert "Omitted: ship contrast tokens" in decision.rewritten_prompt
|
|
159
|
+
assert "src/settings/theme.ts" in decision.rewritten_prompt
|
|
160
|
+
assert "visual check" in decision.rewritten_prompt
|
|
161
|
+
assert "screenshot of the settings page" in decision.additional_context
|
|
162
|
+
assert decision.additional_context.startswith(DEEPEN_MARKER)
|
|
163
|
+
|
|
164
|
+
def test_should_keep_a_skip_silent_on_the_command_hook(
|
|
165
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
166
|
+
) -> None:
|
|
167
|
+
monkeypatch.delenv("CDE_DEEPEN_OUTPUT", raising=False)
|
|
168
|
+
assert _run_main(_payload("thanks")) == ""
|
|
169
|
+
|
|
170
|
+
def test_should_write_a_turn_marker_so_turn_two_stays_silent(
|
|
171
|
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
172
|
+
) -> None:
|
|
173
|
+
monkeypatch.setenv(DEEPEN_REWRITE_ENV_VAR, "Deeper task body")
|
|
174
|
+
transcript_path = tmp_path / "fresh.jsonl"
|
|
175
|
+
first_stdout = _run_main(_payload("Add dark mode to the settings page", str(transcript_path)))
|
|
176
|
+
assert DEEPEN_MARKER in first_stdout
|
|
177
|
+
marker_path = Path(str(transcript_path) + DEEPEN_TURN_MARKER_SUFFIX)
|
|
178
|
+
assert marker_path.is_file()
|
|
179
|
+
second_stdout = _run_main(_payload("and also the billing page", str(transcript_path)))
|
|
180
|
+
assert second_stdout == ""
|
|
181
|
+
|
|
182
|
+
def test_should_print_rewrite_text_for_the_claude_mod(
|
|
183
|
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
184
|
+
) -> None:
|
|
185
|
+
monkeypatch.setenv(DEEPEN_REWRITE_ENV_VAR, "Deeper task body")
|
|
186
|
+
monkeypatch.setenv("CDE_DEEPEN_OUTPUT", DEEPEN_OUTPUT_REWRITE)
|
|
187
|
+
transcript_path = tmp_path / "fresh.jsonl"
|
|
188
|
+
printed = _run_main(_payload("Add dark mode to the settings page", str(transcript_path)))
|
|
189
|
+
assert printed.startswith(DEEPEN_MARKER)
|
|
190
|
+
assert "Deeper task body" in printed
|
|
191
|
+
assert "hookSpecificOutput" not in printed
|
|
192
|
+
|
|
193
|
+
def test_should_print_json_decisions_for_the_playtest(
|
|
194
|
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
195
|
+
) -> None:
|
|
196
|
+
monkeypatch.setenv(DEEPEN_REWRITE_ENV_VAR, "Deeper task body")
|
|
197
|
+
monkeypatch.setenv("CDE_DEEPEN_OUTPUT", DEEPEN_OUTPUT_JSON)
|
|
198
|
+
transcript_path = tmp_path / "fresh.jsonl"
|
|
199
|
+
deepen_payload = json.loads(
|
|
200
|
+
_run_main(_payload("Add dark mode to the settings page", str(transcript_path)))
|
|
201
|
+
)
|
|
202
|
+
assert deepen_payload["decision"] == "deepen"
|
|
203
|
+
skip_payload = json.loads(_run_main(_payload("thanks")))
|
|
204
|
+
assert skip_payload == {"decision": "skip", "reason": "trivial"}
|
|
205
|
+
|
|
206
|
+
def test_should_stay_silent_when_the_claude_mod_is_on(
|
|
207
|
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
208
|
+
) -> None:
|
|
209
|
+
config_directory = tmp_path / "claude-config"
|
|
210
|
+
config_directory.mkdir()
|
|
211
|
+
(config_directory / "settings.json").write_text(
|
|
212
|
+
json.dumps({"enabledPlugins": {"deepen-first-prompt@claude-dev-env": True}}),
|
|
213
|
+
encoding="utf-8",
|
|
214
|
+
)
|
|
215
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_directory))
|
|
216
|
+
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path))
|
|
217
|
+
monkeypatch.setenv("CLAUDECODE", "1")
|
|
218
|
+
monkeypatch.delenv("CLAUDE_CODE_REMOTE", raising=False)
|
|
219
|
+
monkeypatch.setenv(DEEPEN_REWRITE_ENV_VAR, "Deeper task body")
|
|
220
|
+
transcript_path = tmp_path / "fresh.jsonl"
|
|
221
|
+
assert _run_main(_payload("Add dark mode to the settings page", str(transcript_path))) == ""
|
|
222
|
+
|
|
223
|
+
def test_should_emit_context_for_codex_when_the_claude_mod_is_on(
|
|
224
|
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
225
|
+
) -> None:
|
|
226
|
+
config_directory = tmp_path / "claude-config"
|
|
227
|
+
config_directory.mkdir()
|
|
228
|
+
(config_directory / "settings.json").write_text(
|
|
229
|
+
json.dumps({"enabledPlugins": {"deepen-first-prompt@claude-dev-env": True}}),
|
|
230
|
+
encoding="utf-8",
|
|
231
|
+
)
|
|
232
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_directory))
|
|
233
|
+
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path))
|
|
234
|
+
monkeypatch.delenv("CLAUDECODE", raising=False)
|
|
235
|
+
monkeypatch.delenv("CLAUDE_CODE_REMOTE", raising=False)
|
|
236
|
+
monkeypatch.setenv(DEEPEN_REWRITE_ENV_VAR, "Deeper task body")
|
|
237
|
+
transcript_path = tmp_path / "codex-fresh.jsonl"
|
|
238
|
+
printed = _run_main(_payload("Add dark mode to the settings page", str(transcript_path)))
|
|
239
|
+
envelope = json.loads(printed)
|
|
240
|
+
assert envelope["hookSpecificOutput"]["hookEventName"] == USER_PROMPT_SUBMIT_EVENT_NAME
|
|
241
|
+
assert "Deeper task body" in envelope["hookSpecificOutput"]["additionalContext"]
|
|
242
|
+
|
|
243
|
+
def test_should_still_emit_context_in_a_cloud_session_when_the_mod_is_on(
|
|
244
|
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
245
|
+
) -> None:
|
|
246
|
+
config_directory = tmp_path / "claude-config"
|
|
247
|
+
config_directory.mkdir()
|
|
248
|
+
(config_directory / "settings.json").write_text(
|
|
249
|
+
json.dumps({"enabledPlugins": {"deepen-first-prompt@claude-dev-env": True}}),
|
|
250
|
+
encoding="utf-8",
|
|
251
|
+
)
|
|
252
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_directory))
|
|
253
|
+
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path))
|
|
254
|
+
monkeypatch.setenv("CLAUDE_CODE_REMOTE", "true")
|
|
255
|
+
monkeypatch.setenv(DEEPEN_REWRITE_ENV_VAR, "Deeper task body")
|
|
256
|
+
transcript_path = tmp_path / "fresh.jsonl"
|
|
257
|
+
printed = _run_main(_payload("Add dark mode to the settings page", str(transcript_path)))
|
|
258
|
+
envelope = json.loads(printed)
|
|
259
|
+
assert envelope["hookSpecificOutput"]["hookEventName"] == USER_PROMPT_SUBMIT_EVENT_NAME
|
|
260
|
+
assert "Deeper task body" in envelope["hookSpecificOutput"]["additionalContext"]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_should_strip_the_deepen_slash_before_rewriting() -> None:
|
|
264
|
+
assert deepen.task_text_for_deepen("/deepen fix the login page") == "fix the login page"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_should_name_the_four_omitted_answers_in_the_default_rewrite() -> None:
|
|
268
|
+
rewritten = deepen.expand_first_task("Add dark mode to the settings page")
|
|
269
|
+
assert rewritten.startswith(DEEPEN_MARKER)
|
|
270
|
+
assert "Add dark mode to the settings page" in rewritten
|
|
271
|
+
assert "What a top person in that field would do that this prompt omitted." in rewritten
|
|
272
|
+
assert "The raw source to start from." in rewritten
|
|
273
|
+
assert "The methods and tools they would use." in rewritten
|
|
274
|
+
assert "What besides the agent or Jon would show the result is right." in rewritten
|