claude-dev-env 8.87.1 → 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 +1 -0
- 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/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,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.
|
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
|
}
|