claude-dev-env 8.87.1 → 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.
Files changed (34) hide show
  1. package/AGENTS.md +1 -0
  2. package/bin/cursor-agents-guidance.mjs +94 -0
  3. package/bin/cursor-agents-guidance.test.mjs +94 -0
  4. package/bin/cursor-native-hooks.mjs +128 -0
  5. package/bin/cursor-native-hooks.test.mjs +78 -0
  6. package/bin/install-constants.mjs +29 -0
  7. package/bin/install-deepstack-plugin.mjs +84 -39
  8. package/bin/install-deepstack-plugin.test.mjs +51 -11
  9. package/bin/install.cursor-rules.test.mjs +65 -2
  10. package/bin/install.mjs +40 -2
  11. package/bin/install.test.mjs +10 -5
  12. package/bin/python-probe.mjs +20 -0
  13. package/bin/python-probe.test.mjs +37 -0
  14. package/docs/agents-guidance/asks.md +49 -0
  15. package/hooks/blocking/open_tasks_stop_gate.py +210 -0
  16. package/hooks/blocking/open_tasks_transcript.py +270 -0
  17. package/hooks/blocking/test_open_tasks_stop_gate.py +297 -0
  18. package/hooks/blocking/test_open_tasks_transcript.py +54 -0
  19. package/hooks/features/conduct-gates.md +3 -0
  20. package/hooks/features/session-context.md +2 -1
  21. package/hooks/hooks.json +5 -0
  22. package/hooks/hooks_constants/cursor_agents_guidance_constants.py +6 -0
  23. package/hooks/hooks_constants/deepen_first_prompt_constants.py +8 -0
  24. package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +72 -0
  25. package/hooks/session/cursor_agents_guidance.py +54 -0
  26. package/hooks/session/deepen_first_prompt.py +12 -31
  27. package/hooks/session/deepen_turn_marker.py +75 -0
  28. package/hooks/session/test_cursor_agents_guidance.py +24 -0
  29. package/hooks/session/test_deepen_first_prompt.py +63 -0
  30. package/hooks/session/test_deepen_turn_marker.py +59 -0
  31. package/package.json +1 -1
  32. package/scripts/policy_lint/adapter_configuration.py +6 -0
  33. package/scripts/policy_lint/config/constants.py +1 -0
  34. package/scripts/tests/test_adapter_configuration.py +8 -0
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env python3
2
+ """Rebuild a session task list and its last assistant text from a transcript.
3
+
4
+ The open tasks Stop gate reads three transcript shapes, replayed in order::
5
+
6
+ TaskCreate / TaskUpdate calls (Claude Code)
7
+ TodoWrite input.todos (Claude Code and Cursor; ``merge`` updates by id)
8
+ update_plan function_call arguments (Codex rollout)
9
+
10
+ The Cursor CLI can also reach TodoWrite through ``CallDynamicTool``, with the
11
+ TodoWrite input under ``arguments``; that call counts as the TodoWrite call.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import NamedTuple
21
+
22
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
23
+ if hooks_root_directory not in sys.path:
24
+ sys.path.insert(0, hooks_root_directory)
25
+
26
+ from hooks_constants.open_tasks_stop_gate_constants import (
27
+ ASSISTANT_ROLE,
28
+ CODEX_ARGUMENTS_KEY,
29
+ CODEX_FUNCTION_CALL_TYPE,
30
+ CODEX_PAYLOAD_KEY,
31
+ CODEX_PLAN_KEY,
32
+ CODEX_PLAN_TOOL_NAME,
33
+ CODEX_RESPONSE_ITEM_TYPE,
34
+ CODEX_STEP_KEY,
35
+ CONTENT_KEY,
36
+ CREATED_TASK_ID_GROUP_INDEX,
37
+ DYNAMIC_TOOL_ARGUMENTS_KEY,
38
+ DYNAMIC_TOOL_CALL_NAME,
39
+ DYNAMIC_TOOL_NAME_KEY,
40
+ FIRST_ITEM_NUMBER,
41
+ MESSAGE_KEY,
42
+ PENDING_STATUS,
43
+ RECORD_TYPE_KEY,
44
+ REPLY_TEXT_SEPARATOR,
45
+ ROLE_KEY,
46
+ TASK_CREATED_PATTERN,
47
+ TASK_CREATE_TOOL_NAME,
48
+ TASK_ID_KEY,
49
+ TASK_STATUS_KEY,
50
+ TASK_SUBJECT_KEY,
51
+ TASK_UPDATE_ID_KEY,
52
+ TASK_UPDATE_TOOL_NAME,
53
+ TEXT_KEY,
54
+ TODOS_KEY,
55
+ TODO_CONTENT_KEY,
56
+ TODO_ID_KEY,
57
+ TODO_MERGE_KEY,
58
+ TODO_WRITE_TOOL_NAME,
59
+ TOOL_INPUT_KEY,
60
+ TOOL_NAME_KEY,
61
+ TOOL_RESULT_BLOCK_TYPE,
62
+ TOOL_USE_BLOCK_TYPE,
63
+ TOOL_USE_ID_KEY,
64
+ UTF8_ENCODING,
65
+ WORD_SEPARATOR,
66
+ )
67
+
68
+
69
+ class TaskItem(NamedTuple):
70
+ """One task list item.
71
+
72
+ Attributes:
73
+ item_id: The task number or position, as text.
74
+ subject: The item's wording.
75
+ status: The item's status, such as ``pending`` or ``completed``.
76
+ """
77
+
78
+ item_id: str
79
+ subject: str
80
+ status: str
81
+
82
+
83
+ def parsed_object(json_text: str) -> dict[str, object] | None:
84
+ """Return the JSON object in the text, or None for anything else."""
85
+ try:
86
+ parsed_value = json.loads(json_text)
87
+ except ValueError:
88
+ return None
89
+ return parsed_value if isinstance(parsed_value, dict) else None
90
+
91
+
92
+ def _transcript_records(transcript_path: object) -> list[dict[str, object]]:
93
+ if not isinstance(transcript_path, str):
94
+ return []
95
+ try:
96
+ all_lines = Path(transcript_path).read_text(encoding=UTF8_ENCODING).splitlines()
97
+ except OSError:
98
+ return []
99
+ all_records = (parsed_object(each_line) for each_line in all_lines)
100
+ return [each_record for each_record in all_records if each_record is not None]
101
+
102
+
103
+ def _content_blocks(all_record_fields: dict[str, object]) -> list[dict[str, object]]:
104
+ message_body = all_record_fields.get(MESSAGE_KEY)
105
+ content_value = message_body.get(CONTENT_KEY) if isinstance(message_body, dict) else None
106
+ if not isinstance(content_value, list):
107
+ return []
108
+ return [each_block for each_block in content_value if isinstance(each_block, dict)]
109
+
110
+
111
+ def _result_text(all_block_fields: dict[str, object]) -> str:
112
+ result_content = all_block_fields.get(CONTENT_KEY)
113
+ if isinstance(result_content, str):
114
+ return result_content
115
+ if not isinstance(result_content, list):
116
+ return ""
117
+ return WORD_SEPARATOR.join(
118
+ str(each_part.get(TEXT_KEY, "")) for each_part in result_content if isinstance(each_part, dict)
119
+ )
120
+
121
+
122
+ def _numbered_items(all_entries: object, subject_key: str) -> list[TaskItem]:
123
+ if not isinstance(all_entries, list):
124
+ return []
125
+ all_dictionaries = [each_entry for each_entry in all_entries if isinstance(each_entry, dict)]
126
+ return [
127
+ TaskItem(str(each_number), str(each_entry.get(subject_key, "")), str(each_entry.get(TASK_STATUS_KEY, "")))
128
+ for each_number, each_entry in enumerate(all_dictionaries, start=FIRST_ITEM_NUMBER)
129
+ ]
130
+
131
+
132
+ def _todo_items(all_todo_entries: object) -> list[TaskItem]:
133
+ if not isinstance(all_todo_entries, list):
134
+ return []
135
+ all_dictionaries = [each_entry for each_entry in all_todo_entries if isinstance(each_entry, dict)]
136
+ return [
137
+ TaskItem(
138
+ str(each_entry.get(TODO_ID_KEY, each_number)),
139
+ str(each_entry.get(TODO_CONTENT_KEY, "")),
140
+ str(each_entry.get(TASK_STATUS_KEY, "")),
141
+ )
142
+ for each_number, each_entry in enumerate(all_dictionaries, start=FIRST_ITEM_NUMBER)
143
+ ]
144
+
145
+
146
+ def merged_todo_items(all_earlier_items: list[TaskItem], all_update_entries: object) -> list[TaskItem]:
147
+ """Apply a ``merge`` TodoWrite call: update earlier items by id and append new ones."""
148
+ item_by_id = {each_item.item_id: each_item for each_item in all_earlier_items}
149
+ for each_update in _todo_items(all_update_entries):
150
+ earlier_item = item_by_id.get(each_update.item_id)
151
+ if earlier_item is None:
152
+ item_by_id[each_update.item_id] = each_update
153
+ continue
154
+ item_by_id[each_update.item_id] = earlier_item._replace(
155
+ subject=each_update.subject or earlier_item.subject,
156
+ status=each_update.status or earlier_item.status,
157
+ )
158
+ return list(item_by_id.values())
159
+
160
+
161
+ def codex_plan_items(all_record_fields: dict[str, object]) -> list[TaskItem] | None:
162
+ """Return the plan from a Codex rollout ``update_plan`` call record, or None for any other record."""
163
+ payload_body = all_record_fields.get(CODEX_PAYLOAD_KEY)
164
+ if all_record_fields.get(RECORD_TYPE_KEY) != CODEX_RESPONSE_ITEM_TYPE or not isinstance(payload_body, dict):
165
+ return None
166
+ if payload_body.get(RECORD_TYPE_KEY) != CODEX_FUNCTION_CALL_TYPE or payload_body.get(TOOL_NAME_KEY) != CODEX_PLAN_TOOL_NAME:
167
+ return None
168
+ all_plan_arguments = parsed_object(str(payload_body.get(CODEX_ARGUMENTS_KEY, "")))
169
+ if all_plan_arguments is None:
170
+ return None
171
+ return _numbered_items(all_plan_arguments.get(CODEX_PLAN_KEY), CODEX_STEP_KEY)
172
+
173
+
174
+ def unwrapped_tool_call(tool_name: str, all_input_fields: dict[str, object]) -> tuple[str, dict[str, object]]:
175
+ """Return the inner tool name and arguments of a Cursor ``CallDynamicTool`` call, or the call itself.
176
+
177
+ The Cursor CLI can reach TodoWrite through ``CallDynamicTool`` with
178
+ ``{"toolName": "TodoWrite", "arguments": {"todos": [...], "merge": true}}``.
179
+ """
180
+ inner_name = all_input_fields.get(DYNAMIC_TOOL_NAME_KEY)
181
+ all_inner_arguments = all_input_fields.get(DYNAMIC_TOOL_ARGUMENTS_KEY)
182
+ if tool_name != DYNAMIC_TOOL_CALL_NAME or not isinstance(inner_name, str) or not isinstance(all_inner_arguments, dict):
183
+ return tool_name, all_input_fields
184
+ return inner_name, all_inner_arguments
185
+
186
+
187
+ class _TranscriptReplay:
188
+ """Fold transcript records into the newest task list they describe."""
189
+
190
+ def __init__(self) -> None:
191
+ self.latest_items: list[TaskItem] | None = None
192
+ self.created_by_id: dict[str, TaskItem] = {}
193
+ self.subject_by_tool_use_id: dict[str, str] = {}
194
+ self.last_assistant_text = ""
195
+
196
+ def apply_record(self, all_record_fields: dict[str, object]) -> None:
197
+ plan_items = codex_plan_items(all_record_fields)
198
+ if plan_items is not None:
199
+ self.latest_items = plan_items
200
+ return
201
+ all_blocks = _content_blocks(all_record_fields)
202
+ for each_block in all_blocks:
203
+ self.apply_block(each_block)
204
+ self.note_assistant_text(all_record_fields, all_blocks)
205
+
206
+ def note_assistant_text(self, all_record_fields: dict[str, object], all_blocks: list[dict[str, object]]) -> None:
207
+ message_body = all_record_fields.get(MESSAGE_KEY)
208
+ message_role = message_body.get(ROLE_KEY) if isinstance(message_body, dict) else None
209
+ if ASSISTANT_ROLE not in (all_record_fields.get(ROLE_KEY), all_record_fields.get(RECORD_TYPE_KEY), message_role):
210
+ return
211
+ record_text = REPLY_TEXT_SEPARATOR.join(
212
+ str(each_block.get(TEXT_KEY, "")) for each_block in all_blocks if each_block.get(RECORD_TYPE_KEY) == TEXT_KEY
213
+ ).strip()
214
+ if record_text:
215
+ self.last_assistant_text = record_text
216
+
217
+ def apply_block(self, all_block_fields: dict[str, object]) -> None:
218
+ block_type = all_block_fields.get(RECORD_TYPE_KEY)
219
+ tool_input = all_block_fields.get(TOOL_INPUT_KEY)
220
+ if block_type == TOOL_USE_BLOCK_TYPE and isinstance(tool_input, dict):
221
+ tool_name, all_tool_arguments = unwrapped_tool_call(str(all_block_fields.get(TOOL_NAME_KEY)), tool_input)
222
+ self.apply_tool_use(tool_name, str(all_block_fields.get(TASK_ID_KEY)), all_tool_arguments)
223
+ elif block_type == TOOL_RESULT_BLOCK_TYPE:
224
+ self.apply_tool_result(all_block_fields)
225
+
226
+ def apply_tool_use(self, tool_name: str, tool_use_id: str, all_input_fields: dict[str, object]) -> None:
227
+ if tool_name == TODO_WRITE_TOOL_NAME:
228
+ all_todo_entries = all_input_fields.get(TODOS_KEY)
229
+ is_merge = all_input_fields.get(TODO_MERGE_KEY) is True and self.latest_items is not None
230
+ self.latest_items = (
231
+ merged_todo_items(self.latest_items or [], all_todo_entries) if is_merge else _todo_items(all_todo_entries)
232
+ )
233
+ return
234
+ if tool_name == TASK_CREATE_TOOL_NAME:
235
+ self.subject_by_tool_use_id[tool_use_id] = str(all_input_fields.get(TASK_SUBJECT_KEY, ""))
236
+ return
237
+ if tool_name == TASK_UPDATE_TOOL_NAME:
238
+ self.apply_task_update(all_input_fields)
239
+
240
+ def apply_task_update(self, all_input_fields: dict[str, object]) -> None:
241
+ task_id = str(all_input_fields.get(TASK_UPDATE_ID_KEY, ""))
242
+ new_status = all_input_fields.get(TASK_STATUS_KEY)
243
+ if task_id in self.created_by_id and new_status:
244
+ self.created_by_id[task_id] = self.created_by_id[task_id]._replace(status=str(new_status))
245
+
246
+ def apply_tool_result(self, all_block_fields: dict[str, object]) -> None:
247
+ created_subject = self.subject_by_tool_use_id.pop(str(all_block_fields.get(TOOL_USE_ID_KEY)), None)
248
+ created_match = re.search(TASK_CREATED_PATTERN, _result_text(all_block_fields))
249
+ if created_subject is None or created_match is None:
250
+ return
251
+ created_id = created_match.group(CREATED_TASK_ID_GROUP_INDEX)
252
+ self.created_by_id[created_id] = TaskItem(created_id, created_subject, PENDING_STATUS)
253
+
254
+ def items(self) -> list[TaskItem] | None:
255
+ if self.created_by_id:
256
+ return list(self.created_by_id.values())
257
+ return self.latest_items
258
+
259
+
260
+ def replayed_transcript(transcript_path: object) -> _TranscriptReplay:
261
+ """Fold every transcript record into one replay of the task list and the last assistant text."""
262
+ replay = _TranscriptReplay()
263
+ for each_record in _transcript_records(transcript_path):
264
+ replay.apply_record(each_record)
265
+ return replay
266
+
267
+
268
+ def transcript_items(transcript_path: object) -> list[TaskItem] | None:
269
+ """Rebuild the task list from TaskCreate/TaskUpdate, TodoWrite, and Codex update_plan calls."""
270
+ return replayed_transcript(transcript_path).items()
@@ -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.