claude-dev-env 8.98.8 → 8.98.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/bin/install.prompt-context.test.mjs +1 -1
- package/docs/agents-guidance/asks.md +13 -12
- package/docs/rule-guides/session-title.md +15 -4
- package/hooks/blocking/open_tasks_stop_gate.py +60 -259
- package/hooks/blocking/open_tasks_transcript.py +104 -90
- package/hooks/blocking/resolve_thread_reply_gate.py +203 -41
- package/hooks/blocking/session_title_turn_gate.py +142 -56
- package/hooks/blocking/test_open_tasks_stop_gate.py +203 -500
- package/hooks/blocking/test_open_tasks_transcript.py +123 -15
- package/hooks/blocking/test_resolve_thread_reply_gate.py +341 -25
- package/hooks/blocking/test_session_title_turn_gate.py +173 -90
- package/hooks/features/conduct-gates.md +12 -12
- package/hooks/hooks.json +2 -2
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +27 -60
- package/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +18 -3
- package/hooks/hooks_constants/session_title_constants.py +19 -1
- package/hooks/open_task_asks.py +6 -101
- package/hooks/session/test_tracking_nudge.py +124 -0
- package/hooks/session/tracking_nudge.py +113 -0
- package/hooks/test_open_task_asks.py +6 -13
- package/package.json +1 -1
- package/rules/index.md +1 -1
- package/scripts/policy_lint/adapter_configuration.py +6 -6
- package/hooks/session/open_task_ask_recorder.py +0 -38
- package/hooks/session/test_open_task_ask_recorder.py +0 -57
|
@@ -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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
18
|
-
"
|
|
19
|
-
"
|
|
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
|
-
"
|
|
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
|
+
)
|
package/hooks/open_task_asks.py
CHANGED
|
@@ -1,67 +1,33 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Tell a prompt that asks for work from one that does not.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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
|
|
45
|
-
|
|
46
|
-
assert
|
|
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
|
|
56
|
-
assert
|
|
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
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>`
|
|
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
|
|
179
|
-
|
|
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
|
|
197
|
-
list
|
|
198
|
-
|
|
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
|