claude-dev-env 8.98.9 → 8.98.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +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/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_session_title_turn_gate.py +173 -90
- package/hooks/features/conduct-gates.md +9 -9
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +27 -60
- 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
|
@@ -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
|
|
10
|
-
- `session/
|
|
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
|
|
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
|
|
33
|
-
- `session/
|
|
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
|
|
61
|
-
- **Ask
|
|
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
|
-
- **
|
|
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
|
|
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
|
@@ -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
|
+
)
|
|
@@ -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())
|