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.
@@ -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
+ )
@@ -8,15 +8,30 @@ from hooks_constants.reply_length_gate_constants import (
8
8
  )
9
9
 
10
10
  RESOLVE_TOOL_NAME_PATTERN = re.compile(r"^mcp__.+__resolve_review_thread$")
11
+ REVIEW_WRITE_TOOL_NAME_PATTERN = re.compile(r"^mcp__.+__pull_request_review_write$")
12
+ REVIEW_WRITE_METHOD_KEY = "method"
13
+ RESOLVE_THREAD_METHOD = "resolve_thread"
11
14
  ALL_REPLY_TOOL_NAME_SUFFIXES = ("__add_reply_to_pull_request_comment",)
12
15
  SHELL_COMMAND_KEY = "command"
13
16
  SHELL_REPLY_COMMAND_PATTERN = re.compile(
14
17
  r"\bgh\s+api\b.*(?:/comments/\d+/replies\b|addPullRequestReviewThreadReply)", re.DOTALL
15
18
  )
19
+ SHELL_RESOLVE_COMMAND_PATTERN = re.compile(r"\bgh\s+api\s+graphql\b.*\bresolveReviewThread\b", re.DOTALL)
20
+ SHELL_RESOLVE_COMMENT_ID_PATTERN = re.compile(r"/pulls/\d+/ccr/comments/(\d+)/resolve\b")
21
+ ALL_THREAD_ID_INPUT_KEYS = ("threadID", "threadId")
22
+ COMMENT_ID_INPUT_KEY = "commentId"
23
+ THREAD_ID_PATTERN = re.compile(r"\bPRRT_[A-Za-z0-9_-]+")
24
+ COMMENT_ANCHOR_PATTERN = re.compile(r"(?:discussion_r|\"databaseId\"\s*:\s*)(\d+)")
25
+ SHELL_REPLY_COMMENT_ID_PATTERN = re.compile(r"/comments/(\d+)/replies\b")
26
+ TEXT_BLOCK_TYPE = "text"
27
+ TEXT_FIELD = "text"
16
28
  MISSING_REPLY_REASON = (
17
- "This review thread has no reply that posted since the last user prompt and the last resolve."
18
- " Post the reply with add_reply_to_pull_request_comment, confirm its result came back without"
19
- " an error, then resolve the thread. A reply that a hook refused did not post; shorten it to"
29
+ "This review thread has no reply on it that posted since the last user prompt and the last"
30
+ " resolve of the thread. Post the reply on this thread with add_reply_to_pull_request_comment,"
31
+ " or with gh api graphql addPullRequestReviewThreadReply and the thread's PRRT_ id, confirm its"
32
+ " result came back without an error, then resolve the thread by its PRRT_ id or by the comment id the reply answered. A comment id"
33
+ " ties to a thread only through a pull_request_read get_review_comments result in this session."
34
+ " A reply that a hook refused did not post; shorten it to"
20
35
  f" at most {MAXIMUM_SENTENCE_COUNT} sentences of at most {MAXIMUM_WORDS_PER_SENTENCE} words each,"
21
36
  " footer included, and send it again."
22
37
  )
@@ -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())
@@ -2,7 +2,7 @@ from pathlib import Path
2
2
 
3
3
  import pytest
4
4
 
5
- from open_task_asks import is_ask, record_prompt, recorded_prompts
5
+ from open_task_asks import claude_home, is_ask
6
6
 
7
7
 
8
8
  @pytest.fixture(autouse=True)
@@ -41,17 +41,10 @@ def test_should_classify_work_prompts_as_asks(prompt_text: str, is_expected_ask:
41
41
  assert is_ask(prompt_text) is is_expected_ask
42
42
 
43
43
 
44
- def test_should_number_asks_and_keep_every_prompt_in_order() -> None:
45
- assert record_prompt("s-1", "Export the APKs") == "ask-1"
46
- assert record_prompt("s-1", "did it fail?") is None
47
- assert record_prompt("s-1", "Submit them") == "ask-2"
48
- assert [each_prompt.ask_id for each_prompt in recorded_prompts("s-1")] == [
49
- "ask-1",
50
- None,
51
- "ask-2",
52
- ]
44
+ def test_should_read_the_claude_home_from_claude_config_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
45
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "profile"))
46
+ assert claude_home() == tmp_path / "profile"
53
47
 
54
48
 
55
- def test_should_skip_a_session_id_that_is_not_a_plain_name() -> None:
56
- assert record_prompt("../escape", "Export the APKs") is None
57
- assert recorded_prompts("../escape") == []
49
+ def test_should_default_the_claude_home_to_the_user_home(tmp_path: Path) -> None:
50
+ assert claude_home() == tmp_path / ".claude"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.98.8",
3
+ "version": "8.98.10",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
package/rules/index.md CHANGED
@@ -24,7 +24,7 @@ Each line gives a rule in brief. When its trigger matches your task, open the gu
24
24
  **Shipping**
25
25
  - **Pull request lifecycle**. Before a commit, push, pull request action, or merge. Invoke the `pr-lifecycle` skill; a hook denies those commands until it loads.
26
26
  - **Proof before a pull request** ([guide](../docs/rule-guides/proof-before-pull-request.md)). Before you open a pull request. Run the change with and without it, and quote both under "Proof in practice".
27
- - **Session title** ([guide](../docs/rule-guides/session-title.md)). The session has a tool whose name ends in `__set_session_title`. Set `<emoji> <name>` before each turn ends.
27
+ - **Session title** ([guide](../docs/rule-guides/session-title.md)). The session has a tool whose name ends in `__set_session_title`. Set `<emoji> <name>` when the status changes.
28
28
 
29
29
  **Memory**
30
30
  - **Durable facts only** ([guide](../docs/rule-guides/memory-stores-durable-facts.md)). Writing auto-memory or a `MEMORY.md` entry. Keep only a fact that helps a fresh session weeks later.
@@ -175,8 +175,9 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
175
175
  asks the model to check a hedged claim, and the call it reads stays in place.
176
176
 
177
177
  The session title gates keep the session title in its status format. One
178
- denies a malformed title, and the other asks for a title before a turn
179
- ends. They decide the shape of a title and no code or safety policy.
178
+ denies a malformed title, and the other asks for a new title when the
179
+ shown status stops fitting. They decide the shape of a title and no code
180
+ or safety policy.
180
181
 
181
182
  The artifact page gate denies a page publish whose text is unreadable in
182
183
  dark mode. It decides how a page renders and no code or safety policy.
@@ -193,10 +194,9 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
193
194
  names open review findings. It reads only the wording of a chat card and
194
195
  decides no code or safety policy; the merge review gate holds the merge.
195
196
 
196
- The open tasks Stop gate holds the end of a turn while the session's task
197
- list has items the closing reply neither completes nor reports as
198
- ``Blocked:`` or ``Open:``. It decides when a turn may end and no code or
199
- safety policy.
197
+ The open tasks Stop gate holds the end of a turn once when the turn worked
198
+ on an ask and made no task list call. It decides when a turn may end and
199
+ no code or safety policy.
200
200
 
201
201
  The owned pull request Stop gate holds the end of a turn while a pull
202
202
  request the session opened on an owned repository is still open and the