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.
@@ -6,14 +6,14 @@ 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.
@@ -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`.
@@ -57,14 +57,14 @@ Preconditions:
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
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
@@ -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
  {
@@ -1,13 +1,12 @@
1
1
  """Constants for the open tasks Stop gate."""
2
2
 
3
3
  HOOK_EVENT_NAME = "Stop"
4
- SESSION_ID_KEY = "session_id"
5
4
  TRANSCRIPT_PATH_KEY = "transcript_path"
6
- LAST_ASSISTANT_MESSAGE_KEY = "last_assistant_message"
7
5
  DECISION_KEY = "decision"
8
6
  BLOCK_DECISION = "block"
9
7
  REASON_KEY = "reason"
10
8
  ALLOW_EXIT_CODE = 0
9
+ STOP_HOOK_ACTIVE_KEY = "stop_hook_active"
11
10
  UTF8_ENCODING = "utf-8"
12
11
  WORD_SEPARATOR = " "
13
12
  TASK_LIST_ID_ENVIRONMENT_VARIABLE = "CLAUDE_CODE_TASK_LIST_ID"
@@ -38,8 +37,6 @@ CURSOR_FOLLOWUP_MESSAGE_KEY = "followup_message"
38
37
  CURSOR_STATUS_KEY = "status"
39
38
  CURSOR_COMPLETED_STATUS = "completed"
40
39
  CURSOR_LOOP_COUNT_KEY = "loop_count"
41
- CURSOR_FOLLOWUP_LIMIT = 5
42
- REPLY_TEXT_SEPARATOR = "\n"
43
40
  TASK_UPDATE_ID_KEY = "taskId"
44
41
  TODO_WRITE_TOOL_NAME = "TodoWrite"
45
42
  DYNAMIC_TOOL_CALL_NAME = "CallDynamicTool"
@@ -59,51 +56,15 @@ CODEX_STEP_KEY = "step"
59
56
  FIRST_ITEM_NUMBER = 1
60
57
  TASK_CREATED_PATTERN = r"Task #(\d+) created"
61
58
  CREATED_TASK_ID_GROUP_INDEX = 1
62
- REPORT_LINE_PATTERN = r"^\s*(?:[-*]\s*)?(open|blocked)\b\s*[:\-]?\s*(.*)$"
63
- REPORTED_TEXT_GROUP_INDEX = 2
64
- WORD_BOUNDARY_PATTERN = r"\b"
65
59
  TASK_NUMBER_REFERENCE_TEMPLATE = "#{task_id}"
66
- ITEM_LINE_TEMPLATE = "\n- {subject}"
67
- BLOCK_REASON_PREFIX = "The task list still has incomplete items that this reply does not report:"
68
- BLOCK_REASON_SUFFIX = (
69
- "\nKeep working each one now. End the reply with one line per item you cannot finish this turn:"
70
- " 'Blocked: <item> - <what the user must give>' when only the user can unblock it, or"
71
- " 'Open: <item> - <the step you take next>' when you are still working it."
72
- " When the user must decide, choose, or approve, ask on a question card (AskUserQuestion,"
73
- " or mcp__hearthbot__ask_decision / request_decision) this turn, then add the Blocked line."
74
- )
75
- BLOCKED_REPORT_KIND = "blocked"
76
- REPORT_KIND_GROUP_INDEX = 1
77
- DECISION_WORD_PATTERN = (
78
- r"\b(?:ok|okay|approve[sd]?|approval|go-?ahead|green\s+light|sign-?off|decide[sd]?|decision"
79
- r"|choose|choice|permission|consent|say-?so|your\s+call|prefer(?:s|ence)?)\b"
80
- )
81
- DECISION_CARD_TOOL_PATTERN = (
82
- r"(?:^|__)AskUserQuestion$|hearthbot__(?:ask|request)_decision$|^request_user_input(?:_async)?$"
83
- )
84
60
  ALL_SKIPPED_PROMPT_FLAGS = ("isMeta", "isSidechain", "isCompactSummary")
85
61
  USER_ROLE = "user"
86
62
  USER_RECORD_TYPE = "user"
87
63
  CODEX_MESSAGE_TYPE = "message"
88
- CODEX_CARD_EVENT = "card"
89
- CODEX_PROMPT_EVENT = "prompt"
90
64
  STOP_HOOK_FEEDBACK_PREFIX = "Stop hook feedback"
91
- DECISION_LINE_TEMPLATE = "\n- {line}"
92
- DECISION_BLOCK_REASON_PREFIX = "This reply asks the user for a decision in a Blocked line, with no question card this turn:"
93
- DECISION_BLOCK_REASON_SUFFIX = (
94
- "\nPut the decision on a question card now: call AskUserQuestion, or mcp__hearthbot__ask_decision or"
95
- " mcp__hearthbot__request_decision, with two or three choices. Then end the reply with the Blocked line."
96
- )
97
- ASK_CLOSE_SUFFIX = (
98
- "\nClose a finished ask with a 'Done: #ask-N' line. Close an ask or a carried plan item the user"
99
- " called off with 'Dropped: <#ask-N or item> - \"<the user's words that called it off>\"'."
100
- )
101
65
  COMPLETED_STATUS = "completed"
102
66
  IN_PROGRESS_STATUS = "in_progress"
103
67
  ASSISTANT_RECORD_TYPE = "assistant"
104
- RECORD_TIMESTAMP_KEY = "timestamp"
105
- UTC_SUFFIX = "Z"
106
- UTC_OFFSET_TEXT = "+00:00"
107
68
  CCD_PLAN_TOOL_NAME = "mcp__ccd_turn__update_plan"
108
69
  CCD_PLAN_ITEMS_KEY = "items"
109
70
  CCD_PLAN_CONTENT_KEY = "content"
@@ -111,21 +72,7 @@ CCD_PLAN_DONE_KEY = "done"
111
72
  CCD_PLAN_START_KEY = "start"
112
73
  CCD_PLAN_ADVANCE_KEY = "advance"
113
74
  CCD_PLAN_ITEM_ID_TEMPLATE = "plan-{position}"
114
- ASK_ID_PREFIX = "ask-"
115
- ASK_ID_TEMPLATE = "ask-{ask_number}"
116
- ASK_LINE_TEMPLATE = "\n- #{ask_id}: {subject}"
117
- ASK_SUBJECT_CHARACTER_LIMIT = 120
118
- ASK_SUBJECT_ELLIPSIS = "..."
119
- ASKS_DIRECTORY_NAME = "open-task-asks"
120
- ASKS_FILE_SUFFIX = ".json"
121
- ASKS_TEMPORARY_PREFIX = ".open-task-asks-"
122
- ASKS_TEMPORARY_SUFFIX = ".tmp"
123
- ASKS_PROMPTS_KEY = "prompts"
124
- ASK_PROMPT_TEXT_KEY = "text"
125
- ASK_PROMPT_RECORDED_AT_KEY = "recorded_at"
126
- ASK_PROMPT_ID_KEY = "ask_id"
127
75
  PROMPT_KEY = "prompt"
128
- SAFE_SESSION_ID_PATTERN = r"[\w-]+"
129
76
  SENTENCE_SPLIT_PATTERN = r"(?<=[.!?])\s+|\n+"
130
77
  QUESTION_END_PATTERN = r"\?\s*$"
131
78
  YES_OR_NO_END_PATTERN = r"\byes\s+or\s+no\W*$"
@@ -140,9 +87,29 @@ ACKNOWLEDGEMENT_PATTERN = (
140
87
  r"|sounds\s+good|looks\s+good|lgtm)(?:[\s,]+(?:thanks?|thank\s+you))?\W*$"
141
88
  )
142
89
  POLITE_REQUEST_START_PATTERN = r"^\s*(?:please\b|(?:can|could|would|will)\s+you\b)"
143
- DONE_LINE_PATTERN = r"^\s*(?:[-*]\s*)?done\b\s*[:\-]?\s*(.*)$"
144
- DONE_TEXT_GROUP_INDEX = 1
145
- ASK_REFERENCE_PATTERN = r"#(ask-\d+)\b"
146
- DROPPED_LINE_PATTERN = r"^\s*(?:[-*]\s*)?dropped\b\s*[:\-]?\s*(.+?)\s+-\s+[\"“](.+?)[\"”]\s*$"
147
- DROPPED_TARGET_GROUP_INDEX = 1
148
- DROPPED_QUOTE_GROUP_INDEX = 2
90
+ ALL_TRACKER_TOOL_NAMES = frozenset({TODO_WRITE_TOOL_NAME, TASK_CREATE_TOOL_NAME, TASK_UPDATE_TOOL_NAME})
91
+ ALL_NON_WORK_TOOL_NAMES = frozenset(
92
+ {
93
+ "AskUserQuestion",
94
+ "mcp__hearthbot__ask_decision",
95
+ "mcp__hearthbot__request_decision",
96
+ "ToolSearch",
97
+ "TaskList",
98
+ "TaskGet",
99
+ "request_user_input",
100
+ "request_user_input_async",
101
+ }
102
+ )
103
+ SESSION_TITLE_TOOL_SUFFIX = "__set_session_title"
104
+ ALL_CODEX_TOOL_CALL_TYPES = frozenset({CODEX_FUNCTION_CALL_TYPE, "custom_tool_call", "local_shell_call"})
105
+ BLOCK_REASON = (
106
+ "This turn worked on the user's ask and left the task list unchanged. The user follows your work"
107
+ " there. Update it now: TaskCreate the ask if it has no item, and TaskUpdate each item to its"
108
+ " current status (TodoWrite or update_plan where that is the tracker). Then end the turn. Leave"
109
+ " the reply as it stands."
110
+ )
111
+ PROMPT_SUBMIT_EVENT_NAME = "UserPromptSubmit"
112
+ TASK_LIST_NUDGE = (
113
+ "The user follows your work in the task list. Give this ask a task list item (TaskCreate) and"
114
+ " keep each item's status current as the work moves."
115
+ )
@@ -60,8 +60,21 @@ ALL_TOOL_LISTING_RECORD_KEYS = ("tools", "entries")
60
60
  REMOVED_NAMES_KEY = "removedNames"
61
61
  TITLE_TOOL_DISPLAY_SEPARATOR = " or "
62
62
 
63
+ SESSION_ID_KEY = "session_id"
64
+ BLOCK_INPUT_KEY = "input"
65
+ RED_FLAG_STATUS = "\U0001f6a9"
66
+ CHECK_MARK_STATUS = "\u2705"
67
+ HOURGLASS_STATUS = "\u23f3"
68
+ NO_TITLE_REASON = "This session has no title yet."
69
+ STALE_FLAG_REASON = (
70
+ "The title '{title}' still shows the red flag from before the user's last message,"
71
+ " and that message gave you work."
72
+ )
73
+ OPEN_ITEMS_REASON = "The title '{title}' shows the work as done, and the task list has open items."
74
+ ALL_ITEMS_COMPLETED_REASON = "The title '{title}' shows work running, and every task list item is completed or deleted."
75
+
63
76
  TURN_BLOCK_REASON = (
64
- "This turn set no session title. Call {tool_name} now with '<emoji> <name>':"
77
+ "{why} Call {tool_name} now with '<emoji> <name>' for the current status:"
65
78
  " \U0001f6a9 the user must act, \U0001f6a7 an outside dependency blocks the work,"
66
79
  " \u2705 the work is done, \u23f3 work runs and nothing waits on the user."
67
80
  " When the tool is deferred, load its schema with ToolSearch first."
@@ -69,3 +82,8 @@ TURN_BLOCK_REASON = (
69
82
  " After the call, end the turn with one short closing sentence."
70
83
  " Leave the earlier reply as it stands and do not repeat it."
71
84
  )
85
+ ALL_STALE_AT_START_STATUSES = frozenset({RED_FLAG_STATUS, CHECK_MARK_STATUS})
86
+ TITLE_NUDGE_TEMPLATE = (
87
+ "The session title shows '{title}' from before this message. Set it with {tool_name} to"
88
+ " '⏳ <name>' before other work, so the user sees the session working."
89
+ )
@@ -1,67 +1,33 @@
1
- """Per-session record of the user's prompts, read by the open tasks Stop gate.
1
+ """Tell a prompt that asks for work from one that does not.
2
2
 
3
- Every prompt lands in one file outside the task store::
4
-
5
- ~/.claude/open-task-asks/<session id>.json
6
- {"prompts": [{"text": "...", "recorded_at": 1761076800.0, "ask_id": "ask-1"}, ...]}
7
-
8
- A prompt that asks for work gets the next ``ask-N`` id. A question-only
9
- prompt, an acknowledgement such as ``thanks!``, an empty prompt, a prompt of only
10
- ``<system-reminder>`` blocks, and an envelope such as ``<agent-message>`` or
11
- ``<task-notification>`` get ``ask_id`` null, and the gate still reads their
12
- text when it checks the quote on a ``Dropped:`` line.
3
+ The open tasks Stop gate, the session title gate, and the ask tracking nudge
4
+ read a prompt through ``is_ask``. A question-only prompt, an acknowledgement
5
+ such as ``thanks!``, an empty prompt, a prompt of only ``<system-reminder>``
6
+ blocks, and an envelope such as ``<agent-message>`` or ``<task-notification>``
7
+ ask for no work.
13
8
  """
14
9
 
15
10
  from __future__ import annotations
16
11
 
17
- import json
18
12
  import os
19
13
  import re
20
- import time
21
14
  from pathlib import Path
22
- from typing import NamedTuple
23
15
 
24
- from atomic_file_writer import write_text_atomically
25
16
  from hooks_constants.open_tasks_stop_gate_constants import (
26
17
  ACKNOWLEDGEMENT_PATTERN,
27
18
  ALL_ASK_ONLY_ENVELOPE_MARKERS,
28
- ASK_ID_TEMPLATE,
29
- ASK_PROMPT_ID_KEY,
30
- ASK_PROMPT_RECORDED_AT_KEY,
31
- ASK_PROMPT_TEXT_KEY,
32
- ASKS_DIRECTORY_NAME,
33
- ASKS_FILE_SUFFIX,
34
- ASKS_PROMPTS_KEY,
35
- ASKS_TEMPORARY_PREFIX,
36
- ASKS_TEMPORARY_SUFFIX,
37
19
  CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE,
38
20
  CLAUDE_HOME_DIRECTORY_NAME,
39
21
  INTERROGATIVE_START_PATTERN,
40
22
  POLITE_REQUEST_START_PATTERN,
41
23
  QUESTION_END_PATTERN,
42
- SAFE_SESSION_ID_PATTERN,
43
24
  SENTENCE_SPLIT_PATTERN,
44
25
  SYSTEM_REMINDER_BLOCK_PATTERN,
45
- UTF8_ENCODING,
46
26
  YES_OR_NO_END_PATTERN,
47
27
  )
48
28
  from hooks_constants.spawn_readiness_hook_constants import ALL_HARNESS_ENVELOPE_MARKERS
49
29
 
50
30
 
51
- class RecordedPrompt(NamedTuple):
52
- """One prompt the user sent.
53
-
54
- Attributes:
55
- text: The prompt as the user sent it, with ``<system-reminder>`` blocks removed.
56
- recorded_at: Seconds since the epoch when the recorder saw it.
57
- ask_id: ``ask-N`` for a prompt that asks for work, or None.
58
- """
59
-
60
- text: str
61
- recorded_at: float
62
- ask_id: str | None
63
-
64
-
65
31
  def claude_home() -> Path:
66
32
  """Return the Claude home directory, honoring ``CLAUDE_CONFIG_DIR``."""
67
33
  configured_directory = os.environ.get(CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE)
@@ -70,12 +36,6 @@ def claude_home() -> Path:
70
36
  return Path.home() / CLAUDE_HOME_DIRECTORY_NAME
71
37
 
72
38
 
73
- def _asks_file(session_id: object) -> Path | None:
74
- if not isinstance(session_id, str) or not re.fullmatch(SAFE_SESSION_ID_PATTERN, session_id):
75
- return None
76
- return claude_home() / ASKS_DIRECTORY_NAME / (session_id + ASKS_FILE_SUFFIX)
77
-
78
-
79
39
  def is_question_only(prompt_text: str) -> bool:
80
40
  """Return True when the prompt asks a question and asks for no work.
81
41
 
@@ -122,58 +82,3 @@ def is_ask(prompt_text: str) -> bool:
122
82
  if re.fullmatch(ACKNOWLEDGEMENT_PATTERN, stripped_text, re.IGNORECASE):
123
83
  return False
124
84
  return not is_question_only(stripped_text)
125
-
126
-
127
- def recorded_prompts(session_id: object) -> list[RecordedPrompt]:
128
- """Return every prompt recorded for the session, oldest first."""
129
- asks_file = _asks_file(session_id)
130
- if asks_file is None:
131
- return []
132
- try:
133
- all_file_fields = json.loads(asks_file.read_text(encoding=UTF8_ENCODING))
134
- except (OSError, ValueError):
135
- return []
136
- all_entries = (
137
- all_file_fields.get(ASKS_PROMPTS_KEY) if isinstance(all_file_fields, dict) else None
138
- )
139
- if not isinstance(all_entries, list):
140
- return []
141
- return [
142
- _parsed_prompt(each_entry) for each_entry in all_entries if isinstance(each_entry, dict)
143
- ]
144
-
145
-
146
- def _parsed_prompt(all_entry_fields: dict[str, object]) -> RecordedPrompt:
147
- recorded_at = all_entry_fields.get(ASK_PROMPT_RECORDED_AT_KEY)
148
- ask_id = all_entry_fields.get(ASK_PROMPT_ID_KEY)
149
- return RecordedPrompt(
150
- str(all_entry_fields.get(ASK_PROMPT_TEXT_KEY, "")),
151
- float(recorded_at) if isinstance(recorded_at, (int, float)) else 0.0,
152
- ask_id if isinstance(ask_id, str) else None,
153
- )
154
-
155
-
156
- def record_prompt(session_id: object, prompt_text: str) -> str | None:
157
- """Append the prompt to the session's file and return its ``ask-N`` id, or None for no ask."""
158
- asks_file = _asks_file(session_id)
159
- if asks_file is None:
160
- return None
161
- all_prompts = recorded_prompts(session_id)
162
- ask_count = sum(1 for each_prompt in all_prompts if each_prompt.ask_id)
163
- ask_id = ASK_ID_TEMPLATE.format(ask_number=ask_count + 1) if is_ask(prompt_text) else None
164
- all_prompts.append(RecordedPrompt(prompt_without_reminders(prompt_text), time.time(), ask_id))
165
- serialized_text = json.dumps(
166
- {ASKS_PROMPTS_KEY: [each_prompt._asdict() for each_prompt in all_prompts]}
167
- )
168
- try:
169
- write_text_atomically(
170
- asks_file,
171
- serialized_text,
172
- UTF8_ENCODING,
173
- ASKS_TEMPORARY_PREFIX,
174
- ASKS_TEMPORARY_SUFFIX,
175
- False,
176
- )
177
- except OSError:
178
- return None
179
- return ask_id
@@ -0,0 +1,124 @@
1
+ import importlib
2
+ import io
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ _SESSION_DIR = Path(__file__).resolve().parent
11
+ if str(_SESSION_DIR) not in sys.path:
12
+ sys.path.insert(0, str(_SESSION_DIR))
13
+
14
+ tracking_nudge = importlib.import_module("tracking_nudge")
15
+
16
+ NUDGE_PATH = _SESSION_DIR / "tracking_nudge.py"
17
+ TITLE_TOOL = "mcp__claude-code-remote__set_session_title"
18
+ TOOL_LISTING = {"type": "attachment", "attachment": {"tools": [{"name": TITLE_TOOL}]}}
19
+
20
+
21
+ def titled_entries(title: str) -> list[dict[str, object]]:
22
+ title_use = {"type": "tool_use", "id": "t1", "name": TITLE_TOOL, "input": {"title": title}}
23
+ title_done = {"type": "tool_result", "tool_use_id": "t1", "content": "ok"}
24
+ return [
25
+ TOOL_LISTING,
26
+ {"type": "assistant", "message": {"content": [title_use]}},
27
+ {"type": "user", "message": {"content": [title_done]}},
28
+ ]
29
+
30
+
31
+ def run_nudge(
32
+ monkeypatch: pytest.MonkeyPatch,
33
+ capsys: pytest.CaptureFixture[str],
34
+ tmp_path: Path,
35
+ prompt_text: str,
36
+ all_entries: list[dict[str, object]],
37
+ ) -> str:
38
+ transcript_path = tmp_path / "s.jsonl"
39
+ transcript_path.write_text(
40
+ "\n".join(json.dumps(each) for each in all_entries), encoding="utf-8"
41
+ )
42
+ hook_input = {
43
+ "hook_event_name": "UserPromptSubmit",
44
+ "prompt": prompt_text,
45
+ "transcript_path": str(transcript_path),
46
+ }
47
+ monkeypatch.setattr(
48
+ "sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
49
+ )
50
+ assert tracking_nudge.main() == 0
51
+ printed_output = capsys.readouterr().out
52
+ return (
53
+ json.loads(printed_output)["hookSpecificOutput"]["additionalContext"]
54
+ if printed_output
55
+ else ""
56
+ )
57
+
58
+
59
+ def test_should_remind_a_work_prompt_to_use_the_task_list(
60
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
61
+ ) -> None:
62
+ context_text = run_nudge(
63
+ monkeypatch, capsys, tmp_path, "Export the APKs", titled_entries("⏳ Export")
64
+ )
65
+ assert "TaskCreate" in context_text
66
+ assert "⏳ Export" not in context_text
67
+
68
+
69
+ @pytest.mark.parametrize("earlier_title", ["\U0001f6a9 Merge approval", "✅ Gate"])
70
+ def test_should_ask_to_refresh_a_title_that_says_the_session_waits_or_is_done(
71
+ monkeypatch: pytest.MonkeyPatch,
72
+ capsys: pytest.CaptureFixture[str],
73
+ tmp_path: Path,
74
+ earlier_title: str,
75
+ ) -> None:
76
+ context_text = run_nudge(
77
+ monkeypatch, capsys, tmp_path, "Export the APKs", titled_entries(earlier_title)
78
+ )
79
+ assert earlier_title in context_text
80
+ assert TITLE_TOOL in context_text
81
+
82
+
83
+ @pytest.mark.parametrize(
84
+ "prompt_text",
85
+ ["did the export fail?", "thanks!", "<task-notification>done</task-notification>"],
86
+ )
87
+ def test_should_stay_silent_for_a_prompt_that_asks_for_no_work(
88
+ monkeypatch: pytest.MonkeyPatch,
89
+ capsys: pytest.CaptureFixture[str],
90
+ tmp_path: Path,
91
+ prompt_text: str,
92
+ ) -> None:
93
+ assert (
94
+ run_nudge(
95
+ monkeypatch, capsys, tmp_path, prompt_text, titled_entries("\U0001f6a9 Merge approval")
96
+ )
97
+ == ""
98
+ )
99
+
100
+
101
+ def test_should_skip_the_title_note_in_a_session_without_the_title_tool() -> None:
102
+ assert tracking_nudge.title_nudge([]) is None
103
+ assert tracking_nudge.title_nudge(titled_entries("✅ Gate")) is not None
104
+
105
+
106
+ def test_should_build_the_note_only_for_a_work_prompt() -> None:
107
+ assert tracking_nudge.ask_tracking_context("did it fail?", []) is None
108
+ assert "task list" in (tracking_nudge.ask_tracking_context("Export the APKs", []) or "")
109
+
110
+
111
+ def test_should_print_the_documented_output_through_a_subprocess(tmp_path: Path) -> None:
112
+ hook_input = {"hook_event_name": "UserPromptSubmit", "prompt": "Export the APKs"}
113
+ completed_process = subprocess.run(
114
+ [sys.executable, str(NUDGE_PATH)],
115
+ input=json.dumps(hook_input),
116
+ capture_output=True,
117
+ text=True,
118
+ env={"HOME": str(tmp_path), "USERPROFILE": str(tmp_path), "PATH": ""},
119
+ check=False,
120
+ )
121
+ assert completed_process.returncode == 0
122
+ hook_output = json.loads(completed_process.stdout)["hookSpecificOutput"]
123
+ assert hook_output["hookEventName"] == "UserPromptSubmit"
124
+ assert "TaskCreate" in hook_output["additionalContext"]
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env python3
2
+ """UserPromptSubmit hook that reminds the agent to track a new ask before it starts.
3
+
4
+ The user follows a session in two places: the task list shows what the agent
5
+ does, and the title's status emoji shows whether the session needs them. A new
6
+ ask changes both, so the hook adds one short ``additionalContext`` note when the
7
+ prompt asks for work::
8
+
9
+ "Export the APKs" -> task list note
10
+ "Export the APKs", title shows a red flag or a check mark
11
+ -> task list note + title note
12
+ "did the export fail?" -> nothing
13
+ "<task-notification>...</task-notification>"
14
+ -> nothing
15
+
16
+ The note never blocks. The Stop gates ``open_tasks_stop_gate.py`` and
17
+ ``session_title_turn_gate.py`` hold the turn once only when the agent skipped it.
18
+ The title note needs the title tool in the transcript, as the title gate does.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
28
+ if hooks_root_directory not in sys.path:
29
+ sys.path.insert(0, hooks_root_directory)
30
+ blocking_directory = str(Path(__file__).resolve().parent.parent / "blocking")
31
+ if blocking_directory not in sys.path:
32
+ sys.path.insert(0, blocking_directory)
33
+
34
+ from hooks_constants.open_tasks_stop_gate_constants import (
35
+ ALLOW_EXIT_CODE,
36
+ PROMPT_KEY,
37
+ PROMPT_SUBMIT_EVENT_NAME,
38
+ TASK_LIST_NUDGE,
39
+ TRANSCRIPT_PATH_KEY,
40
+ WORD_SEPARATOR,
41
+ )
42
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
43
+ from hooks_constants.session_title_constants import (
44
+ ALL_STALE_AT_START_STATUSES,
45
+ TITLE_NUDGE_TEMPLATE,
46
+ TITLE_TOOL_DISPLAY_SEPARATOR,
47
+ )
48
+ from open_task_asks import is_ask
49
+ from session_title_turn_gate import (
50
+ available_title_tools,
51
+ read_transcript_entries,
52
+ shown_title,
53
+ title_status,
54
+ )
55
+
56
+
57
+ def title_nudge(all_entries: list[dict[str, object]]) -> str | None:
58
+ """Return the title note when the shown title says the session waits or is done, else None.
59
+
60
+ ::
61
+
62
+ title tool listed, shows "\U0001f6a9 Merge approval" -> note naming that title
63
+ title tool listed, shows "⏳ Export" -> None
64
+ no title tool in the transcript -> None
65
+
66
+ Args:
67
+ all_entries: The transcript entries, oldest first.
68
+
69
+ Returns:
70
+ The note text, or None.
71
+ """
72
+ all_title_tools = available_title_tools(all_entries)
73
+ title = shown_title(all_entries)
74
+ if (
75
+ not all_title_tools
76
+ or title is None
77
+ or title_status(title) not in ALL_STALE_AT_START_STATUSES
78
+ ):
79
+ return None
80
+ return TITLE_NUDGE_TEMPLATE.format(
81
+ title=title, tool_name=TITLE_TOOL_DISPLAY_SEPARATOR.join(all_title_tools)
82
+ )
83
+
84
+
85
+ def ask_tracking_context(prompt_text: str, all_entries: list[dict[str, object]]) -> str | None:
86
+ """Return the note for a prompt that asks for work, or None for any other prompt."""
87
+ if not is_ask(prompt_text):
88
+ return None
89
+ all_notes = [TASK_LIST_NUDGE, title_nudge(all_entries)]
90
+ return WORD_SEPARATOR.join(each_note for each_note in all_notes if each_note)
91
+
92
+
93
+ def main() -> int:
94
+ hook_input = read_hook_input_dictionary_from_stdin()
95
+ prompt_value = hook_input.get(PROMPT_KEY) if hook_input is not None else None
96
+ if hook_input is None or not isinstance(prompt_value, str):
97
+ return ALLOW_EXIT_CODE
98
+ context_text = ask_tracking_context(
99
+ prompt_value, read_transcript_entries(hook_input.get(TRANSCRIPT_PATH_KEY))
100
+ )
101
+ if context_text:
102
+ hook_output = {
103
+ "hookSpecificOutput": {
104
+ "hookEventName": PROMPT_SUBMIT_EVENT_NAME,
105
+ "additionalContext": context_text,
106
+ }
107
+ }
108
+ sys.stdout.write(json.dumps(hook_output))
109
+ return ALLOW_EXIT_CODE
110
+
111
+
112
+ if __name__ == "__main__":
113
+ sys.exit(main())