claude-dev-env 8.98.9 → 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 CHANGED
@@ -4,7 +4,7 @@ Every rule below is always on. A rule with a link keeps its full text in that fi
4
4
  - **Diffs.** Show the hunk. No prose stand-in.
5
5
  - **Verdicts.** One line. Read files first. Quote file:line. Code beats brief. No flip-flops.
6
6
  - **Evidence** ([`~/.claude/docs/agents-guidance/evidence.md`](docs/agents-guidance/evidence.md)). Act and state only on what you or your workers saw, ran, or tested. Settle an open fact with a tool before you answer. When no permitted tool reaches it, name the check and who can run it.
7
- - **Asks** ([`~/.claude/docs/agents-guidance/asks.md`](docs/agents-guidance/asks.md)). Work every ask from the user now, without pause, until it is done with proof you saw or blocked on something only the user can give. No deadlines and no "later". Each ask is a task list item. A turn ends only when every item is completed, or the reply ends with `Blocked: <item> - <what the user must give>` or `Open: <item> - <the step you take next>` for it. The Stop hook `open_tasks_stop_gate.py` holds the turn until then.
7
+ - **Asks** ([`~/.claude/docs/agents-guidance/asks.md`](docs/agents-guidance/asks.md)). Work every ask from the user now, without pause, until it is done with proof you saw or blocked on something only the user can give. No deadlines and no "later". Each ask is a task list item, and the user follows your work there. Keep each item's status current as the work moves. The Stop hook `open_tasks_stop_gate.py` holds a turn once when it worked on an ask and left the task list unchanged.
8
8
  - **Writes.** Write/Edit only. LF. After: git diff --check, file <path>. ASCII unless the file already isn't.
9
9
  - **Edit gate.** Own worktree. Path overlap with another session → stop. Behind origin/main → rebase or report.
10
10
  - **Scope.** Min diff. No drive-by reformat. No blanket autofix without tests and revert on break.
