claude-dev-env 8.87.0 → 8.88.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.md +16 -71
- package/bin/cursor-agents-guidance.mjs +94 -0
- package/bin/cursor-agents-guidance.test.mjs +94 -0
- package/bin/cursor-native-hooks.mjs +128 -0
- package/bin/cursor-native-hooks.test.mjs +78 -0
- package/bin/install-constants.mjs +29 -0
- package/bin/install-deepstack-plugin.mjs +84 -39
- package/bin/install-deepstack-plugin.test.mjs +51 -11
- package/bin/install.cursor-rules.test.mjs +65 -2
- package/bin/install.mjs +35 -0
- package/bin/install.test.mjs +10 -5
- package/docs/agents-guidance/asks.md +49 -0
- package/docs/agents-guidance/banned-word.md +13 -0
- package/docs/agents-guidance/evidence.md +11 -0
- package/docs/agents-guidance/prose.md +34 -0
- package/docs/agents-guidance/tools.md +9 -0
- package/hooks/blocking/open_tasks_stop_gate.py +210 -0
- package/hooks/blocking/open_tasks_transcript.py +270 -0
- package/hooks/blocking/test_open_tasks_stop_gate.py +297 -0
- package/hooks/blocking/test_open_tasks_transcript.py +54 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/cursor_agents_guidance_constants.py +6 -0
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +72 -0
- package/hooks/session/cursor_agents_guidance.py +54 -0
- package/hooks/session/test_cursor_agents_guidance.py +24 -0
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +6 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import open_tasks_stop_gate
|
|
10
|
+
|
|
11
|
+
GATE_PATH = Path(open_tasks_stop_gate.__file__)
|
|
12
|
+
SESSION_ID = "s-123"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture(autouse=True)
|
|
16
|
+
def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
17
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
18
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
19
|
+
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
|
20
|
+
monkeypatch.delenv("CLAUDE_CODE_TASK_LIST_ID", raising=False)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_gate(
|
|
24
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
25
|
+
capsys: pytest.CaptureFixture[str],
|
|
26
|
+
reply_text: str,
|
|
27
|
+
transcript_path: Path | None = None,
|
|
28
|
+
) -> tuple[int, str]:
|
|
29
|
+
hook_input: dict[str, object] = {
|
|
30
|
+
"hook_event_name": "Stop",
|
|
31
|
+
"session_id": SESSION_ID,
|
|
32
|
+
"stop_hook_active": False,
|
|
33
|
+
"last_assistant_message": reply_text,
|
|
34
|
+
}
|
|
35
|
+
if transcript_path is not None:
|
|
36
|
+
hook_input["transcript_path"] = str(transcript_path)
|
|
37
|
+
monkeypatch.setattr(
|
|
38
|
+
"sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
|
|
39
|
+
)
|
|
40
|
+
exit_code = open_tasks_stop_gate.main()
|
|
41
|
+
return exit_code, capsys.readouterr().out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def write_task_store(home_directory: Path, all_tasks: list[dict[str, str]]) -> None:
|
|
45
|
+
task_directory = home_directory / ".claude" / "tasks" / SESSION_ID
|
|
46
|
+
task_directory.mkdir(parents=True)
|
|
47
|
+
for each_task in all_tasks:
|
|
48
|
+
(task_directory / f"{each_task['id']}.json").write_text(json.dumps(each_task), encoding="utf-8")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write_todo_transcript(transcript_path: Path, all_todos: list[dict[str, str]]) -> Path:
|
|
52
|
+
tool_use = {"type": "tool_use", "id": "t1", "name": "TodoWrite", "input": {"todos": all_todos}}
|
|
53
|
+
transcript_path.write_text(
|
|
54
|
+
json.dumps({"type": "assistant", "message": {"content": [tool_use]}}), encoding="utf-8"
|
|
55
|
+
)
|
|
56
|
+
return transcript_path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_should_block_when_a_pending_task_is_not_reported(
|
|
60
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
61
|
+
) -> None:
|
|
62
|
+
write_task_store(tmp_path, [{"id": "1", "subject": "Rotate the deploy key", "status": "pending"}])
|
|
63
|
+
exit_code, stdout_text = run_gate(monkeypatch, capsys, "All done.")
|
|
64
|
+
decision = json.loads(stdout_text)
|
|
65
|
+
assert exit_code == 0
|
|
66
|
+
assert decision["decision"] == "block"
|
|
67
|
+
assert "Rotate the deploy key" in decision["reason"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_should_allow_when_every_open_task_is_reported_as_blocked_or_open(
|
|
71
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
72
|
+
) -> None:
|
|
73
|
+
write_task_store(
|
|
74
|
+
tmp_path,
|
|
75
|
+
[
|
|
76
|
+
{"id": "1", "subject": "Rotate the deploy key", "status": "pending"},
|
|
77
|
+
{"id": "2", "subject": "Wait on checks", "status": "in_progress"},
|
|
78
|
+
{"id": "3", "subject": "Write tests", "status": "completed"},
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
reply_text = "Tests written.\nBlocked: Rotate the deploy key - the owner approves the secret\n- Open: #2 - checks running"
|
|
82
|
+
assert run_gate(monkeypatch, capsys, reply_text) == (0, "")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_should_block_when_the_item_is_only_mentioned_in_prose(
|
|
86
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
87
|
+
) -> None:
|
|
88
|
+
write_task_store(tmp_path, [{"id": "1", "subject": "Rotate the deploy key", "status": "pending"}])
|
|
89
|
+
_, stdout_text = run_gate(monkeypatch, capsys, "I will rotate the deploy key later.")
|
|
90
|
+
assert json.loads(stdout_text)["decision"] == "block"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_should_allow_when_every_task_is_completed(
|
|
94
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
95
|
+
) -> None:
|
|
96
|
+
write_task_store(tmp_path, [{"id": "1", "subject": "Rotate the deploy key", "status": "completed"}])
|
|
97
|
+
assert run_gate(monkeypatch, capsys, "Done.") == (0, "")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_should_read_todowrite_from_the_transcript_when_no_task_store_exists(
|
|
101
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
102
|
+
) -> None:
|
|
103
|
+
transcript_path = write_todo_transcript(
|
|
104
|
+
tmp_path / "s.jsonl",
|
|
105
|
+
[{"content": "Ship the fix", "status": "in_progress"}, {"content": "Lint", "status": "completed"}],
|
|
106
|
+
)
|
|
107
|
+
_, stdout_text = run_gate(monkeypatch, capsys, "Done.", transcript_path)
|
|
108
|
+
reason_text = json.loads(stdout_text)["reason"]
|
|
109
|
+
assert "Ship the fix" in reason_text
|
|
110
|
+
assert "Lint" not in reason_text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_should_replay_taskcreate_and_taskupdate_from_the_transcript(
|
|
114
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
115
|
+
) -> None:
|
|
116
|
+
transcript_records = [
|
|
117
|
+
{"type": "assistant", "message": {"content": [{"type": "tool_use", "id": "c1", "name": "TaskCreate", "input": {"subject": "Alpha"}}]}},
|
|
118
|
+
{"type": "user", "message": {"content": [{"type": "tool_result", "tool_use_id": "c1", "content": "Task #1 created successfully: Alpha"}]}},
|
|
119
|
+
{"type": "assistant", "message": {"content": [{"type": "tool_use", "id": "c2", "name": "TaskCreate", "input": {"subject": "Beta"}}]}},
|
|
120
|
+
{"type": "user", "message": {"content": [{"type": "tool_result", "tool_use_id": "c2", "content": "Task #2 created successfully: Beta"}]}},
|
|
121
|
+
{"type": "assistant", "message": {"content": [{"type": "tool_use", "id": "u1", "name": "TaskUpdate", "input": {"taskId": "1", "status": "completed"}}]}},
|
|
122
|
+
]
|
|
123
|
+
transcript_path = tmp_path / "s.jsonl"
|
|
124
|
+
transcript_path.write_text("\n".join(json.dumps(each) for each in transcript_records), encoding="utf-8")
|
|
125
|
+
_, stdout_text = run_gate(monkeypatch, capsys, "Done.", transcript_path)
|
|
126
|
+
reason_text = json.loads(stdout_text)["reason"]
|
|
127
|
+
assert "Beta" in reason_text
|
|
128
|
+
assert "Alpha" not in reason_text
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_should_read_codex_update_plan_from_the_rollout(
|
|
132
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
133
|
+
) -> None:
|
|
134
|
+
plan_arguments = json.dumps({"plan": [{"step": "Port the hook", "status": "pending"}]})
|
|
135
|
+
rollout_record = {"type": "response_item", "payload": {"type": "function_call", "name": "update_plan", "arguments": plan_arguments}}
|
|
136
|
+
transcript_path = tmp_path / "rollout.jsonl"
|
|
137
|
+
transcript_path.write_text(json.dumps(rollout_record), encoding="utf-8")
|
|
138
|
+
_, stdout_text = run_gate(monkeypatch, capsys, "Done.", transcript_path)
|
|
139
|
+
assert "Port the hook" in json.loads(stdout_text)["reason"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_should_allow_a_session_with_no_task_list(
|
|
143
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
144
|
+
) -> None:
|
|
145
|
+
assert run_gate(monkeypatch, capsys, "Done.") == (0, "")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_should_allow_malformed_stdin(
|
|
149
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
150
|
+
) -> None:
|
|
151
|
+
monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(b"not json")))
|
|
152
|
+
assert (open_tasks_stop_gate.main(), capsys.readouterr().out) == (0, "")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_should_block_through_a_subprocess_with_the_documented_contract(tmp_path: Path) -> None:
|
|
156
|
+
write_task_store(tmp_path, [{"id": "1", "subject": "Rotate the deploy key", "status": "pending"}])
|
|
157
|
+
hook_input = {"hook_event_name": "Stop", "session_id": SESSION_ID, "last_assistant_message": "Done."}
|
|
158
|
+
completed_process = subprocess.run(
|
|
159
|
+
[sys.executable, str(GATE_PATH)],
|
|
160
|
+
input=json.dumps(hook_input),
|
|
161
|
+
capture_output=True,
|
|
162
|
+
text=True,
|
|
163
|
+
env={"HOME": str(tmp_path), "USERPROFILE": str(tmp_path), "PATH": ""},
|
|
164
|
+
check=False,
|
|
165
|
+
)
|
|
166
|
+
assert completed_process.returncode == 0
|
|
167
|
+
assert json.loads(completed_process.stdout)["decision"] == "block"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def write_cursor_transcript(transcript_path: Path, all_records: list[dict[str, object]]) -> Path:
|
|
171
|
+
transcript_path.write_text("\n".join(json.dumps(each_record) for each_record in all_records), encoding="utf-8")
|
|
172
|
+
return transcript_path
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cursor_todo_record(all_todos: list[dict[str, str]], is_merge: bool, reply_text: str) -> dict[str, object]:
|
|
176
|
+
tool_use = {"type": "tool_use", "name": "TodoWrite", "input": {"todos": all_todos, "merge": is_merge}}
|
|
177
|
+
return {"role": "assistant", "message": {"content": [{"type": "text", "text": reply_text}, tool_use]}}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def run_cursor_gate(
|
|
181
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
182
|
+
capsys: pytest.CaptureFixture[str],
|
|
183
|
+
transcript_path: Path,
|
|
184
|
+
stop_status: str = "completed",
|
|
185
|
+
loop_count: int = 0,
|
|
186
|
+
) -> dict[str, str]:
|
|
187
|
+
hook_input = {
|
|
188
|
+
"hook_event_name": "stop",
|
|
189
|
+
"conversation_id": "c-1",
|
|
190
|
+
"status": stop_status,
|
|
191
|
+
"loop_count": loop_count,
|
|
192
|
+
"transcript_path": str(transcript_path),
|
|
193
|
+
}
|
|
194
|
+
monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8"))))
|
|
195
|
+
open_tasks_stop_gate.main()
|
|
196
|
+
printed_output = capsys.readouterr().out
|
|
197
|
+
return json.loads(printed_output) if printed_output else {}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_should_send_a_cursor_followup_when_a_merged_todo_stays_pending(
|
|
201
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
202
|
+
) -> None:
|
|
203
|
+
all_first_todos = [
|
|
204
|
+
{"id": "1", "content": "write hello.txt", "status": "in_progress"},
|
|
205
|
+
{"id": "2", "content": "rotate the deploy key", "status": "pending"},
|
|
206
|
+
]
|
|
207
|
+
transcript_path = write_cursor_transcript(
|
|
208
|
+
tmp_path / "cursor.jsonl",
|
|
209
|
+
[
|
|
210
|
+
cursor_todo_record(all_first_todos, False, "Starting."),
|
|
211
|
+
cursor_todo_record([{"id": "1", "status": "completed"}], True, "Done."),
|
|
212
|
+
],
|
|
213
|
+
)
|
|
214
|
+
block_output = run_cursor_gate(monkeypatch, capsys, transcript_path)
|
|
215
|
+
assert "rotate the deploy key" in block_output["followup_message"]
|
|
216
|
+
assert "write hello.txt" not in block_output["followup_message"]
|
|
217
|
+
assert block_output["decision"] == "block"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@pytest.mark.parametrize("stop_status", ["aborted", "error"])
|
|
221
|
+
def test_should_allow_a_cursor_stop_the_user_aborted_or_that_errored(
|
|
222
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path, stop_status: str
|
|
223
|
+
) -> None:
|
|
224
|
+
all_todos = [{"id": "2", "content": "rotate the deploy key", "status": "pending"}]
|
|
225
|
+
transcript_path = write_cursor_transcript(
|
|
226
|
+
tmp_path / "cursor.jsonl", [cursor_todo_record(all_todos, False, "Stopping.")]
|
|
227
|
+
)
|
|
228
|
+
assert run_cursor_gate(monkeypatch, capsys, transcript_path, stop_status) == {}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_should_allow_cursor_when_the_last_assistant_text_reports_the_item(
|
|
232
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
233
|
+
) -> None:
|
|
234
|
+
all_todos = [{"id": "2", "content": "rotate the deploy key", "status": "pending"}]
|
|
235
|
+
transcript_path = write_cursor_transcript(
|
|
236
|
+
tmp_path / "cursor.jsonl",
|
|
237
|
+
[
|
|
238
|
+
cursor_todo_record(all_todos, False, "Working."),
|
|
239
|
+
{"role": "assistant", "message": {"content": [
|
|
240
|
+
{"type": "text", "text": "Blocked: rotate the deploy key - the owner must approve the org secret"}
|
|
241
|
+
]}},
|
|
242
|
+
],
|
|
243
|
+
)
|
|
244
|
+
assert run_cursor_gate(monkeypatch, capsys, transcript_path) == {}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_should_leave_claude_block_output_without_a_followup_message(
|
|
248
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
249
|
+
) -> None:
|
|
250
|
+
transcript_path = write_todo_transcript(
|
|
251
|
+
tmp_path / "t.jsonl", [{"content": "rotate the deploy key", "status": "pending"}]
|
|
252
|
+
)
|
|
253
|
+
_, printed_output = run_gate(monkeypatch, capsys, "Done.", transcript_path)
|
|
254
|
+
assert set(json.loads(printed_output)) == {"decision", "reason"}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def cursor_dynamic_todo_record(all_todos: list[dict[str, str]], is_merge: bool) -> dict[str, object]:
|
|
258
|
+
tool_use = {
|
|
259
|
+
"type": "tool_use",
|
|
260
|
+
"name": "CallDynamicTool",
|
|
261
|
+
"input": {"namespace": "cursor", "toolName": "TodoWrite", "arguments": {"merge": is_merge, "todos": all_todos}},
|
|
262
|
+
}
|
|
263
|
+
return {"role": "assistant", "message": {"content": [tool_use]}}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_should_read_todos_the_cursor_cli_writes_through_call_dynamic_tool(
|
|
267
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
268
|
+
) -> None:
|
|
269
|
+
all_first_todos = [
|
|
270
|
+
{"id": "1", "content": "write hello.txt", "status": "in_progress"},
|
|
271
|
+
{"id": "2", "content": "rotate the deploy key", "status": "pending"},
|
|
272
|
+
]
|
|
273
|
+
all_merged_todos = [
|
|
274
|
+
{"id": "1", "content": "write hello.txt", "status": "completed"},
|
|
275
|
+
{"id": "2", "content": "rotate the deploy key", "status": "pending"},
|
|
276
|
+
]
|
|
277
|
+
transcript_path = write_cursor_transcript(
|
|
278
|
+
tmp_path / "cursor.jsonl",
|
|
279
|
+
[
|
|
280
|
+
cursor_dynamic_todo_record(all_first_todos, False),
|
|
281
|
+
cursor_dynamic_todo_record(all_merged_todos, True),
|
|
282
|
+
{"role": "assistant", "message": {"content": [{"type": "text", "text": "Done."}]}},
|
|
283
|
+
{"type": "turn_ended", "status": "success"},
|
|
284
|
+
],
|
|
285
|
+
)
|
|
286
|
+
block_output = run_cursor_gate(monkeypatch, capsys, transcript_path)
|
|
287
|
+
assert "rotate the deploy key" in block_output["followup_message"]
|
|
288
|
+
assert "write hello.txt" not in block_output["followup_message"]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_should_allow_a_cursor_stop_after_five_followups(
|
|
292
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
293
|
+
) -> None:
|
|
294
|
+
all_todos = [{"id": "2", "content": "rotate the deploy key", "status": "pending"}]
|
|
295
|
+
transcript_path = write_cursor_transcript(tmp_path / "cursor.jsonl", [cursor_todo_record(all_todos, False, "Done.")])
|
|
296
|
+
assert "rotate the deploy key" in run_cursor_gate(monkeypatch, capsys, transcript_path, loop_count=4)["followup_message"]
|
|
297
|
+
assert run_cursor_gate(monkeypatch, capsys, transcript_path, loop_count=5) == {}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from open_tasks_transcript import TaskItem, parsed_object, replayed_transcript, unwrapped_tool_call
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def write_transcript(transcript_path: Path, all_records: list[dict[str, object]]) -> Path:
|
|
8
|
+
transcript_path.write_text("\n".join(json.dumps(each_record) for each_record in all_records), encoding="utf-8")
|
|
9
|
+
return transcript_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def dynamic_todo_record(all_todos: list[dict[str, str]], is_merge: bool) -> dict[str, object]:
|
|
13
|
+
tool_use = {
|
|
14
|
+
"type": "tool_use",
|
|
15
|
+
"name": "CallDynamicTool",
|
|
16
|
+
"input": {"namespace": "cursor", "toolName": "TodoWrite", "arguments": {"merge": is_merge, "todos": all_todos}},
|
|
17
|
+
}
|
|
18
|
+
return {"role": "assistant", "message": {"content": [tool_use]}}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_should_unwrap_a_call_dynamic_tool_todo_write_call() -> None:
|
|
22
|
+
all_arguments = {"merge": False, "todos": []}
|
|
23
|
+
wrapped_input = {"namespace": "cursor", "toolName": "TodoWrite", "arguments": all_arguments}
|
|
24
|
+
assert unwrapped_tool_call("CallDynamicTool", wrapped_input) == ("TodoWrite", all_arguments)
|
|
25
|
+
assert unwrapped_tool_call("Read", wrapped_input) == ("Read", wrapped_input)
|
|
26
|
+
assert unwrapped_tool_call("CallDynamicTool", {"toolName": "TodoWrite"}) == ("CallDynamicTool", {"toolName": "TodoWrite"})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_should_replay_cursor_dynamic_todo_writes_and_the_last_reply(tmp_path: Path) -> None:
|
|
30
|
+
all_first_todos = [
|
|
31
|
+
{"id": "1", "content": "write hello.txt", "status": "in_progress"},
|
|
32
|
+
{"id": "2", "content": "rotate the deploy key", "status": "pending"},
|
|
33
|
+
]
|
|
34
|
+
transcript_path = write_transcript(
|
|
35
|
+
tmp_path / "cursor.jsonl",
|
|
36
|
+
[
|
|
37
|
+
dynamic_todo_record(all_first_todos, False),
|
|
38
|
+
dynamic_todo_record([{"id": "1", "content": "write hello.txt", "status": "completed"}], True),
|
|
39
|
+
{"role": "assistant", "message": {"content": [{"type": "text", "text": "Done."}]}},
|
|
40
|
+
{"type": "turn_ended", "status": "success"},
|
|
41
|
+
],
|
|
42
|
+
)
|
|
43
|
+
replay = replayed_transcript(str(transcript_path))
|
|
44
|
+
assert replay.items() == [
|
|
45
|
+
TaskItem("1", "write hello.txt", "completed"),
|
|
46
|
+
TaskItem("2", "rotate the deploy key", "pending"),
|
|
47
|
+
]
|
|
48
|
+
assert replay.last_assistant_text == "Done."
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_should_parse_only_json_objects() -> None:
|
|
52
|
+
assert parsed_object('{"a": 1}') == {"a": 1}
|
|
53
|
+
assert parsed_object("[1]") is None
|
|
54
|
+
assert parsed_object("not json") is None
|
|
@@ -6,6 +6,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
6
6
|
|
|
7
7
|
- `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link. It denies a sentence that guesses, estimates, hedges a duration, or admits a skipped check, such as "I didn't confirm this one." It denies a cited Actions run, pull request, or run, job, or check id that no successful tool result in the transcript shows; a subagent's report is the result of its Agent call. A question card from `AskUserQuestion` gets the banned word check and `unvalidated_claim_violation`, like a decision card. With visual reply mode on, it also runs the checks in `visual_reply_rules.py` from the rule list in `rules/visual-reply-rules.json`: no abbreviation or tracker number outside a link, a widget or page before a reply of more than one sentence, and no anchor link inside widget code. The mode is off by default; `~/.claude/visual-reply-mode.json` with `{"enabled": true}` turns the mode checks on.
|
|
8
8
|
- `blocking/final_reply_gate.py` applies the banned words, `unvalidated_claim_violation`, and `unseen_citation_violation` from the reply gate to the reply a session ends its turn with.
|
|
9
|
+
- `blocking/open_tasks_stop_gate.py` holds the end of a turn while the session task list has items that are neither completed nor named on a `Blocked:` or `Open:` line of the closing reply. `blocking/open_tasks_transcript.py` rebuilds the task list and the last reply from the transcript for it.
|
|
9
10
|
- The claim judge is a `prompt` hook in `hooks.json`, with no `model` set, so Claude Code's small default hook model reads each chat message and denies the first claim about how code, a file, a tool, a service, a run, or a process behaves, or why something happened, when nothing in the message shows it: inline code that quotes output or a code line, a file and line, or a link. A pull request link covers what that change does, what it blocks or lets through, and its checks. The author's own actions, a restatement of the user's words, questions, instructions, and a decision card's option consequences pass. It catches the plain claims the causal word list in `blocking/reply_length_gate.py` misses, such as "The code lets any of them rewrite it mid-check." `continueOnBlock` returns the reason to the agent as the tool error, so it can add the source and send again. The installer owns it through the `claude-dev-env: ` prefix on its `statusMessage`. It skips `post_widget`, whose input is SVG or HTML markup.
|
|
10
11
|
- `blocking/chat_reply_gate.py` runs the `reply_length_gate.py` reply checks on a plain chat reply, which is no tool call. On `UserPromptSubmit` it adds the reply rules as `additionalContext`. On `MessageDisplay` it holds each streamed batch until the message ends. A message that passes shows whole. A message that fails shows as a draft from `claude -p` on Haiku that passes the same checks, and the original shows when two drafts fail. The draft changes only the text on screen, so the next `UserPromptSubmit` tells the agent the text the user saw, and `SessionEnd` deletes the stored draft. The [reply arena harness](https://github.com/jl-cmd-projects/claude-dev-env/tree/160349b90f627ea542fc6bc7b2b7f86dcbf98b73/tests/reply_arena) ran the comparison that chose this display path.
|
|
11
12
|
- `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
|
|
@@ -23,6 +24,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
23
24
|
|
|
24
25
|
- `blocking/reply_length_gate.py` runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__update_message|mcp__hearthbot__ask_decision|AskUserQuestion|mcp__hearthbot__post_widget`, timeout `10` seconds in `hooks.json`.
|
|
25
26
|
- `blocking/final_reply_gate.py` runs on `Stop`, timeout `10` seconds in `hooks.json`.
|
|
27
|
+
- `blocking/open_tasks_stop_gate.py` runs on `Stop`, timeout `10` seconds in `hooks.json`. The installer also registers it on Codex `Stop` and as a native Cursor `stop` entry in `~/.cursor/hooks.json`, where a block also returns `followup_message`. A Cursor stop with status `aborted` or `error` passes. A Cursor stop whose `loop_count` is 5 or more passes, because a copy Cursor imports from `~/.claude/settings.json` has no follow-up limit.
|
|
26
28
|
- The claim judge runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__update_message|mcp__hearthbot__ask_decision`, timeout `30` seconds in `hooks.json`.
|
|
27
29
|
- `blocking/chat_reply_gate.py` runs on `UserPromptSubmit`, matcher empty, timeout `10` seconds, on `MessageDisplay`, timeout `100` seconds, and on `SessionEnd`, timeout `3` seconds, in `hooks.json`.
|
|
28
30
|
- `blocking/edit_marker_gate.py` runs on `PreToolUse`, matcher `mcp__.*__update_message`, timeout `10` seconds in `hooks.json`.
|
|
@@ -44,6 +46,7 @@ Preconditions:
|
|
|
44
46
|
|
|
45
47
|
- **Reply length.** Input is a reply with four sentences, a plain pull request number, an abbreviation, two sentences with no widget this turn, or widget code with an anchor link. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count, link, or mode reason. Widget code with no flowchart passes with a flowchart reminder in `additionalContext`.
|
|
46
48
|
- **Final reply.** Input is a closing reply that says "My guess is one to two hours." or cites a run no tool result shows. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_final_reply_gate.py -q`. The adjacent test observes a `block` decision that quotes the sentence or the citation; a reply that cites a run a tool result shows passes.
|
|
49
|
+
- **Open tasks.** Input is a session whose task list still has "rotate the deploy key" pending and a closing reply of "Done.". Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_open_tasks_stop_gate.py -q`. The adjacent test observes a `block` decision that names the item; a reply line `Blocked: rotate the deploy key - <what the user must give>` passes.
|
|
47
50
|
- **Claim judge.** Input is a headless `claude -p` session with a stub MCP server named `hearthbot` that exposes `reply` and `ask_decision`, told to send "The code lets any of them rewrite it mid-check." The session settings add the `hooks.json` prompt hook. The stub logs zero bytes sent, and the tool error reads `PreToolUse:mcp__hearthbot__reply hook error: [<the prompt>]: Fact with no source: ...`. The same sentence with a `file:line` in backticks posts. A hook whose `model` Claude Code does not recognize lets every message through, so leave `model` unset. Run `node --test packages/claude-dev-env/bin/install.prompt-hook.test.mjs` for the installer side; it observes one managed prompt hook after two installs and none after uninstall.
|
|
48
51
|
- **Chat reply.** Input is a `MessageDisplay` event whose message holds four sentences, the same message split over two batches, and a `UserPromptSubmit` event. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_chat_reply_gate.py -q`. The adjacent test observes the drafter's text in `displayContent`, an empty first batch, no output for three sentences, the original after two failed drafts, the shown draft in the next `additionalContext`, and the stored draft deleted at session end.
|
|
49
52
|
- **Edit marker.** Input is an update message with strikethrough or an edit note. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_edit_marker_gate.py -q`. The adjacent test observes a denial; clean replacement text passes.
|
package/hooks/hooks.json
CHANGED
|
@@ -379,6 +379,11 @@
|
|
|
379
379
|
"type": "command",
|
|
380
380
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/final_reply_gate.py",
|
|
381
381
|
"timeout": 10
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
"type": "command",
|
|
385
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/open_tasks_stop_gate.py",
|
|
386
|
+
"timeout": 10
|
|
382
387
|
}
|
|
383
388
|
]
|
|
384
389
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Constants for the open tasks Stop gate."""
|
|
2
|
+
|
|
3
|
+
HOOK_EVENT_NAME = "Stop"
|
|
4
|
+
SESSION_ID_KEY = "session_id"
|
|
5
|
+
TRANSCRIPT_PATH_KEY = "transcript_path"
|
|
6
|
+
LAST_ASSISTANT_MESSAGE_KEY = "last_assistant_message"
|
|
7
|
+
DECISION_KEY = "decision"
|
|
8
|
+
BLOCK_DECISION = "block"
|
|
9
|
+
REASON_KEY = "reason"
|
|
10
|
+
ALLOW_EXIT_CODE = 0
|
|
11
|
+
UTF8_ENCODING = "utf-8"
|
|
12
|
+
WORD_SEPARATOR = " "
|
|
13
|
+
TASK_LIST_ID_ENVIRONMENT_VARIABLE = "CLAUDE_CODE_TASK_LIST_ID"
|
|
14
|
+
CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE = "CLAUDE_CONFIG_DIR"
|
|
15
|
+
CLAUDE_HOME_DIRECTORY_NAME = ".claude"
|
|
16
|
+
TASKS_DIRECTORY_NAME = "tasks"
|
|
17
|
+
TASK_FILE_GLOB = "*.json"
|
|
18
|
+
TASK_ID_KEY = "id"
|
|
19
|
+
TASK_SUBJECT_KEY = "subject"
|
|
20
|
+
TASK_STATUS_KEY = "status"
|
|
21
|
+
PENDING_STATUS = "pending"
|
|
22
|
+
INCOMPLETE_STATUSES = frozenset({PENDING_STATUS, "in_progress"})
|
|
23
|
+
RECORD_TYPE_KEY = "type"
|
|
24
|
+
MESSAGE_KEY = "message"
|
|
25
|
+
CONTENT_KEY = "content"
|
|
26
|
+
TEXT_KEY = "text"
|
|
27
|
+
TOOL_NAME_KEY = "name"
|
|
28
|
+
TOOL_INPUT_KEY = "input"
|
|
29
|
+
TOOL_USE_ID_KEY = "tool_use_id"
|
|
30
|
+
TODOS_KEY = "todos"
|
|
31
|
+
TODO_CONTENT_KEY = "content"
|
|
32
|
+
TODO_ID_KEY = "id"
|
|
33
|
+
TODO_MERGE_KEY = "merge"
|
|
34
|
+
ROLE_KEY = "role"
|
|
35
|
+
ASSISTANT_ROLE = "assistant"
|
|
36
|
+
CURSOR_CONVERSATION_ID_KEY = "conversation_id"
|
|
37
|
+
CURSOR_FOLLOWUP_MESSAGE_KEY = "followup_message"
|
|
38
|
+
CURSOR_STATUS_KEY = "status"
|
|
39
|
+
CURSOR_COMPLETED_STATUS = "completed"
|
|
40
|
+
CURSOR_LOOP_COUNT_KEY = "loop_count"
|
|
41
|
+
CURSOR_FOLLOWUP_LIMIT = 5
|
|
42
|
+
REPLY_TEXT_SEPARATOR = "\n"
|
|
43
|
+
TASK_UPDATE_ID_KEY = "taskId"
|
|
44
|
+
TODO_WRITE_TOOL_NAME = "TodoWrite"
|
|
45
|
+
DYNAMIC_TOOL_CALL_NAME = "CallDynamicTool"
|
|
46
|
+
DYNAMIC_TOOL_NAME_KEY = "toolName"
|
|
47
|
+
DYNAMIC_TOOL_ARGUMENTS_KEY = "arguments"
|
|
48
|
+
TASK_CREATE_TOOL_NAME = "TaskCreate"
|
|
49
|
+
TASK_UPDATE_TOOL_NAME = "TaskUpdate"
|
|
50
|
+
TOOL_USE_BLOCK_TYPE = "tool_use"
|
|
51
|
+
TOOL_RESULT_BLOCK_TYPE = "tool_result"
|
|
52
|
+
CODEX_RESPONSE_ITEM_TYPE = "response_item"
|
|
53
|
+
CODEX_FUNCTION_CALL_TYPE = "function_call"
|
|
54
|
+
CODEX_PAYLOAD_KEY = "payload"
|
|
55
|
+
CODEX_ARGUMENTS_KEY = "arguments"
|
|
56
|
+
CODEX_PLAN_TOOL_NAME = "update_plan"
|
|
57
|
+
CODEX_PLAN_KEY = "plan"
|
|
58
|
+
CODEX_STEP_KEY = "step"
|
|
59
|
+
FIRST_ITEM_NUMBER = 1
|
|
60
|
+
TASK_CREATED_PATTERN = r"Task #(\d+) created"
|
|
61
|
+
CREATED_TASK_ID_GROUP_INDEX = 1
|
|
62
|
+
REPORT_LINE_PATTERN = r"^\s*(?:[-*]\s*)?(open|blocked)\b\s*[:\-]?\s*(.*)$"
|
|
63
|
+
REPORTED_TEXT_GROUP_INDEX = 2
|
|
64
|
+
WORD_BOUNDARY_PATTERN = r"\b"
|
|
65
|
+
TASK_NUMBER_REFERENCE_TEMPLATE = "#{task_id}"
|
|
66
|
+
ITEM_LINE_TEMPLATE = "\n- {subject}"
|
|
67
|
+
BLOCK_REASON_PREFIX = "The task list still has incomplete items that this reply does not report:"
|
|
68
|
+
BLOCK_REASON_SUFFIX = (
|
|
69
|
+
"\nKeep working each one now. End the reply with one line per item you cannot finish this turn:"
|
|
70
|
+
" 'Blocked: <item> - <what the user must give>' when only the user can unblock it, or"
|
|
71
|
+
" 'Open: <item> - <the step you take next>' when you are still working it."
|
|
72
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cursor sessionStart hook that puts the installed agent guidance into every session.
|
|
3
|
+
|
|
4
|
+
Cursor keeps user rules in its app settings, and it reads ``~/.cursor/rules``
|
|
5
|
+
only for a workspace inside the home directory, so no file under ``~/.cursor``
|
|
6
|
+
carries global guidance. The installer registers this hook as a native
|
|
7
|
+
``sessionStart`` entry in ``~/.cursor/hooks.json`` with the installed
|
|
8
|
+
``AGENTS.md`` path as its one argument. The hook prints that file as Cursor's
|
|
9
|
+
``additional_context``, which Cursor adds to the session's context::
|
|
10
|
+
|
|
11
|
+
python3 cursor_agents_guidance.py ~/.agents/AGENTS.md
|
|
12
|
+
-> {"additional_context": "# Agent guidance ..."}
|
|
13
|
+
|
|
14
|
+
A missing or unreadable file prints ``{}`` so the session starts without it.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
24
|
+
if hooks_root_directory not in sys.path:
|
|
25
|
+
sys.path.insert(0, hooks_root_directory)
|
|
26
|
+
|
|
27
|
+
from hooks_constants.cursor_agents_guidance_constants import (
|
|
28
|
+
CURSOR_CONTEXT_KEY,
|
|
29
|
+
EMPTY_OUTPUT,
|
|
30
|
+
GUIDANCE_PATH_ARGUMENT_INDEX,
|
|
31
|
+
UTF8_ENCODING,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def guidance_output(all_arguments: list[str]) -> str:
|
|
36
|
+
"""Return the SessionStart output for the guidance file named in the arguments."""
|
|
37
|
+
if len(all_arguments) <= GUIDANCE_PATH_ARGUMENT_INDEX:
|
|
38
|
+
return EMPTY_OUTPUT
|
|
39
|
+
try:
|
|
40
|
+
guidance_text = Path(all_arguments[GUIDANCE_PATH_ARGUMENT_INDEX]).read_text(encoding=UTF8_ENCODING)
|
|
41
|
+
except OSError:
|
|
42
|
+
return EMPTY_OUTPUT
|
|
43
|
+
if not guidance_text.strip():
|
|
44
|
+
return EMPTY_OUTPUT
|
|
45
|
+
return json.dumps({CURSOR_CONTEXT_KEY: guidance_text})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main() -> None:
|
|
49
|
+
sys.stdin.read()
|
|
50
|
+
sys.stdout.write(guidance_output(sys.argv))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
main()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
SESSION_DIRECTORY = Path(__file__).resolve().parent
|
|
6
|
+
if str(SESSION_DIRECTORY) not in sys.path:
|
|
7
|
+
sys.path.insert(0, str(SESSION_DIRECTORY))
|
|
8
|
+
|
|
9
|
+
import cursor_agents_guidance
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_should_emit_the_guidance_file_as_cursor_additional_context(tmp_path: Path) -> None:
|
|
13
|
+
guidance_path = tmp_path / "AGENTS.md"
|
|
14
|
+
guidance_path.write_text("# Agent guidance\n- **Asks.** Work every ask now.\n", encoding="utf-8")
|
|
15
|
+
printed_output = cursor_agents_guidance.guidance_output(["hook", str(guidance_path)])
|
|
16
|
+
assert json.loads(printed_output) == {"additional_context": guidance_path.read_text(encoding="utf-8")}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_should_emit_an_empty_object_when_the_file_is_missing(tmp_path: Path) -> None:
|
|
20
|
+
assert cursor_agents_guidance.guidance_output(["hook", str(tmp_path / "absent.md")]) == "{}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_should_emit_an_empty_object_without_a_path_argument() -> None:
|
|
24
|
+
assert cursor_agents_guidance.guidance_output(["hook"]) == "{}"
|
package/package.json
CHANGED
|
@@ -139,6 +139,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
139
139
|
hooks/blocking/build_eval_before_eval_write_gate.py -> exempt
|
|
140
140
|
hooks/blocking/merge_over_review_offer_gate.py -> exempt
|
|
141
141
|
hooks/blocking/final_reply_gate.py -> exempt
|
|
142
|
+
hooks/blocking/open_tasks_stop_gate.py -> exempt
|
|
142
143
|
hooks/blocking/some_new_blocker.py -> flagged
|
|
143
144
|
|
|
144
145
|
The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
|
|
@@ -179,6 +180,11 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
179
180
|
names open review findings. It reads only the wording of a chat card and
|
|
180
181
|
decides no code or safety policy; the merge review gate holds the merge.
|
|
181
182
|
|
|
183
|
+
The open tasks Stop gate holds the end of a turn while the session's task
|
|
184
|
+
list has items the closing reply neither completes nor reports as
|
|
185
|
+
``Blocked:`` or ``Open:``. It decides when a turn may end and no code or
|
|
186
|
+
safety policy.
|
|
187
|
+
|
|
182
188
|
Args:
|
|
183
189
|
registered_string: One command, path, script, or entrypoint string.
|
|
184
190
|
"""
|
|
@@ -78,6 +78,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
|
78
78
|
"blocking/build_eval_before_eval_write_gate.py",
|
|
79
79
|
"blocking/merge_over_review_offer_gate.py",
|
|
80
80
|
"blocking/final_reply_gate.py",
|
|
81
|
+
"blocking/open_tasks_stop_gate.py",
|
|
81
82
|
)
|
|
82
83
|
ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
|
|
83
84
|
ALL_TEST_FILE_PREFIXES = ("test_",)
|
|
@@ -74,6 +74,14 @@ def test_new_final_reply_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
|
74
74
|
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
def test_new_open_tasks_stop_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
78
|
+
current_document = _hook_document(
|
|
79
|
+
["hooks/blocking/open_tasks_stop_gate.py"],
|
|
80
|
+
'{"hooks": {}}',
|
|
81
|
+
)
|
|
82
|
+
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
83
|
+
|
|
84
|
+
|
|
77
85
|
def test_new_step_note_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
78
86
|
current_document = _hook_document(
|
|
79
87
|
["hooks/blocking/step_note_gate.py"],
|