claude-dev-env 8.98.8 → 8.98.10
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 -1
- package/bin/install.prompt-context.test.mjs +1 -1
- package/docs/agents-guidance/asks.md +13 -12
- package/docs/rule-guides/session-title.md +15 -4
- package/hooks/blocking/open_tasks_stop_gate.py +60 -259
- package/hooks/blocking/open_tasks_transcript.py +104 -90
- package/hooks/blocking/resolve_thread_reply_gate.py +203 -41
- package/hooks/blocking/session_title_turn_gate.py +142 -56
- package/hooks/blocking/test_open_tasks_stop_gate.py +203 -500
- package/hooks/blocking/test_open_tasks_transcript.py +123 -15
- package/hooks/blocking/test_resolve_thread_reply_gate.py +341 -25
- package/hooks/blocking/test_session_title_turn_gate.py +173 -90
- package/hooks/features/conduct-gates.md +12 -12
- package/hooks/hooks.json +2 -2
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +27 -60
- package/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +18 -3
- package/hooks/hooks_constants/session_title_constants.py +19 -1
- package/hooks/open_task_asks.py +6 -101
- package/hooks/session/test_tracking_nudge.py +124 -0
- package/hooks/session/tracking_nudge.py +113 -0
- package/hooks/test_open_task_asks.py +6 -13
- package/package.json +1 -1
- package/rules/index.md +1 -1
- package/scripts/policy_lint/adapter_configuration.py +6 -6
- package/hooks/session/open_task_ask_recorder.py +0 -38
- package/hooks/session/test_open_task_ask_recorder.py +0 -57
|
@@ -5,6 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
7
|
import session_title_turn_gate
|
|
8
|
+
from open_tasks_transcript import TaskItem
|
|
8
9
|
|
|
9
10
|
REMOTE_TITLE_TOOL = "mcp__claude-code-remote__set_session_title"
|
|
10
11
|
DESKTOP_TITLE_TOOL = "mcp__ccd_session_mgmt__set_session_title"
|
|
@@ -28,10 +29,16 @@ WORK_RESULT = {
|
|
|
28
29
|
"type": "user",
|
|
29
30
|
"message": {"content": [{"type": "tool_result", "tool_use_id": "w1", "content": "ok"}]},
|
|
30
31
|
}
|
|
32
|
+
SESSION_ID = "s-title"
|
|
33
|
+
RED_FLAG_TITLE = "\U0001f6a9 Merge approval"
|
|
34
|
+
HOURGLASS_TITLE = "\u23f3 Export"
|
|
35
|
+
QUESTION_PROMPT = {"type": "user", "message": {"role": "user", "content": "did the export fail?"}}
|
|
31
36
|
REPLY = {"type": "assistant", "message": {"content": [{"type": "text", "text": "Done."}]}}
|
|
32
37
|
|
|
33
38
|
|
|
34
|
-
def title_call(
|
|
39
|
+
def title_call(
|
|
40
|
+
tool_use_id: str, tool_name: str = REMOTE_TITLE_TOOL, title: str = "✅ Gate"
|
|
41
|
+
) -> dict[str, object]:
|
|
35
42
|
return {
|
|
36
43
|
"type": "assistant",
|
|
37
44
|
"message": {
|
|
@@ -40,7 +47,7 @@ def title_call(tool_use_id: str, tool_name: str = REMOTE_TITLE_TOOL) -> dict[str
|
|
|
40
47
|
"type": "tool_use",
|
|
41
48
|
"id": tool_use_id,
|
|
42
49
|
"name": tool_name,
|
|
43
|
-
"input": {"title":
|
|
50
|
+
"input": {"title": title},
|
|
44
51
|
}
|
|
45
52
|
]
|
|
46
53
|
},
|
|
@@ -76,6 +83,7 @@ def run_gate(
|
|
|
76
83
|
)
|
|
77
84
|
hook_input = {
|
|
78
85
|
"hook_event_name": "Stop",
|
|
86
|
+
"session_id": SESSION_ID,
|
|
79
87
|
"stop_hook_active": is_stop_hook_active,
|
|
80
88
|
"transcript_path": str(transcript_path),
|
|
81
89
|
}
|
|
@@ -90,9 +98,33 @@ def run_gate(
|
|
|
90
98
|
def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
91
99
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
92
100
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
101
|
+
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
|
102
|
+
monkeypatch.delenv("CLAUDE_CODE_TASK_LIST_ID", raising=False)
|
|
93
103
|
|
|
94
104
|
|
|
95
|
-
def
|
|
105
|
+
def write_task_store(home_directory: Path, all_statuses: list[str]) -> None:
|
|
106
|
+
task_directory = home_directory / ".claude" / "tasks" / SESSION_ID
|
|
107
|
+
task_directory.mkdir(parents=True)
|
|
108
|
+
for each_number, each_status in enumerate(all_statuses, start=1):
|
|
109
|
+
each_task = {"id": str(each_number), "subject": f"Step {each_number}", "status": each_status}
|
|
110
|
+
(task_directory / f"{each_number}.json").write_text(json.dumps(each_task), encoding="utf-8")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def earlier_turn(title: str) -> list[dict[str, object]]:
|
|
114
|
+
return [TOOL_LISTING, PROMPT, title_call("t0", title=title), title_result("t0"), REPLY]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def block_reason(
|
|
118
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
119
|
+
capsys: pytest.CaptureFixture[str],
|
|
120
|
+
tmp_path: Path,
|
|
121
|
+
all_entries: list[dict[str, object]],
|
|
122
|
+
) -> str:
|
|
123
|
+
stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)[1]
|
|
124
|
+
return json.loads(stdout_text)["reason"] if stdout_text else ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_should_hold_a_work_turn_in_a_session_with_no_title(
|
|
96
128
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
97
129
|
) -> None:
|
|
98
130
|
exit_code, stdout_text = run_gate(
|
|
@@ -101,6 +133,7 @@ def test_should_hold_a_turn_that_set_no_title(
|
|
|
101
133
|
block_output = json.loads(stdout_text)
|
|
102
134
|
assert exit_code == 0
|
|
103
135
|
assert block_output["decision"] == "block"
|
|
136
|
+
assert "no title yet" in block_output["reason"]
|
|
104
137
|
assert REMOTE_TITLE_TOOL in block_output["reason"]
|
|
105
138
|
assert all(
|
|
106
139
|
each_emoji in block_output["reason"]
|
|
@@ -111,144 +144,136 @@ def test_should_hold_a_turn_that_set_no_title(
|
|
|
111
144
|
def test_should_pass_a_turn_that_set_a_title(
|
|
112
145
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
113
146
|
) -> None:
|
|
147
|
+
write_task_store(tmp_path, ["completed"])
|
|
114
148
|
all_entries = [
|
|
115
149
|
TOOL_LISTING,
|
|
116
150
|
PROMPT,
|
|
117
151
|
WORK_CALL,
|
|
118
152
|
WORK_RESULT,
|
|
119
|
-
title_call("t1"),
|
|
153
|
+
title_call("t1", title=HOURGLASS_TITLE),
|
|
120
154
|
title_result("t1"),
|
|
121
155
|
REPLY,
|
|
122
156
|
]
|
|
123
157
|
assert run_gate(monkeypatch, capsys, tmp_path, all_entries) == (0, "")
|
|
124
158
|
|
|
125
159
|
|
|
126
|
-
|
|
160
|
+
@pytest.mark.parametrize(
|
|
161
|
+
("earlier_title", "all_statuses"),
|
|
162
|
+
[
|
|
163
|
+
(HOURGLASS_TITLE, ["completed", "in_progress"]),
|
|
164
|
+
("✅ Gate", []),
|
|
165
|
+
("✅ Gate", ["completed"]),
|
|
166
|
+
("\U0001f6a7 Org secret", ["pending"]),
|
|
167
|
+
],
|
|
168
|
+
)
|
|
169
|
+
def test_should_pass_a_work_turn_whose_carried_title_still_fits(
|
|
170
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
171
|
+
capsys: pytest.CaptureFixture[str],
|
|
172
|
+
tmp_path: Path,
|
|
173
|
+
earlier_title: str,
|
|
174
|
+
all_statuses: list[str],
|
|
175
|
+
) -> None:
|
|
176
|
+
if all_statuses:
|
|
177
|
+
write_task_store(tmp_path, all_statuses)
|
|
178
|
+
all_entries = [*earlier_turn(earlier_title), PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
179
|
+
assert run_gate(monkeypatch, capsys, tmp_path, all_entries) == (0, "")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_should_hold_a_carried_red_flag_after_a_prompt_that_gives_work(
|
|
127
183
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
128
184
|
) -> None:
|
|
129
|
-
all_entries = [
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
185
|
+
all_entries = [*earlier_turn(RED_FLAG_TITLE), PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
186
|
+
reason_text = block_reason(monkeypatch, capsys, tmp_path, all_entries)
|
|
187
|
+
assert RED_FLAG_TITLE in reason_text
|
|
188
|
+
assert "red flag" in reason_text
|
|
133
189
|
|
|
134
190
|
|
|
135
|
-
def
|
|
191
|
+
def test_should_pass_a_carried_red_flag_after_a_question(
|
|
136
192
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
137
193
|
) -> None:
|
|
138
|
-
all_entries = [
|
|
139
|
-
assert run_gate(monkeypatch, capsys, tmp_path, all_entries
|
|
140
|
-
|
|
141
|
-
|
|
194
|
+
all_entries = [*earlier_turn(RED_FLAG_TITLE), QUESTION_PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
195
|
+
assert run_gate(monkeypatch, capsys, tmp_path, all_entries) == (0, "")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_should_hold_a_carried_check_mark_while_items_are_open(
|
|
199
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
200
|
+
) -> None:
|
|
201
|
+
write_task_store(tmp_path, ["completed", "pending"])
|
|
202
|
+
all_entries = [*earlier_turn("✅ Gate"), QUESTION_PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
203
|
+
assert "open items" in block_reason(monkeypatch, capsys, tmp_path, all_entries)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_should_hold_a_carried_hourglass_once_every_item_is_completed(
|
|
207
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
208
|
+
) -> None:
|
|
209
|
+
write_task_store(tmp_path, ["completed", "completed"])
|
|
210
|
+
all_entries = [*earlier_turn(HOURGLASS_TITLE), PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
211
|
+
assert "every task list item is completed" in block_reason(
|
|
212
|
+
monkeypatch, capsys, tmp_path, all_entries
|
|
142
213
|
)
|
|
143
214
|
|
|
144
215
|
|
|
145
|
-
def
|
|
216
|
+
def test_should_pass_a_held_turn_even_with_no_title(
|
|
146
217
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
147
218
|
) -> None:
|
|
148
219
|
all_entries = [TOOL_LISTING, PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
149
|
-
|
|
150
|
-
|
|
220
|
+
assert run_gate(monkeypatch, capsys, tmp_path, all_entries, is_stop_hook_active=True) == (0, "")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_should_pass_a_tool_free_turn_with_no_title(
|
|
224
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
225
|
+
) -> None:
|
|
226
|
+
assert run_gate(monkeypatch, capsys, tmp_path, [TOOL_LISTING, PROMPT, REPLY]) == (0, "")
|
|
151
227
|
|
|
152
228
|
|
|
153
|
-
def
|
|
229
|
+
def test_should_keep_showing_the_earlier_title_when_this_turns_title_call_was_denied(
|
|
154
230
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
155
231
|
) -> None:
|
|
156
232
|
all_entries = [
|
|
157
|
-
|
|
158
|
-
title_call("t1"),
|
|
159
|
-
title_result("t1"),
|
|
160
|
-
REPLY,
|
|
233
|
+
*earlier_turn(RED_FLAG_TITLE),
|
|
161
234
|
PROMPT,
|
|
162
235
|
WORK_CALL,
|
|
163
236
|
WORK_RESULT,
|
|
237
|
+
title_call("t1", title="⏳ bad title."),
|
|
238
|
+
title_result("t1", is_error=True),
|
|
164
239
|
REPLY,
|
|
165
240
|
]
|
|
166
|
-
|
|
167
|
-
assert block_output["decision"] == "block"
|
|
241
|
+
assert RED_FLAG_TITLE in block_reason(monkeypatch, capsys, tmp_path, all_entries)
|
|
168
242
|
|
|
169
243
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
[
|
|
173
|
-
[REPLY],
|
|
174
|
-
[title_call("t2"), title_result("t2", is_error=True), REPLY],
|
|
175
|
-
],
|
|
176
|
-
)
|
|
177
|
-
def test_should_pass_a_tool_free_turn_after_an_earlier_title(
|
|
178
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
179
|
-
capsys: pytest.CaptureFixture[str],
|
|
180
|
-
tmp_path: Path,
|
|
181
|
-
all_turn_tail: list[dict[str, object]],
|
|
244
|
+
def test_should_ignore_a_title_set_on_a_sidechain(
|
|
245
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
182
246
|
) -> None:
|
|
247
|
+
sidechain_call = {**title_call("s1"), "isSidechain": True}
|
|
248
|
+
sidechain_result = {**title_result("s1"), "isSidechain": True}
|
|
183
249
|
all_entries = [
|
|
184
250
|
TOOL_LISTING,
|
|
185
251
|
PROMPT,
|
|
186
|
-
|
|
187
|
-
|
|
252
|
+
sidechain_call,
|
|
253
|
+
sidechain_result,
|
|
254
|
+
WORK_CALL,
|
|
255
|
+
WORK_RESULT,
|
|
188
256
|
REPLY,
|
|
189
|
-
PROMPT,
|
|
190
|
-
*all_turn_tail,
|
|
191
257
|
]
|
|
192
|
-
assert
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
@pytest.mark.parametrize(
|
|
196
|
-
"all_earlier_title_entries",
|
|
197
|
-
[
|
|
198
|
-
[],
|
|
199
|
-
[title_call("t1"), title_result("t1", is_error=True)],
|
|
200
|
-
[
|
|
201
|
-
{**title_call("s1"), "isSidechain": True},
|
|
202
|
-
{**title_result("s1"), "isSidechain": True},
|
|
203
|
-
],
|
|
204
|
-
],
|
|
205
|
-
)
|
|
206
|
-
def test_should_hold_a_tool_free_turn_without_an_earlier_title(
|
|
207
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
208
|
-
capsys: pytest.CaptureFixture[str],
|
|
209
|
-
tmp_path: Path,
|
|
210
|
-
all_earlier_title_entries: list[dict[str, object]],
|
|
211
|
-
) -> None:
|
|
212
|
-
all_entries = [TOOL_LISTING, PROMPT, *all_earlier_title_entries, REPLY, PROMPT, REPLY]
|
|
213
|
-
stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)[1]
|
|
214
|
-
assert json.loads(stdout_text)["decision"] == "block"
|
|
258
|
+
assert "no title yet" in block_reason(monkeypatch, capsys, tmp_path, all_entries)
|
|
215
259
|
|
|
216
260
|
|
|
217
261
|
def test_should_start_a_turn_at_an_image_only_prompt(
|
|
218
262
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
219
263
|
) -> None:
|
|
264
|
+
write_task_store(tmp_path, ["pending"])
|
|
220
265
|
image_prompt = {
|
|
221
266
|
"type": "user",
|
|
222
267
|
"message": {"content": [{"type": "image", "source": {"type": "base64", "data": ""}}]},
|
|
223
268
|
}
|
|
224
|
-
all_entries = [
|
|
225
|
-
|
|
226
|
-
PROMPT,
|
|
227
|
-
title_call("t1"),
|
|
228
|
-
title_result("t1"),
|
|
229
|
-
REPLY,
|
|
230
|
-
image_prompt,
|
|
231
|
-
WORK_CALL,
|
|
232
|
-
WORK_RESULT,
|
|
233
|
-
REPLY,
|
|
234
|
-
]
|
|
235
|
-
stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)[1]
|
|
236
|
-
assert json.loads(stdout_text)["decision"] == "block"
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def test_should_hold_a_turn_whose_only_title_call_ran_on_a_sidechain(
|
|
240
|
-
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
241
|
-
) -> None:
|
|
242
|
-
sidechain_call = {**title_call("s1"), "isSidechain": True}
|
|
243
|
-
sidechain_result = {**title_result("s1"), "isSidechain": True}
|
|
244
|
-
all_entries = [TOOL_LISTING, PROMPT, sidechain_call, sidechain_result, REPLY]
|
|
245
|
-
stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)[1]
|
|
246
|
-
assert json.loads(stdout_text)["decision"] == "block"
|
|
269
|
+
all_entries = [*earlier_turn("✅ Gate"), image_prompt, WORK_CALL, WORK_RESULT, REPLY]
|
|
270
|
+
assert "open items" in block_reason(monkeypatch, capsys, tmp_path, all_entries)
|
|
247
271
|
|
|
248
272
|
|
|
249
273
|
def test_should_keep_the_turn_across_a_stop_block_and_a_meta_entry(
|
|
250
274
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
251
275
|
) -> None:
|
|
276
|
+
write_task_store(tmp_path, ["pending"])
|
|
252
277
|
stop_block = {
|
|
253
278
|
"type": "attachment",
|
|
254
279
|
"attachment": {"type": "hook_blocking_error", "hookEvent": "Stop"},
|
|
@@ -257,6 +282,8 @@ def test_should_keep_the_turn_across_a_stop_block_and_a_meta_entry(
|
|
|
257
282
|
all_entries = [
|
|
258
283
|
TOOL_LISTING,
|
|
259
284
|
PROMPT,
|
|
285
|
+
WORK_CALL,
|
|
286
|
+
WORK_RESULT,
|
|
260
287
|
title_call("t1"),
|
|
261
288
|
title_result("t1"),
|
|
262
289
|
REPLY,
|
|
@@ -270,10 +297,8 @@ def test_should_keep_the_turn_across_a_stop_block_and_a_meta_entry(
|
|
|
270
297
|
def test_should_name_a_deferred_title_tool_in_the_reason(
|
|
271
298
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
272
299
|
) -> None:
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
)
|
|
276
|
-
assert DESKTOP_TITLE_TOOL in block_output["reason"]
|
|
300
|
+
all_entries = [DEFERRED_LISTING, PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
301
|
+
assert DESKTOP_TITLE_TOOL in block_reason(monkeypatch, capsys, tmp_path, all_entries)
|
|
277
302
|
|
|
278
303
|
|
|
279
304
|
@pytest.mark.parametrize(
|
|
@@ -290,6 +315,8 @@ def test_should_name_a_deferred_title_tool_in_the_reason(
|
|
|
290
315
|
},
|
|
291
316
|
},
|
|
292
317
|
PROMPT,
|
|
318
|
+
WORK_CALL,
|
|
319
|
+
WORK_RESULT,
|
|
293
320
|
REPLY,
|
|
294
321
|
],
|
|
295
322
|
],
|
|
@@ -311,3 +338,59 @@ def test_should_pass_when_the_transcript_cannot_be_read(
|
|
|
311
338
|
"sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
|
|
312
339
|
)
|
|
313
340
|
assert (session_title_turn_gate.main(), capsys.readouterr().out) == (0, "")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_should_return_the_last_title_that_came_back_without_an_error() -> None:
|
|
344
|
+
all_entries = [
|
|
345
|
+
title_call("t1", title=HOURGLASS_TITLE),
|
|
346
|
+
title_result("t1"),
|
|
347
|
+
title_call("t2", title=RED_FLAG_TITLE),
|
|
348
|
+
title_result("t2", is_error=True),
|
|
349
|
+
]
|
|
350
|
+
assert session_title_turn_gate.shown_title(all_entries) == HOURGLASS_TITLE
|
|
351
|
+
assert session_title_turn_gate.shown_title([PROMPT]) is None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@pytest.mark.parametrize(
|
|
355
|
+
("title", "expected_status"),
|
|
356
|
+
[(RED_FLAG_TITLE, "\U0001f6a9"), ("⏳️ Export", "⏳"), ("Export", None)],
|
|
357
|
+
)
|
|
358
|
+
def test_should_read_the_status_emoji_without_its_variation_selector(
|
|
359
|
+
title: str, expected_status: str | None
|
|
360
|
+
) -> None:
|
|
361
|
+
assert session_title_turn_gate.title_status(title) == expected_status
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@pytest.mark.parametrize(
|
|
365
|
+
("title", "is_ask_turn", "all_statuses", "is_expected_stale"),
|
|
366
|
+
[
|
|
367
|
+
(RED_FLAG_TITLE, True, [], True),
|
|
368
|
+
(RED_FLAG_TITLE, False, [], False),
|
|
369
|
+
("✅ Gate", False, ["pending"], True),
|
|
370
|
+
("✅ Gate", True, ["completed"], False),
|
|
371
|
+
("✅ Gate", True, ["completed", "deleted"], False),
|
|
372
|
+
(HOURGLASS_TITLE, True, ["completed", "deleted"], True),
|
|
373
|
+
(HOURGLASS_TITLE, True, ["completed"], True),
|
|
374
|
+
(HOURGLASS_TITLE, True, ["in_progress"], False),
|
|
375
|
+
(HOURGLASS_TITLE, True, [], False),
|
|
376
|
+
],
|
|
377
|
+
)
|
|
378
|
+
def test_should_call_a_status_stale_only_when_the_session_state_contradicts_it(
|
|
379
|
+
title: str, is_ask_turn: bool, all_statuses: list[str], is_expected_stale: bool
|
|
380
|
+
) -> None:
|
|
381
|
+
all_items = [
|
|
382
|
+
TaskItem(str(each_number), "Step", each_status)
|
|
383
|
+
for each_number, each_status in enumerate(all_statuses)
|
|
384
|
+
]
|
|
385
|
+
stale_reason = session_title_turn_gate.stale_status_reason(title, is_ask_turn, all_items)
|
|
386
|
+
assert (stale_reason is not None) is is_expected_stale
|
|
387
|
+
if stale_reason is not None:
|
|
388
|
+
assert title in stale_reason
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def test_should_build_the_block_reason_from_the_stale_status() -> None:
|
|
392
|
+
all_entries = [*earlier_turn(RED_FLAG_TITLE), PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
393
|
+
reason_text = session_title_turn_gate.turn_block_reason(all_entries, False, True, [])
|
|
394
|
+
assert reason_text is not None
|
|
395
|
+
assert reason_text.startswith("The title '" + RED_FLAG_TITLE)
|
|
396
|
+
assert session_title_turn_gate.turn_block_reason(all_entries, False, False, []) is None
|
|
@@ -6,18 +6,18 @@ 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. Its `windows_path_violation` blocks a path the terminal would render without one of its backslashes.
|
|
9
|
-
- `blocking/open_tasks_stop_gate.py` holds the end of a turn
|
|
10
|
-
- `session/
|
|
9
|
+
- `blocking/open_tasks_stop_gate.py` holds the end of a turn once when the prompt that started it asks for work, the turn ran a work tool, and the turn made no tracker call: TaskCreate, TaskUpdate, TodoWrite, the Codex `update_plan`, or the desktop `mcp__ccd_turn__update_plan`. A work tool is any tool but a tracker call, the session title tool, `AskUserQuestion`, a hearthbot decision card, Codex `request_user_input`, `ToolSearch`, `TaskList`, or `TaskGet`. It never reads the reply text. `open_task_asks.is_ask` decides whether a prompt asks for work: a question-only prompt, an acknowledgement such as `thanks!`, an empty prompt, a prompt of only `<system-reminder>` blocks, and an envelope such as `<agent-message>` or `<task-notification>` do not. `blocking/open_tasks_transcript.py` replays the transcript for it: the turn's prompt, its tool calls, and the task list. `session_task_items` merges the task store with the transcript items whose subject the store does not hold, for the session title gate.
|
|
10
|
+
- `session/tracking_nudge.py` adds one `additionalContext` note to a prompt that asks for work. The note asks for a task list item. When the title shows the red flag or the check mark, it also asks for the hourglass before other work. It never blocks.
|
|
11
11
|
- 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.
|
|
12
12
|
- `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.
|
|
13
13
|
- `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
|
|
14
14
|
- `blocking/issue_close_handoff_gate.py` denies an issue comment or issue write that closes the issue and routes a found defect to another issue or epic.
|
|
15
15
|
- `blocking/session_title_format_gate.py` denies a session title that breaks the `<emoji> <name>` shape or has a name longer than 25 characters.
|
|
16
|
-
- `blocking/session_title_turn_gate.py` holds the end of a turn in a session that has a `__set_session_title` tool
|
|
16
|
+
- `blocking/session_title_turn_gate.py` holds the end of a turn once, in a session that has a `__set_session_title` tool, when the shown title does not fit the session's state: no title yet, a red flag after a prompt that asks for work, a check mark while the task list has open items, or an hourglass once every item is completed or deleted. A turn that set a title passes. A turn that called no tool other than the title tool passes, and so does a held turn.
|
|
17
17
|
- `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
|
|
18
18
|
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
19
19
|
- `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
|
|
20
|
-
- `blocking/resolve_thread_reply_gate.py` denies a review thread resolve until the agent's own transcript shows a reply on
|
|
20
|
+
- `blocking/resolve_thread_reply_gate.py` denies a review thread resolve until the agent's own transcript shows a reply on that same thread that posted after the last user prompt and that no successful resolve of the thread has spent. A resolve is a `resolve_review_thread` call, which names the thread by `threadID`; a `pull_request_review_write` call with method `resolve_thread`, which names it by `threadId`; a shell command with a `gh api graphql` `resolveReviewThread` mutation, which names it by a literal `PRRT_` id; or a shell call to `pulls/<n>/ccr/comments/<id>/resolve`, which names it by a comment id. A graphql resolve that names no `PRRT_` id is denied. A reply is an `add_reply_to_pull_request_comment` call, or a `gh api` call to a comment's `replies` endpoint or to `addPullRequestReviewThreadReply`, which names its thread by `PRRT_` id. A reply and a resolve by comment id match when they name the same comment, or when a successful tool result in the transcript, such as `pull_request_read` `get_review_comments`, lists the comment under the thread by its `discussion_r` anchor or `databaseId`. A reply whose result is an error, such as a refusal from `blocking/reply_length_gate.py`, does not count. With no readable transcript, the resolve passes.
|
|
21
21
|
- `blocking/verify_before_commit_gate.py` denies a `git commit` in a repository with a `.claude/skills/verify` or `.agents/skills/verify` directory until the `verify` skill loads after the last user prompt, through a Skill call or the `/verify` command in that prompt. It reads the committed paths from the staged diff, the tracked changes for `-a`, and the paths of each `git add` earlier in the same command, and passes a commit whose paths are all docs, images, or test files.
|
|
22
22
|
- `blocking/build_eval_before_eval_write_gate.py` denies a write to an eval file until a Skill call named exactly `claude-api` with args `build-eval`, or the `/claude-api build-eval` command, appears in the transcript.
|
|
23
23
|
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets. The reason tells the agent to run that command for the first three passes and `/code-review low --fix <number>` for each later pass; the gate itself counts no passes.
|
|
@@ -29,8 +29,8 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
29
29
|
|
|
30
30
|
- `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`.
|
|
31
31
|
- `blocking/final_reply_gate.py` runs on `Stop`, timeout `10` seconds in `hooks.json`.
|
|
32
|
-
- `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
|
|
33
|
-
- `session/
|
|
32
|
+
- `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 above 0 passes, because a copy Cursor imports from `~/.claude/settings.json` has no follow-up limit.
|
|
33
|
+
- `session/tracking_nudge.py` runs on `UserPromptSubmit`, matcher empty, timeout `10` seconds in `hooks.json`.
|
|
34
34
|
- 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`.
|
|
35
35
|
- `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`.
|
|
36
36
|
- `blocking/edit_marker_gate.py` runs on `PreToolUse`, matcher `mcp__.*__update_message`, timeout `10` seconds in `hooks.json`.
|
|
@@ -40,7 +40,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
40
40
|
- `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
|
|
41
41
|
- `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
|
|
42
42
|
- `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
|
|
43
|
-
- `blocking/resolve_thread_reply_gate.py` runs on `PreToolUse`, matcher `mcp__.*__resolve_review_thread`, timeout `10` seconds in `hooks.json`.
|
|
43
|
+
- `blocking/resolve_thread_reply_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__resolve_review_thread|mcp__.*__pull_request_review_write`, timeout `10` seconds in `hooks.json`.
|
|
44
44
|
- `blocking/verify_before_commit_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell`, timeout `30` seconds in `hooks.json`.
|
|
45
45
|
- `blocking/build_eval_before_eval_write_gate.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|apply_patch`, timeout `10` seconds in `hooks.json`.
|
|
46
46
|
- `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
|
|
@@ -55,16 +55,16 @@ Preconditions:
|
|
|
55
55
|
- Run each command from the repository root with Python and pytest available. The tests create transcript and payload fixtures.
|
|
56
56
|
|
|
57
57
|
- **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`.
|
|
58
|
-
- **Resolve reply.** Input is a transcript where a thread reply came back refused, or with no reply, or with one reply already spent on a resolve, then a resolve call. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_resolve_thread_reply_gate.py -q`. The adjacent test observes a deny whose reason says to post the reply, confirm it posted, then resolve. A reply that posted before the resolve passes, and an unreadable transcript passes.
|
|
58
|
+
- **Resolve reply.** Input is a transcript where a thread reply came back refused, or with no reply, or with one reply already spent on a resolve, or with a reply on another thread, then a resolve call, a `pull_request_review_write` `resolve_thread` call, a graphql `resolveReviewThread` command, or a `ccr/comments/<id>/resolve` call. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_resolve_thread_reply_gate.py -q`. The adjacent test observes a deny whose reason says to post the reply, confirm it posted, then resolve. A reply that posted before the resolve passes, and an unreadable transcript passes.
|
|
59
59
|
- **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. A reply with a plain-text `D:\Reports\.claude\report.html` is blocked; the same path in backticks or a fenced block passes.
|
|
60
|
-
- **Open tasks.** Input is a
|
|
61
|
-
- **Ask
|
|
60
|
+
- **Open tasks.** Input is a transcript whose last prompt asks for work and whose turn ran a Bash call and no tracker call. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_open_tasks_stop_gate.py -q`. The adjacent test observes one `block` decision; the same turn with a TaskCreate, TaskUpdate, TodoWrite, or desktop plan call passes. A question, an acknowledgement, a `<task-notification>` envelope, a tool-free turn, and a held stop pass. The same file covers Codex and Cursor payloads.
|
|
61
|
+
- **Ask nudge.** Input is a `UserPromptSubmit` payload with a work prompt, a question, and a `<task-notification>` envelope, after a transcript whose title shows the red flag. Run `python -m pytest packages/claude-dev-env/hooks/session/test_tracking_nudge.py -q`. The adjacent test observes a task list note and a title note for the work prompt, and empty stdout for the other two.
|
|
62
62
|
- **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.
|
|
63
63
|
- **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.
|
|
64
64
|
- **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.
|
|
65
65
|
- **Handoff close.** Input is an issue comment that says the issue is closed and that the found defect belongs to another issue. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_issue_close_handoff_gate.py -q`. The adjacent test observes a denial; a close with a plain cross-reference and a routing comment on an open issue both pass.
|
|
66
66
|
- **Session title.** Input is a set session title call with no leading emoji or a name over 25 characters. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_format_gate.py -q`. The adjacent test observes a denial that names the broken rule; a well-formed title passes.
|
|
67
|
-
- **
|
|
67
|
+
- **Stale title.** Input is a transcript that lists the title tool, an earlier turn that set the red flag, and a work prompt whose turn ran a tool and set no title. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_turn_gate.py -q`. The adjacent test observes a `block` decision that names the shown title, the tool, and the four status emoji. A carried hourglass with an item in progress passes, and so does a turn whose title call came back without an error.
|
|
68
68
|
- **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
|
|
69
69
|
- **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
|
|
70
70
|
- **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
|
|
@@ -77,7 +77,7 @@ Preconditions:
|
|
|
77
77
|
|
|
78
78
|
## Gotchas
|
|
79
79
|
|
|
80
|
-
- The
|
|
80
|
+
- The ask nudge runs only from `hooks.json`, so Codex and Cursor sessions get no turn-start note. Their Stop gate still holds a work turn with no tracker call.
|
|
81
81
|
- `blocking/step_note_gate.py` is off by default and polls for the transcript record. Subagent calls and unreadable records pass.
|
|
82
82
|
- `blocking/verify_before_acting.py` runs after the tool. A block asks the agent to check the claim and undo a contradicted change.
|
|
83
83
|
- The reply gate counts each nonempty list line as a sentence. Link targets and code spans add no words. Its banned words come from `reply-banned-words.json` in the Claude home, or the file `CLAUDE_REPLY_BANNED_WORDS_PATH` names, with built-in defaults.
|
package/hooks/hooks.json
CHANGED
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
]
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
|
-
"matcher": "mcp__.*__resolve_review_thread",
|
|
106
|
+
"matcher": "Bash|PowerShell|mcp__.*__resolve_review_thread|mcp__.*__pull_request_review_write",
|
|
107
107
|
"hooks": [
|
|
108
108
|
{
|
|
109
109
|
"type": "command",
|
|
@@ -336,7 +336,7 @@
|
|
|
336
336
|
},
|
|
337
337
|
{
|
|
338
338
|
"type": "command",
|
|
339
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/
|
|
339
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/tracking_nudge.py",
|
|
340
340
|
"timeout": 10
|
|
341
341
|
},
|
|
342
342
|
{
|