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.
@@ -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(tool_use_id: str, tool_name: str = REMOTE_TITLE_TOOL) -> dict[str, object]:
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": "✅ Gate"},
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 test_should_hold_a_turn_that_set_no_title(
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
- def test_should_hold_a_turn_whose_title_call_was_denied(
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 = [TOOL_LISTING, PROMPT, title_call("t1"), title_result("t1", is_error=True), REPLY]
130
- assert (
131
- json.loads(run_gate(monkeypatch, capsys, tmp_path, all_entries)[1])["decision"] == "block"
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 test_should_pass_a_held_turn_whose_title_call_failed(
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 = [TOOL_LISTING, PROMPT, title_call("t1"), title_result("t1", is_error=True), REPLY]
139
- assert run_gate(monkeypatch, capsys, tmp_path, all_entries, is_stop_hook_active=True) == (
140
- 0,
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 test_should_hold_a_held_turn_with_no_title_call(
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
- stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries, is_stop_hook_active=True)[1]
150
- assert json.loads(stdout_text)["decision"] == "block"
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 test_should_hold_when_the_title_was_set_only_in_an_earlier_turn(
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
- PROMPT,
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
- block_output = json.loads(run_gate(monkeypatch, capsys, tmp_path, all_entries)[1])
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
- @pytest.mark.parametrize(
171
- "all_turn_tail",
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
- title_call("t1"),
187
- title_result("t1"),
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 run_gate(monkeypatch, capsys, tmp_path, all_entries) == (0, "")
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
- TOOL_LISTING,
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
- block_output = json.loads(
274
- run_gate(monkeypatch, capsys, tmp_path, [DEFERRED_LISTING, PROMPT, REPLY])[1]
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 while the session has open items that the closing reply does not name on a `Blocked:` or `Open:` line. It merges the task store, the transcript task calls, the desktop `mcp__ccd_turn__update_plan` plan, and the recorded asks. A transcript item whose subject the task store holds is dropped, and every other item stays. `blocking/open_tasks_transcript.py` rebuilds the task list and the last reply from the transcript for it. Each new desktop plan list carries the unfinished items of the list before it after its own items, so `done` and `start` positions still point at its own items. An ask stays open until a reply line `Done: #ask-N` closes it. A line `Dropped: <#ask-N or carried item> - "<quote>"` closes an ask or a carried plan item when the quote is in a prompt the user sent after that ask or that list.
10
- - `session/open_task_ask_recorder.py` records each prompt in `~/.claude/open-task-asks/<session id>.json`, outside the task store. A prompt that asks for work gets the next `#ask-N` id. 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>` get none.
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 until a title call in that turn comes back without an error. A turn that called no tool other than the title tool may end when an earlier turn already set the title. A held turn whose title call failed may end.
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 a thread that posted after the last user prompt and that no successful resolve has spent. Each successful resolve spends one posted reply. 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`. 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.
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 5 or more passes, because a copy Cursor imports from `~/.claude/settings.json` has no follow-up limit.
33
- - `session/open_task_ask_recorder.py` runs on `UserPromptSubmit`, matcher empty, timeout `10` seconds in `hooks.json`. It prints nothing.
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 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. The same file covers an interrupted ask, a continuation plan list that leaves items out, `Done:` and `Dropped:` lines, and a carried item that a later list marks completed.
61
- - **Ask recorder.** Input is a `UserPromptSubmit` payload with a work prompt, a question, a `<task-notification>` envelope, and a polite request. Run `python -m pytest packages/claude-dev-env/hooks/session/test_open_task_ask_recorder.py -q`. The adjacent test observes `ask-1` and `ask-2` for the work prompt and the request, no ask for the other two, and empty stdout.
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
- - **Title this turn.** Input is a transcript that lists the title tool and a turn with no title call. 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 tool and the four status emoji; a turn whose title call came back without an error passes.
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 open tasks gate reads `Done:` and `Dropped:` lines in every assistant text of the transcript, so a close holds for later stops. The ask recorder runs only from `hooks.json`, so Codex and Cursor sessions get no asks.
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/open_task_ask_recorder.py",
339
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/tracking_nudge.py",
340
340
  "timeout": 10
341
341
  },
342
342
  {