@@ -40,7 +40,7 @@ test('reinstall drops the retired style reminder and keeps one skill reminder ho
40
40
  assert.match(userPromptHooks[1].command, /session\/skill_loaded_reminder\.py$/);
41
41
  assert.match(userPromptHooks[2].command, /lifecycle\/nested_project_hooks\.py$/);
42
42
  assert.match(userPromptHooks[3].command, /blocking\/chat_reply_gate\.py$/);
43
- assert.match(userPromptHooks[4].command, /session\/open_task_ask_recorder\.py$/);
43
+ assert.match(userPromptHooks[4].command, /session\/tracking_nudge\.py$/);
44
44
  assert.match(userPromptHooks[5].command, /session\/plugin_change_warning\.py$/);
45
45
  assert.ok(userPromptHooks.every(eachHook => !eachHook.command.includes('style_reminder_prompt')));
46
46
 
@@ -7,11 +7,9 @@
7
7
  - Work every ask as soon as it arrives, and keep working it until it is done with proof you saw. Asks have no due dates and no deadlines. Every ask is worked now, continuously.
8
8
  - Stop working an ask only when it is done with proof, or when it is blocked on something only the user can give: a decision, an approval, an account, or a credential. Put a blocked ask in front of the user in the same reply.
9
9
  - "Later", "next time", "follow-up", and "parked" are not outcomes.
10
- - Record each ask as one item in the session tracker: `TaskCreate`, `TodoWrite`, or the Codex `update_plan` tool. Mark an item completed only after you saw the proof.
11
- - A turn ends only when every item is completed, or the reply ends with one line per item that is not:
12
- - `Blocked: <item> - <what the user must give>`
13
- - `Open: <item> - <the step you take next>`
14
- - An `Open:` line is a report. Take its next step at once.
10
+ - Record each ask as one item in the session tracker: `TaskCreate`, `TodoWrite`, or the Codex `update_plan` tool. The user follows your work there.
11
+ - Keep each item's status current as the work moves: in progress when you start it, completed only after you saw the proof. A long turn updates the list at each step, so the list never lags the work.
12
+ - An item blocked on the user stays open, and the reply says what the user must give.
15
13
 
16
14
  ## Asks ledger
17
15
 
@@ -25,16 +23,19 @@ When the host keeps an asks ledger with `ledger.py` and `asks_check.py`, record
25
23
 
26
24
  The installer registers `open_tasks_stop_gate.py` as a Stop hook for each host:
27
25
 
28
- - Claude Code: `hooks.json`. A block returns the open items as the reason, and the session keeps working. An install into `~/.claude` also adds the same Stop entry to every profile under `~/.claude-profiles` that has its own `settings.json` and does not already run its own copy of the gate. A profile whose `settings.json` links to `~/.claude/settings.json` already has it. Uninstall removes only that entry. On install, a profile `settings.json` that is not valid JSON is left unchanged and listed under Action needed with its path and parse error, and the rest of the install goes on. On uninstall, such a file that still names the gate stops the uninstall before anything changes, so the manifest stays and a rerun after the fix removes the gate; one that does not name the gate is left as it is.
26
+ - Claude Code: `hooks.json`. A block returns the reason, and the session updates its task list and ends the turn. An install into `~/.claude` also adds the same Stop entry to every profile under `~/.claude-profiles` that has its own `settings.json` and does not already run its own copy of the gate. A profile whose `settings.json` links to `~/.claude/settings.json` already has it. Uninstall removes only that entry. On install, a profile `settings.json` that is not valid JSON is left unchanged and listed under Action needed with its path and parse error, and the rest of the install goes on. On uninstall, such a file that still names the gate stops the uninstall before anything changes, so the manifest stays and a rerun after the fix removes the gate; one that does not name the gate is left as it is.
29
27
  - Codex: `$CODEX_HOME/hooks.json`. Codex turns the reason into a continuation prompt.
30
28
  - Cursor: a native `stop` entry in `~/.cursor/hooks.json`. The reason comes back as `followup_message`, the next user message. A stop with status `aborted` or `error` passes, so a turn the user cancels stays silent.
31
29
 
32
- The gate merges the task store, the transcript TaskCreate, TaskUpdate, and TodoWrite calls, the Codex `update_plan` calls, and the Claude Code desktop `mcp__ccd_turn__update_plan` calls. Each new desktop plan list keeps the unfinished items of the list before it. In Claude Code, `open_task_ask_recorder.py` also records each prompt that asks for work as `#ask-N`. The gate ends the hold when each open item is named on a `Blocked:` or `Open:` line of the closing reply, and that line releases only the current stop.
30
+ The gate holds a turn once when all three hold:
33
31
 
34
- Close an item for good with one of these lines:
32
+ - The prompt that started the turn asks for work. A question, an acknowledgement such as `thanks!`, and an envelope such as `<task-notification>` do not.
33
+ - The turn ran a work tool: any tool but a tracker call, the session title tool, `AskUserQuestion`, a hearthbot decision card, Codex `request_user_input`, `ToolSearch`, `TaskList`, or `TaskGet`. A turn that only asks the user a question passes.
34
+ - The turn made no tracker call: TaskCreate, TaskUpdate, TodoWrite, the Codex `update_plan`, or the Claude Code desktop `mcp__ccd_turn__update_plan`.
35
35
 
36
- - `Done: #ask-N` for a finished ask. A plan item closes only when the plan marks it completed.
37
- - `Dropped: <#ask-N or carried item> - "<the user's words>"` for an ask or carried plan item the user called off. The quote must come from a prompt the user sent after that ask or after the plan list that held the item.
36
+ The gate never reads the reply text. A held turn passes, so the gate adds at most one step to a turn.
37
+
38
+ In Claude Code, `tracking_nudge.py` runs on `UserPromptSubmit`. For a prompt that asks for work, it adds one note that asks for a task list item, so the agent tracks the ask before it starts. The note never blocks.
38
39
 
39
40
  ## Cursor
40
41
 
@@ -43,7 +44,7 @@ Cursor keeps user rules in its app settings. The installer adds a native `sessio
43
44
  What has been seen to work, in an interactive `cursor-agent` session (version 2026.10.16, Windows):
44
45
 
45
46
  - The `sessionStart` entry runs, and the session can quote the Asks line. Without it, the session cannot.
46
- - The `stop` entry runs at the end of each turn. With an open todo left, the gate sent one follow-up, and the agent closed the turn with a `Blocked:` line. Without the gate, the turn ended at "Done." with the todo still open.
47
+ - The `stop` entry runs at the end of each turn. This test ran on the earlier version of the gate, which read `Blocked:` lines in the reply. With an open todo left, the gate sent one follow-up, and the agent closed the turn with a `Blocked:` line. Without the gate, the turn ended at "Done." with the todo still open.
47
48
  - A turn cancelled with Esc sends `stop` with status `aborted`, then `error`. The gate stays silent for both.
48
49
  - On Windows, each hook command runs in PowerShell: Cursor writes it into a temporary `.ps1` script and starts `pwsh -NonInteractive -File` on it. A command that starts with a quote gets the `&` call operator first, so a quoted Python path with a space runs. A probe from `C:/.../py space/venv/Scripts/python.exe` ran with both a bare and a quoted script path. The Cursor editor (3.24.12) has the same `&` rule in its hook runner.
49
50
 
@@ -51,5 +52,5 @@ Limits:
51
52
 
52
53
  - `cursor-agent -p` (print mode) does not send `stop` or `beforeSubmitPrompt`, so the gate never runs there.
53
54
  - The Cursor editor has not been tested.
54
- - Cursor also runs the hooks in `~/.claude/settings.json` by default. When Claude Code hooks from this package are installed in the same home, the gate runs twice at each Cursor stop. A stop hook that comes from that file has no follow-up limit in Cursor, so the gate passes once `loop_count` reaches 5.
55
+ - Cursor also runs the hooks in `~/.claude/settings.json` by default. When Claude Code hooks from this package are installed in the same home, the gate runs twice at each Cursor stop. A stop hook that comes from that file has no follow-up limit in Cursor, so the gate passes any stop whose `loop_count` is above 0.
55
56
  - One entry in the Claude Code format inside `~/.cursor/hooks.json` makes Cursor skip every hook in that file. The installer writes only native entries and converts older ones.
@@ -4,15 +4,15 @@ Back to the [rules index](../../rules/index.md).
4
4
 
5
5
  ## In brief
6
6
 
7
- Before each turn ends, set the title to `<emoji> <name>`. Use the red flag when the user must act, the construction sign when an outside dependency blocks the work, the check mark when the work is done, and the hourglass while work, CI, or a merge queue runs. The name has 25 characters or fewer, in sentence case, with concrete nouns and no IDs, dates, or branch names.
7
+ Keep the title's status current: set `<emoji> <name>` when the status changes, and set the hourglass when a new ask starts work. Use the red flag when the user must act, the construction sign when an outside dependency blocks the work, the check mark when the work is done, and the hourglass while work, CI, or a merge queue runs. The name has 25 characters or fewer, in sentence case, with concrete nouns and no IDs, dates, or branch names.
8
8
 
9
- **Enforcement:** `hooks/blocking/session_title_format_gate.py` denies a malformed title. `hooks/blocking/session_title_turn_gate.py` holds a turn that set no title.
9
+ **Enforcement:** `hooks/blocking/session_title_format_gate.py` denies a malformed title. `hooks/blocking/session_title_turn_gate.py` holds a turn once when the title shows a stale status.
10
10
 
11
11
  **When this applies:** The session has a tool whose name ends in `__set_session_title`, such as `mcp__claude-code-remote__set_session_title` in a cloud session or `mcp__ccd_session_mgmt__set_session_title` in the desktop app.
12
12
 
13
13
  ## Rule
14
14
 
15
- Before each turn ends, set the title to `<emoji> <name>`. Get the session id from `get_session` with no `session_id` when the tool asks for one.
15
+ Set the title to `<emoji> <name>` when the session's status changes, at any point in a turn. A title that still fits needs no new call. When a new ask arrives while the title shows the red flag or the check mark, set the hourglass before other work, so the user sees the session working. Get the session id from `get_session` with no `session_id` when the tool asks for one.
16
16
 
17
17
  Set the title before the final reply, and set it in silence. The reply to the user carries no mention of the title, its emoji, or the reminder that asked for it. When the Stop gate holds the turn after the answer went out, call the title tool and close with one short sentence. Leave the answer as it stands.
18
18
 
@@ -31,4 +31,15 @@ The name has 25 characters or fewer and names the main change or outcome in conc
31
31
 
32
32
  ## Enforcement
33
33
 
34
- `hooks/blocking/session_title_format_gate.py` denies a malformed title. `hooks/blocking/session_title_turn_gate.py` is a Stop hook. It holds the end of a turn 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. It applies when the transcript shows the title tool. A held turn whose title call failed may end, so a failing title tool cannot hold it forever.
34
+ `hooks/blocking/session_title_format_gate.py` denies a malformed title.
35
+
36
+ `hooks/blocking/session_title_turn_gate.py` is a Stop hook. It holds the end of a turn once when the shown title does not fit the session's state:
37
+
38
+ - No title is set yet.
39
+ - The red flag shows, and the prompt that started the turn asks for work.
40
+ - The check mark shows, and the task list has open items.
41
+ - The hourglass shows, and every task list item is completed or deleted.
42
+
43
+ A turn that set a title passes, because the agent's choice stands. A turn that called no tool other than the title tool passes, and so does a held turn. It applies when the transcript shows the title tool.
44
+
45
+ `hooks/session/tracking_nudge.py` runs when the user sends a prompt. When the prompt asks for work and the title shows the red flag or the check mark, it adds a note that asks for the hourglass before other work. The note never blocks.
@@ -1,69 +1,44 @@
1
1
  #!/usr/bin/env python3
2
- """Stop hook that keeps a session working while its task list has unreported open items.
3
-
4
- The session's open items merge these sources. A transcript item whose subject
5
- the task store holds is dropped, so the task store wins; every other item stays::
6
-
7
- ~/.claude/tasks/<task list id>/*.json (TaskCreate / TaskUpdate store)
8
- transcript TaskCreate / TaskUpdate calls (replayed in order)
9
- transcript TodoWrite input.todos (last call wins)
10
- transcript Codex update_plan arguments (last call wins)
11
- transcript mcp__ccd_turn__update_plan calls (unfinished items carry into a replacing list)
12
- ~/.claude/open-task-asks/<session id>.json (each prompt that asks for work, as #ask-N)
13
-
14
- An item with status ``pending`` or ``in_progress`` is open, and so is an ask
15
- until a reply closes it. The turn may end only when the closing reply names
16
- every open item on a report line::
17
-
18
- allow: Blocked: Rotate the deploy key - the owner pastes the org secret
19
- allow: - Open: #3 - waits on the release checks
20
- allow: Open: #ask-2 - the export job runs next
21
- block: (reply never mentions "Rotate the deploy key")
22
-
23
- A report line releases only the current stop. Two lines in any assistant text
24
- of the session close an item for good::
25
-
26
- Done: #ask-2 closes an ask; never a plan item
27
- Dropped: #ask-1 - "skip the APK export" closes an ask
28
- Dropped: Submit both APKs - "skip the APK export" closes a carried plan item
29
-
30
- A ``Dropped:`` quote must appear in a prompt the user sent after that ask, or
31
- after the last plan list that held the item.
32
-
33
- A ``Blocked:`` line that asks the user for a decision needs a question card
34
- in the same turn. The gate calls a line a decision when it holds a decision
35
- word: ok, okay, approve, approval, go-ahead, green light, sign-off, decide,
36
- decision, choose, choice, permission, consent, say-so, your call, or prefer.
37
- Any other ``Blocked:`` line names an input the user
38
- supplies, such as a secret, a file, or a step on a device, and needs no card::
39
-
40
- block: Blocked: #ask-1 - your OK to merge the app branch first (no card this turn)
41
- allow: the same line after an AskUserQuestion call this turn
42
- allow: Blocked: #ask-1 - the new deploy key secret, pasted into the session
43
-
44
- The cards are ``AskUserQuestion``, ``mcp__hearthbot__ask_decision``,
45
- ``mcp__hearthbot__request_decision``, and Codex ``request_user_input``. The
46
- check runs whether or not any item is open. A Cursor payload skips it, because
47
- the gate reads no card call from a Cursor transcript.
48
-
49
- A block sends the open items back as the reason, so the model keeps working or
50
- reports them. Claude Code ends the turn after eight blocks in a row. Codex
51
- turns the reason into a continuation prompt. A Cursor payload (it carries
52
- ``conversation_id``) also gets the reason as ``followup_message``, which
53
- Cursor submits as the next user message; Cursor sends no closing reply, so
54
- the gate reads the last assistant text from the transcript. A Cursor payload
55
- whose ``status`` is ``aborted`` or ``error`` is allowed, because Cursor would
56
- submit the follow-up after the user stopped the run, and so is one whose
57
- ``loop_count`` shows five follow-ups already sent. A TodoWrite call with
58
- ``merge`` set updates the earlier items by id, and a Cursor ``CallDynamicTool``
59
- call that wraps TodoWrite counts as that TodoWrite call.
2
+ """Stop hook that keeps the task list in step with the work an ask started.
3
+
4
+ The user follows a session's work in its task list. A turn that works on an ask
5
+ leaves a trace there: at least one tracker call after the prompt::
6
+
7
+ TaskCreate / TaskUpdate (Claude Code)
8
+ TodoWrite (Claude Code and Cursor)
9
+ update_plan (Codex)
10
+ mcp__ccd_turn__update_plan (Claude Code desktop plan)
11
+
12
+ The gate holds the turn once when all three hold::
13
+
14
+ the prompt that started the turn asks for work (open_task_asks.is_ask)
15
+ the turn ran a work tool: any tool but a tracker call, the title tool,
16
+ AskUserQuestion, a hearthbot decision card, ToolSearch, TaskList, TaskGet,
17
+ or Codex request_user_input
18
+ the turn made no tracker call
19
+
20
+ block: "Export the APKs" -> Bash, Edit -> stop
21
+ allow: "Export the APKs" -> TaskCreate, Bash, Edit -> stop
22
+ allow: "did the export fail?" -> Bash -> stop
23
+ allow: "Export the APKs" -> reply only -> stop
24
+ allow: "Export the APKs" -> AskUserQuestion -> stop
25
+
26
+ The reply text is never checked. One tracker call releases the turn, and a
27
+ turn the gate already held ends on the next stop, so the gate costs at most one
28
+ extra step per turn.
29
+
30
+ Claude Code marks a held stop with ``stop_hook_active``. Codex turns the reason
31
+ into a continuation prompt. A Cursor payload (it carries ``conversation_id``)
32
+ gets the reason as ``followup_message``, which Cursor submits as the next user
33
+ message; a Cursor stop whose ``loop_count`` shows a follow-up already sent
34
+ passes, and so does one whose ``status`` is ``aborted`` or ``error``. A TodoWrite
35
+ call that a Cursor ``CallDynamicTool`` call wraps counts as a TodoWrite call.
60
36
  """
61
37
 
62
38
  from __future__ import annotations
63
39
 
64
40
  import json
65
41
  import os
66
- import re
67
42
  import sys
68
43
  from pathlib import Path
69
44
 
@@ -76,47 +51,21 @@ if blocking_directory not in sys.path:
76
51
 
77
52
  from hooks_constants.hook_block_logger import log_hook_block
78
53
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
79
- from open_task_asks import RecordedPrompt, claude_home, recorded_prompts
54
+ from open_task_asks import claude_home, is_ask
80
55
  from open_tasks_transcript import TaskItem, TranscriptReplay, normalized_text, parsed_object, replayed_transcript
81
56
  from hooks_constants.open_tasks_stop_gate_constants import (
82
57
  ALLOW_EXIT_CODE,
83
- ASK_CLOSE_SUFFIX,
84
- ASK_ID_PREFIX,
85
- ASK_LINE_TEMPLATE,
86
- ASK_REFERENCE_PATTERN,
87
- ASK_SUBJECT_CHARACTER_LIMIT,
88
- ASK_SUBJECT_ELLIPSIS,
89
58
  BLOCK_DECISION,
90
- BLOCK_REASON_PREFIX,
91
- BLOCK_REASON_SUFFIX,
92
- BLOCKED_REPORT_KIND,
59
+ BLOCK_REASON,
93
60
  CURSOR_COMPLETED_STATUS,
94
61
  CURSOR_CONVERSATION_ID_KEY,
95
- CURSOR_FOLLOWUP_LIMIT,
96
62
  CURSOR_FOLLOWUP_MESSAGE_KEY,
97
63
  CURSOR_LOOP_COUNT_KEY,
98
64
  CURSOR_STATUS_KEY,
99
- DECISION_BLOCK_REASON_PREFIX,
100
- DECISION_BLOCK_REASON_SUFFIX,
101
65
  DECISION_KEY,
102
- DECISION_LINE_TEMPLATE,
103
- DECISION_WORD_PATTERN,
104
- DONE_LINE_PATTERN,
105
- DONE_TEXT_GROUP_INDEX,
106
- DROPPED_LINE_PATTERN,
107
- DROPPED_QUOTE_GROUP_INDEX,
108
- DROPPED_TARGET_GROUP_INDEX,
109
66
  HOOK_EVENT_NAME,
110
- INCOMPLETE_STATUSES,
111
- ITEM_LINE_TEMPLATE,
112
- LAST_ASSISTANT_MESSAGE_KEY,
113
- PENDING_STATUS,
114
67
  REASON_KEY,
115
- REPORT_KIND_GROUP_INDEX,
116
- REPORT_LINE_PATTERN,
117
- REPLY_TEXT_SEPARATOR,
118
- REPORTED_TEXT_GROUP_INDEX,
119
- SESSION_ID_KEY,
68
+ STOP_HOOK_ACTIVE_KEY,
120
69
  TASK_FILE_GLOB,
121
70
  TASK_ID_KEY,
122
71
  TASK_LIST_ID_ENVIRONMENT_VARIABLE,
@@ -126,7 +75,6 @@ from hooks_constants.open_tasks_stop_gate_constants import (
126
75
  TASKS_DIRECTORY_NAME,
127
76
  TRANSCRIPT_PATH_KEY,
128
77
  UTF8_ENCODING,
129
- WORD_BOUNDARY_PATTERN,
130
78
  )
131
79
 
132
80
 
@@ -157,80 +105,6 @@ def task_store_items(session_id: object) -> list[TaskItem] | None:
157
105
  return [each_item for each_item in all_items if each_item is not None]
158
106
 
159
107
 
160
- def _line_matches(line_pattern: str, all_texts: list[str]) -> list[re.Match[str]]:
161
- all_matches = (
162
- re.match(line_pattern, each_line, re.IGNORECASE) for each_text in all_texts for each_line in each_text.splitlines()
163
- )
164
- return [each_match for each_match in all_matches if each_match]
165
-
166
-
167
- def _reported_texts(reply_text: str) -> list[str]:
168
- return [
169
- normalized_text(each_match.group(REPORTED_TEXT_GROUP_INDEX))
170
- for each_match in _line_matches(REPORT_LINE_PATTERN, [reply_text])
171
- ]
172
-
173
-
174
- def is_reported(task_item: TaskItem, reply_text: str) -> bool:
175
- """Return True when a Blocked: or Open: line of the reply names the item."""
176
- subject_text = normalized_text(task_item.subject)
177
- number_pattern = re.escape(TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=task_item.item_id)) + WORD_BOUNDARY_PATTERN
178
- return any(
179
- (subject_text and subject_text in each_text) or re.search(number_pattern, each_text)
180
- for each_text in _reported_texts(reply_text)
181
- )
182
-
183
-
184
- def _is_quoted_after(quote_text: str, all_later_prompts: list[RecordedPrompt]) -> bool:
185
- normalized_quote = normalized_text(quote_text)
186
- return bool(normalized_quote) and any(
187
- normalized_quote in normalized_text(each_prompt.text) for each_prompt in all_later_prompts
188
- )
189
-
190
-
191
- def _ask_subject(prompt_text: str) -> str:
192
- first_line = prompt_text.strip().splitlines()[0]
193
- if len(first_line) <= ASK_SUBJECT_CHARACTER_LIMIT:
194
- return first_line
195
- return first_line[:ASK_SUBJECT_CHARACTER_LIMIT] + ASK_SUBJECT_ELLIPSIS
196
-
197
-
198
- def open_ask_items(all_prompts: list[RecordedPrompt], all_closing_texts: list[str]) -> list[TaskItem]:
199
- """Return each recorded ask that no ``Done:`` line and no quoted ``Dropped:`` line closes."""
200
- all_done_ids = {
201
- each_reference
202
- for each_match in _line_matches(DONE_LINE_PATTERN, all_closing_texts)
203
- for each_reference in re.findall(ASK_REFERENCE_PATTERN, each_match.group(DONE_TEXT_GROUP_INDEX))
204
- }
205
- all_drop_matches = _line_matches(DROPPED_LINE_PATTERN, all_closing_texts)
206
- all_open_asks = []
207
- for each_position, each_prompt in enumerate(all_prompts):
208
- if not each_prompt.ask_id or each_prompt.ask_id in all_done_ids:
209
- continue
210
- is_dropped = any(
211
- each_prompt.ask_id in re.findall(ASK_REFERENCE_PATTERN, each_match.group(DROPPED_TARGET_GROUP_INDEX))
212
- and _is_quoted_after(each_match.group(DROPPED_QUOTE_GROUP_INDEX), all_prompts[each_position + 1 :])
213
- for each_match in all_drop_matches
214
- )
215
- if not is_dropped:
216
- all_open_asks.append(TaskItem(each_prompt.ask_id, _ask_subject(each_prompt.text), PENDING_STATUS))
217
- return all_open_asks
218
-
219
-
220
- def is_dropped_carried_item(task_item: TaskItem, all_prompts: list[RecordedPrompt], all_closing_texts: list[str]) -> bool:
221
- """Return True when a ``Dropped:`` line names the carried item and quotes a prompt sent after its list."""
222
- listed_at = task_item.listed_at
223
- subject_text = normalized_text(task_item.subject)
224
- if not task_item.is_carried or listed_at is None or not subject_text:
225
- return False
226
- all_later_prompts = [each_prompt for each_prompt in all_prompts if each_prompt.recorded_at > listed_at]
227
- return any(
228
- subject_text in normalized_text(each_match.group(DROPPED_TARGET_GROUP_INDEX))
229
- and _is_quoted_after(each_match.group(DROPPED_QUOTE_GROUP_INDEX), all_later_prompts)
230
- for each_match in _line_matches(DROPPED_LINE_PATTERN, all_closing_texts)
231
- )
232
-
233
-
234
108
  def _subject_key(task_item: TaskItem) -> str:
235
109
  return normalized_text(task_item.subject) or TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=task_item.item_id)
236
110
 
@@ -249,117 +123,44 @@ def deduplicated_by_subject(all_sources: list[list[TaskItem]]) -> list[TaskItem]
249
123
  return all_kept_items
250
124
 
251
125
 
252
- def closing_reply_text(all_hook_input_fields: dict[str, object], replay: TranscriptReplay) -> str:
253
- """Return the closing reply from the hook input, or the last assistant text of the transcript."""
254
- reply_value = all_hook_input_fields.get(LAST_ASSISTANT_MESSAGE_KEY)
255
- return reply_value if isinstance(reply_value, str) else replay.last_assistant_text
256
-
257
-
258
- def uncarded_decision_lines(reply_text: str, replay: TranscriptReplay) -> list[str]:
259
- """Return each ``Blocked:`` line that asks for a decision when no question card ran this turn."""
260
- if replay.has_turn_decision_card:
261
- return []
262
- return [
263
- each_match.group(0).strip()
264
- for each_match in _line_matches(REPORT_LINE_PATTERN, [reply_text])
265
- if each_match.group(REPORT_KIND_GROUP_INDEX).lower() == BLOCKED_REPORT_KIND
266
- and re.search(DECISION_WORD_PATTERN, each_match.group(REPORTED_TEXT_GROUP_INDEX), re.IGNORECASE)
267
- ]
268
-
269
-
270
- def unreported_open_items(all_hook_input_fields: dict[str, object], replay: TranscriptReplay) -> list[TaskItem]:
271
- """Return the open task items and asks the closing reply does not report."""
272
- session_id = all_hook_input_fields.get(SESSION_ID_KEY)
273
- reply_text = closing_reply_text(all_hook_input_fields, replay)
274
- all_closing_texts = [*replay.all_assistant_texts, reply_text]
275
- all_prompts = recorded_prompts(session_id)
276
- all_replayed_items = [
277
- each_item
278
- for each_item in replay.items() or []
279
- if not is_dropped_carried_item(each_item, all_prompts, all_closing_texts)
280
- ]
281
- all_items = [
282
- *deduplicated_by_subject([task_store_items(session_id) or [], all_replayed_items]),
283
- *open_ask_items(all_prompts, all_closing_texts),
284
- ]
285
- return [
286
- each_item
287
- for each_item in all_items
288
- if each_item.status in INCOMPLETE_STATUSES and not is_reported(each_item, reply_text)
289
- ]
290
-
291
-
292
- def _item_line(task_item: TaskItem) -> str:
293
- if task_item.item_id.startswith(ASK_ID_PREFIX):
294
- return ASK_LINE_TEMPLATE.format(ask_id=task_item.item_id, subject=task_item.subject)
295
- return ITEM_LINE_TEMPLATE.format(subject=task_item.subject or TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=task_item.item_id))
296
-
297
-
298
- def decision_block_reason_for(all_decision_lines: list[str]) -> str:
299
- """Return the block reason that quotes each decision line and names the question card."""
300
- all_quoted_lines = "".join(DECISION_LINE_TEMPLATE.format(line=each_line) for each_line in all_decision_lines)
301
- return DECISION_BLOCK_REASON_PREFIX + all_quoted_lines + DECISION_BLOCK_REASON_SUFFIX
302
-
303
-
304
- def block_reason_for(all_open_items: list[TaskItem]) -> str:
305
- """Return the block reason that lists each open item, and how to close an ask when one is open."""
306
- has_open_ask = any(each_item.item_id.startswith(ASK_ID_PREFIX) for each_item in all_open_items)
307
- all_item_lines = "".join(_item_line(each_item) for each_item in all_open_items)
308
- return BLOCK_REASON_PREFIX + all_item_lines + BLOCK_REASON_SUFFIX + (ASK_CLOSE_SUFFIX if has_open_ask else "")
126
+ def session_task_items(session_id: object, replay: TranscriptReplay) -> list[TaskItem]:
127
+ """Return the session's task list: the task store first, then transcript items it does not hold."""
128
+ return deduplicated_by_subject([task_store_items(session_id) or [], replay.items() or []])
309
129
 
310
130
 
311
- def block_output_for(all_hook_input_fields: dict[str, object], block_reason: str) -> dict[str, str]:
312
- """Return the block output; a Cursor payload also gets the reason as ``followup_message``."""
313
- block_output = {DECISION_KEY: BLOCK_DECISION, REASON_KEY: block_reason}
314
- if all_hook_input_fields.get(CURSOR_CONVERSATION_ID_KEY):
315
- block_output[CURSOR_FOLLOWUP_MESSAGE_KEY] = block_reason
316
- return block_output
131
+ def is_untracked_work_turn(replay: TranscriptReplay) -> bool:
132
+ """Return True when the turn worked on an ask and made no tracker call."""
133
+ return replay.has_turn_work_tool and not replay.has_turn_tracker_write and is_ask(replay.turn_prompt_text)
317
134
 
318
135
 
319
- def is_unfinished_cursor_stop(all_hook_input_fields: dict[str, object]) -> bool:
320
- """Return True for a Cursor stop whose run was aborted or ended in an error."""
136
+ def is_held_stop(all_hook_input_fields: dict[str, object]) -> bool:
137
+ """Return True when a stop hook already held this turn once, or the Cursor run did not complete."""
138
+ if all_hook_input_fields.get(STOP_HOOK_ACTIVE_KEY) is True:
139
+ return True
321
140
  if not all_hook_input_fields.get(CURSOR_CONVERSATION_ID_KEY):
322
141
  return False
323
142
  stop_status = all_hook_input_fields.get(CURSOR_STATUS_KEY)
324
- return stop_status is not None and stop_status != CURSOR_COMPLETED_STATUS
325
-
143
+ loop_count = all_hook_input_fields.get(CURSOR_LOOP_COUNT_KEY)
144
+ has_sent_followup = isinstance(loop_count, int) and loop_count > 0
145
+ return has_sent_followup or (stop_status is not None and stop_status != CURSOR_COMPLETED_STATUS)
326
146
 
327
- def has_spent_cursor_followups(all_hook_input_fields: dict[str, object]) -> bool:
328
- """Return True for a Cursor stop that already sent the follow-up limit.
329
147
 
330
- Cursor caps native stop follow-ups at five per script, but a copy it imports
331
- from ``~/.claude/settings.json`` has no cap, so the gate stops at five itself.
332
- """
333
- if not all_hook_input_fields.get(CURSOR_CONVERSATION_ID_KEY):
334
- return False
335
- loop_count = all_hook_input_fields.get(CURSOR_LOOP_COUNT_KEY)
336
- return isinstance(loop_count, int) and loop_count >= CURSOR_FOLLOWUP_LIMIT
148
+ def block_output_for(all_hook_input_fields: dict[str, object], block_reason: str) -> dict[str, str]:
149
+ """Return the block output; a Cursor payload also gets the reason as ``followup_message``."""
150
+ block_output = {DECISION_KEY: BLOCK_DECISION, REASON_KEY: block_reason}
151
+ if all_hook_input_fields.get(CURSOR_CONVERSATION_ID_KEY):
152
+ block_output[CURSOR_FOLLOWUP_MESSAGE_KEY] = block_reason
153
+ return block_output
337
154
 
338
155
 
339
156
  def main() -> int:
340
157
  hook_input = read_hook_input_dictionary_from_stdin()
341
- if hook_input is None or is_unfinished_cursor_stop(hook_input) or has_spent_cursor_followups(hook_input):
158
+ if hook_input is None or is_held_stop(hook_input):
342
159
  return ALLOW_EXIT_CODE
343
- replay = replayed_transcript(hook_input.get(TRANSCRIPT_PATH_KEY))
344
- all_open_items = unreported_open_items(hook_input, replay)
345
- all_decision_lines = (
346
- [] if hook_input.get(CURSOR_CONVERSATION_ID_KEY) else uncarded_decision_lines(closing_reply_text(hook_input, replay), replay)
347
- )
348
- if not all_open_items and not all_decision_lines:
160
+ if not is_untracked_work_turn(replayed_transcript(hook_input.get(TRANSCRIPT_PATH_KEY))):
349
161
  return ALLOW_EXIT_CODE
350
- all_reason_parts = [
351
- *([block_reason_for(all_open_items)] if all_open_items else []),
352
- *([decision_block_reason_for(all_decision_lines)] if all_decision_lines else []),
353
- ]
354
- block_reason = REPLY_TEXT_SEPARATOR.join(all_reason_parts)
355
- reply_value = hook_input.get(LAST_ASSISTANT_MESSAGE_KEY)
356
- log_hook_block(
357
- Path(__file__).name,
358
- HOOK_EVENT_NAME,
359
- block_reason,
360
- offending_input_preview=reply_value if isinstance(reply_value, str) else None,
361
- )
362
- sys.stdout.write(json.dumps(block_output_for(hook_input, block_reason)))
162
+ log_hook_block(Path(__file__).name, HOOK_EVENT_NAME, BLOCK_REASON)
163
+ sys.stdout.write(json.dumps(block_output_for(hook_input, BLOCK_REASON)))
363
164
  return ALLOW_EXIT_CODE
364
165
 
365
166